+
{t('errors:backend.reconnecting')}
)}
+ {!serverDisconnected &&
+ serverInfo?.updateAvailable === true &&
+ !!serverInfo.latestVersion &&
+ dismissedUpdateVersion !== serverInfo.latestVersion && (
+
+
+ {t('common:updateBanner', {
+ latest: serverInfo.latestVersion,
+ installed: serverInfo.version,
+ })}
+
+
+
+ )}
+
{/* Settings Panel (modal) */}
=> {
const response = await fetchWithTimeout(`${_backendUrl}/api/info`);
await assertOk(response);
diff --git a/gitnexus-web/test/unit/update-banner.test.tsx b/gitnexus-web/test/unit/update-banner.test.tsx
new file mode 100644
index 000000000..a9703b7fb
--- /dev/null
+++ b/gitnexus-web/test/unit/update-banner.test.tsx
@@ -0,0 +1,349 @@
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import App from '../../src/App';
+import i18n, { i18nReady } from '../../src/i18n';
+import {
+ UPDATE_DISMISSED_VERSION_KEY,
+ UPDATE_INFO_REFETCH_MS,
+} from '../../src/config/ui-constants';
+import type { ConnectResult, ServerInfo } from '../../src/services/backend-client';
+
+const appStateConfig = vi.hoisted(() => ({
+ initialViewMode: 'onboarding' as 'onboarding' | 'loading' | 'exploring',
+}));
+
+const backendMocks = vi.hoisted(() => ({
+ fetchServerInfo: vi.fn<() => Promise>(),
+ fetchRepos: vi.fn(async () => []),
+ connectHeartbeat: vi.fn(),
+}));
+
+vi.mock('../../src/hooks/useAppState', async () => {
+ const React = await import('react');
+ const AppStateContext = React.createContext | null>(null);
+
+ return {
+ AppStateProvider: ({ children }: { children: React.ReactNode }) => {
+ const [viewMode, setViewMode] = React.useState(appStateConfig.initialViewMode);
+ const [serverBaseUrl, setServerBaseUrl] = React.useState(null);
+ const stable = React.useRef({
+ setGraph: vi.fn(),
+ setGraphMode: vi.fn(),
+ setChatOnlyNodeCount: vi.fn(),
+ setProgress: vi.fn(),
+ setProjectName: vi.fn(),
+ setSettingsPanelOpen: vi.fn(),
+ refreshLLMSettings: vi.fn(),
+ initializeAgent: vi.fn(async () => {}),
+ startEmbeddingsWithFallback: vi.fn(),
+ setAvailableRepos: vi.fn(),
+ switchRepo: vi.fn(async () => {}),
+ setCurrentRepo: vi.fn(),
+ }).current;
+
+ return (
+
+ {children}
+
+ );
+ },
+ useAppState: () => {
+ const value = React.useContext(AppStateContext);
+ if (!value) throw new Error('Missing test AppStateProvider');
+ return value;
+ },
+ };
+});
+
+const connectResult: ConnectResult = {
+ nodes: [],
+ relationships: [],
+ repoInfo: {
+ name: 'demo',
+ path: '/workspace/demo',
+ repoPath: '/workspace/demo',
+ indexedAt: '2026-09-04T00:00:00.000Z',
+ },
+ graphSkipped: false,
+};
+
+vi.mock('../../src/components/DropZone', () => ({
+ DropZone: ({
+ onServerConnect,
+ }: {
+ onServerConnect: (result: ConnectResult, serverUrl: string) => Promise;
+ }) => (
+
+ ),
+}));
+vi.mock('../../src/components/LoadingOverlay', () => ({
+ LoadingOverlay: () => Loading view
,
+}));
+vi.mock('../../src/components/Header', () => ({ Header: () => }));
+vi.mock('../../src/components/GraphCanvas', async () => {
+ const React = await import('react');
+ return { GraphCanvas: React.forwardRef(() => Graph
) };
+});
+vi.mock('../../src/components/RightPanel', () => ({ RightPanel: () => null }));
+vi.mock('../../src/components/SettingsPanel', () => ({ SettingsPanel: () => null }));
+vi.mock('../../src/components/StatusBar', () => ({ StatusBar: () => null }));
+vi.mock('../../src/components/FileTreePanel', () => ({ FileTreePanel: () => null }));
+vi.mock('../../src/components/CodeReferencesPanel', () => ({
+ CodeReferencesPanel: () => null,
+}));
+vi.mock('../../src/core/llm/settings-service', () => ({
+ getActiveProviderConfig: () => null,
+}));
+
+vi.mock('../../src/services/backend-client', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ fetchServerInfo: backendMocks.fetchServerInfo,
+ fetchRepos: backendMocks.fetchRepos,
+ connectHeartbeat: backendMocks.connectHeartbeat,
+ };
+});
+
+const updateInfo = (latestVersion = '2.0.0'): ServerInfo => ({
+ version: '1.0.0',
+ launchContext: 'global',
+ nodeVersion: 'v22.0.0',
+ latestVersion,
+ updateAvailable: true,
+});
+
+async function connectBackend() {
+ await userEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
+}
+
+describe('update banner', () => {
+ beforeEach(async () => {
+ await i18nReady;
+ await i18n.changeLanguage('en');
+ appStateConfig.initialViewMode = 'onboarding';
+ localStorage.removeItem(UPDATE_DISMISSED_VERSION_KEY);
+ backendMocks.fetchServerInfo.mockReset();
+ backendMocks.fetchRepos.mockClear();
+ backendMocks.connectHeartbeat.mockReset();
+ backendMocks.connectHeartbeat.mockReturnValue(() => {});
+ });
+
+ afterEach(() => {
+ cleanup();
+ window.history.replaceState(null, '', '/');
+ });
+
+ it('fetches only after a backend is selected and renders interpolated update copy', async () => {
+ backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
+ render();
+
+ expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+
+ await connectBackend();
+
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'GitNexus 2.0.0 is available — this server runs 1.0.0.',
+ );
+ expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ ['false', { ...updateInfo(), updateAvailable: false }],
+ ['absent', { version: '1.0.0', launchContext: 'global', nodeVersion: 'v22.0.0' }],
+ ])('stays hidden when update state is %s', async (_label, info) => {
+ backendMocks.fetchServerInfo.mockResolvedValue(info as ServerInfo);
+ render();
+
+ await connectBackend();
+ await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1));
+
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('gives the reconnect banner priority and refetches after reconnect', async () => {
+ backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
+ render();
+ await connectBackend();
+ expect(await screen.findByRole('status')).toBeInTheDocument();
+
+ const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
+ act(() => onReconnecting());
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ expect(screen.getByText(/reconnect/i)).toBeInTheDocument();
+
+ await act(async () => onConnect());
+ await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2));
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('persists dismissal across remounts', async () => {
+ backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
+ const first = render();
+ await connectBackend();
+
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Dismiss update notification' }),
+ );
+ expect(localStorage.getItem(UPDATE_DISMISSED_VERSION_KEY)).toBe('2.0.0');
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+
+ first.unmount();
+ window.history.replaceState(null, '', '/');
+ render();
+ await connectBackend();
+ await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2));
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('re-shows after a newer version than the dismissed one appears', async () => {
+ localStorage.setItem(UPDATE_DISMISSED_VERSION_KEY, '2.0.0');
+ backendMocks.fetchServerInfo.mockResolvedValue(updateInfo('2.1.0'));
+ render();
+
+ await connectBackend();
+
+ expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
+ });
+
+ it('fails open without rendering an error UI', async () => {
+ backendMocks.fetchServerInfo.mockRejectedValue(new Error('offline'));
+ render();
+
+ await connectBackend();
+ await waitFor(() => expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1));
+
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ expect(screen.queryByText(/offline/i)).not.toBeInTheDocument();
+ });
+
+ it('never mounts on onboarding or loading views', () => {
+ backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
+ const onboarding = render();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
+
+ onboarding.unmount();
+ appStateConfig.initialViewMode = 'loading';
+ render();
+ expect(screen.getByText('Loading view')).toBeInTheDocument();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ expect(backendMocks.fetchServerInfo).not.toHaveBeenCalled();
+ });
+
+ it('has a keyboard-focusable dismiss control with an accessible label', async () => {
+ backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
+ render();
+ await connectBackend();
+
+ const dismiss = await screen.findByRole('button', { name: 'Dismiss update notification' });
+ dismiss.focus();
+ expect(dismiss).toHaveFocus();
+ await userEvent.keyboard('{Enter}');
+
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('renders translated copy in zh-CN', async () => {
+ await i18n.changeLanguage('zh-CN');
+ backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
+ render();
+
+ await connectBackend();
+
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'GitNexus 2.0.0 已发布 — 此服务器运行 1.0.0。',
+ );
+ });
+
+ it('never commits an older fetch response over a newer one', async () => {
+ const deferred: Array<(value: ServerInfo) => void> = [];
+ backendMocks.fetchServerInfo.mockImplementation(
+ () => new Promise((resolve) => deferred.push(resolve)),
+ );
+ render();
+ await connectBackend();
+
+ // A reconnect refetch starts while the connect fetch is still in flight.
+ const [onConnect, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
+ act(() => onReconnecting());
+ await act(async () => onConnect());
+ expect(deferred).toHaveLength(2);
+
+ // The newer fetch resolves first with 2.1.0; the older fetch resolves late with 2.0.0.
+ await act(async () => deferred[1](updateInfo('2.1.0')));
+ expect(await screen.findByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
+
+ await act(async () => deferred[0](updateInfo('2.0.0')));
+ expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.1.0 is available');
+ });
+
+ it('refetches server info on the slow exploring cadence', async () => {
+ vi.useFakeTimers();
+ try {
+ backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
+ render();
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
+ });
+ expect(screen.getByRole('status')).toHaveTextContent('GitNexus 2.0.0 is available');
+ expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS);
+ });
+ expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(2);
+
+ await act(async () => {
+ vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS);
+ });
+ expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(3);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('does not poll server info while the exploring session is disconnected', async () => {
+ vi.useFakeTimers();
+ try {
+ backendMocks.fetchServerInfo.mockResolvedValue(updateInfo());
+ render();
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Connect test backend' }));
+ });
+ expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
+
+ const [, onReconnecting] = backendMocks.connectHeartbeat.mock.calls[0];
+ act(() => onReconnecting());
+
+ await act(async () => {
+ vi.advanceTimersByTime(UPDATE_INFO_REFETCH_MS * 2);
+ });
+ expect(backendMocks.fetchServerInfo).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md
index 6b4520d63..091870103 100644
--- a/gitnexus/CHANGELOG.md
+++ b/gitnexus/CHANGELOG.md
@@ -4,6 +4,50 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
+## [1.6.11] - 2026-09-04
+
+### Added
+
+- **Zig is a supported language** — functions, methods, structs/enums/unions, relative and `build.zig` / `build.zig.zon` imports, and CALLS including receiver-bound dispatch. `comptime`-false branches mark CALLS as `staticGated`. The grammar is an optional dependency, so a missing native binding skips `.zig` files instead of failing install (#1432, #3161)
+- **Update notifications** — cache-first npm `latest` check with one stderr line on interactive CLI, a `doctor` line, one MCP process log, and a dismissible web banner from `/api/info`. Silent for npx, dev checkouts, and Docker; opt out with `GITNEXUS_NO_UPDATE_NOTIFIER` (#3175)
+- **`gitnexus auto-sync`** — scheduled SSH clone/pull and analyze from `GITNEXUS_HOME/watch_config.yml`. `gitnexus watch` is reserved and prints the split with `analyze --watch` (#2493)
+- **`analyze --watch`** — local incremental re-index with debounce, serialized writers, and last-good graph on failure (#3072)
+- **`--no-parse-cache` forces a cold parser rebuild** on analyze (#3153)
+- **Spring / JVM modeling** — vendor-suffix mapping annotations (#2883), messaging destinations (#3132), handler annotation arguments and template publishes (#3128), Kotlin decorator routes and config consumers (#3133, #3126), optional Actuator runtime import (#3107), `SpringContextUtil.getBeans` dynamic lookups (#2886), Lombok and Kotlin accessor synthesis (#2885), and Java static-wildcard / Kotlin star-import folding for route constants (#3110, #3059)
+- **More contract and route surfaces** — AsyncAPI 3.x Destination nodes (#3140), wrapped-client HTTP consumers with leading-prefix template stripping (#3111), GraphQL cross-repo contracts (#3070), and PHP generated-client `Request(method, host + resourcePath)` consumers (#3079)
+- **Wiki `grok` local CLI provider** (#3069)
+- **Optional bearer auth on the `serve` MCP route** (#3100)
+
+### Fixed
+
+- **Web UI is built from `prepack`**, not on every `npm ci` (#3166)
+- **`analyze --watch` no longer drops events** across a gitignore reload (#3159)
+- **`detect_changes` does not call a zero-symbol result a clean tree** (#3138)
+- **`includeTests` is honored** for C#, Java, Swift and PHP test paths (#2866)
+- **Decorator routes connect to their handler function** (#2865)
+- **Analyze no longer clobbers customized GitNexus skills** (#3124)
+- **Generated agent block requires graph tools** on structural reads (#3125)
+- **`group` degraded links, sync warnings and UID-only impact actually work** (#3113); Maven child coordinates parse independently of parent POMs (#3108); ambiguous sync names fail closed and `analyze --name` is honored (#3094)
+- **Grep tool honors regex, `fileFilter`, and `caseSensitive`** in serve and the web UI (#3109)
+- **Razor ViewComponent names bind to in-repo classes** (#3104)
+- **Incremental analyze skips derived layers it can reuse** (#3016, #3102)
+- **Parse-cache chunks are stable** and ParsedFile loads are cheaper (#3093)
+- **MCP omitted `repo` resolves from cwd** (#3085)
+- **`status` judges freshness by covered files**, not a dirty working tree (#3083)
+- **`impact` File risk is comparable via shared axes** (#3075, #3082), repo-relative paths resolve through `filePath` (#3074, #3084), and scope-extraction omissions are reported (#3071)
+- **Cursor hooks preserve quoted shell search patterns** (#2938)
+
+### Performance
+
+- **Six equivalent redundancies dropped** on the Java-scale emit path (#3129)
+- **V8 sidecars plus hardlinked ParsedFile restore** (#3099)
+
+### Chore / Dependencies
+
+- **Transitive CVE patches** (#3095)
+- **Major runtime bumps** — `chokidar` 4 → 5 (#3117), `graphql` 16 → 17 (#3119)
+- **Dependency bumps** across gitnexus (`js-yaml`, `qs`, `fast-uri`, `ignore`, `tsx`, `@types/node`, `onnxruntime-node`), gitnexus-web (`axios`, `mermaid`, Playwright, Vitest, Testing Library), and the CodeQL action group (#3115, #3118, #3135, #3141–#3151, #3154, #3155, #3158)
+
## [1.6.10] - 2026-08-27
### Added
diff --git a/gitnexus/README.md b/gitnexus/README.md
index f50871b71..d247483bf 100644
--- a/gitnexus/README.md
+++ b/gitnexus/README.md
@@ -236,7 +236,8 @@ gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
-gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
+gitnexus analyze --force # Rebuild graph + FTS; may reuse unchanged parser output
+gitnexus analyze --no-parse-cache # Re-parse every source file, then rebuild graph + FTS
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
@@ -263,6 +264,7 @@ gitnexus wiki --provider grok # Local Grok Build CLI (uses `grok login`, no A
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
# Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION
gitnexus doctor # Show runtime platform capabilities and embedding configuration
+gitnexus update # Install the latest published GitNexus (`npm i -g gitnexus@`)
# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
gitnexus query "" # Process-grouped hybrid search
@@ -491,6 +493,40 @@ bigger cycle) and `N` increments per published rc. Example sequence:
`1.6.3-rc.1`. See the [Releases page](https://github.com/abhigyanpatwari/GitNexus/releases)
for the full list; stable `latest` is unaffected.
+## Update notifications
+
+GitNexus checks the npm registry's `latest` dist-tag at most once every 24
+hours per installation and tells you when a newer stable version exists. The
+result is cached under `$GITNEXUS_HOME` (`~/.gitnexus` by default), so the
+check never runs on the command's hot path and never blocks output. Where the
+notice appears:
+
+- **CLI** — one line on stderr when you run a command interactively (never on
+ stdout, so `gitnexus query … | jq` and other piped output stay clean), a
+ line in `gitnexus doctor` when an update is known. Automatic notices never
+ install. `gitnexus update` checks even when notices are opted out, then
+ runs `npm i -g gitnexus@` (same idea as `claude update` /
+ `codex update`).
+- **MCP server** — one structured log record on the server's stderr per
+ process per version (visible in your host's MCP log panel). Tool results,
+ resources, prompts, and server instructions never carry update text.
+- **Web UI** — a dismissible banner when the server reports a newer version;
+ dismissal persists per version.
+
+The check is skipped entirely (no network request, no output) when `CI` is
+truthy, when the install is not an npm global/local install (npx cache, dev
+checkout, Docker image — the Docker CLI image sets the opt-out itself), or
+when opted out:
+
+| Variable | Effect |
+| --- | --- |
+| `GITNEXUS_NO_UPDATE_NOTIFIER` | Truthy (`1`, `true`, …) disables the update check on every surface. |
+| `NO_UPDATE_NOTIFIER` | Cross-tool convention; honored the same way. |
+| `npm_config_registry` | The check reads the `latest` dist-tag from this registry instead of `https://registry.npmjs.org`. Credentials are never sent, and registries that require authentication are not supported (the check silently skips). |
+
+Eval harnesses running a global install can set `GITNEXUS_NO_UPDATE_NOTIFIER`
+for a quiet registry.
+
## Troubleshooting
### `Cannot destructure property 'package' of 'node.target' as it is null`
diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json
index 924776110..5805de3ee 100644
--- a/gitnexus/package-lock.json
+++ b/gitnexus/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
- "version": "1.6.10",
+ "version": "1.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
- "version": "1.6.10",
+ "version": "1.6.11",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
@@ -1909,9 +1909,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "26.3.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
- "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
+ "version": "26.4.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
+ "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -3513,9 +3513,9 @@
}
},
"node_modules/ignore": {
- "version": "7.0.7",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.7.tgz",
- "integrity": "sha512-dML0wP6oak21rsNYCJpJB6O1BJIEwNpGrTw0URPfAk4hm0e3pRfCtzkfB6olBcXcVlU2rouCyz7lCyRB0OMVCA==",
+ "version": "7.0.8",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
+ "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
"license": "MIT",
"engines": {
"node": ">= 4"
diff --git a/gitnexus/package.json b/gitnexus/package.json
index d99734523..eda58d62b 100644
--- a/gitnexus/package.json
+++ b/gitnexus/package.json
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
- "version": "1.6.10",
+ "version": "1.6.11",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -40,6 +40,7 @@
],
"scripts": {
"build": "node scripts/build.js",
+ "build:web": "node scripts/build.js --web",
"serve": "tsx src/cli/index.ts serve",
"dev": "tsx watch src/cli/index.ts",
"test": "vitest run",
@@ -52,7 +53,7 @@
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
"prepare": "node scripts/build.js",
- "prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js",
+ "prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js --web && node scripts/assert-web-assets.mjs web",
"version": "node scripts/sync-plugin-manifests.mjs"
},
"dependencies": {
diff --git a/gitnexus/scripts/assert-web-assets.mjs b/gitnexus/scripts/assert-web-assets.mjs
new file mode 100644
index 000000000..60c3a4cad
--- /dev/null
+++ b/gitnexus/scripts/assert-web-assets.mjs
@@ -0,0 +1,35 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import path from 'node:path';
+
+const webDir = path.resolve(process.argv[2] ?? 'web');
+const indexPath = path.join(webDir, 'index.html');
+
+let html;
+try {
+ html = fs.readFileSync(indexPath, 'utf8');
+} catch (err) {
+ if (err?.code === 'ENOENT') {
+ console.error(`[web-assets] missing ${indexPath}`);
+ process.exit(1);
+ }
+ throw err;
+}
+const localRefs = [...html.matchAll(/(?:src|href)=["']([^"'#]+)["']/g)]
+ .map((match) => match[1])
+ .filter((ref) => !/^(?:[a-z]+:|\/\/|data:)/i.test(ref))
+ .map((ref) => ref.split(/[?#]/, 1)[0].replace(/^\/+/, ''));
+const assetRefs = localRefs.filter((ref) => ref.startsWith('assets/'));
+
+if (assetRefs.length === 0) {
+ console.error(`[web-assets] ${indexPath} references no assets`);
+ process.exit(1);
+}
+
+const missing = assetRefs.filter((ref) => !fs.existsSync(path.join(webDir, ref)));
+if (missing.length > 0) {
+ console.error(`[web-assets] ${indexPath} references missing assets:\n${missing.join('\n')}`);
+ process.exit(1);
+}
+
+console.log(`[web-assets] verified index.html and ${assetRefs.length} referenced asset(s)`);
diff --git a/gitnexus/scripts/build-web.js b/gitnexus/scripts/build-web.js
new file mode 100644
index 000000000..ccfc87b93
--- /dev/null
+++ b/gitnexus/scripts/build-web.js
@@ -0,0 +1,72 @@
+import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+
+export function shouldBuildWeb(argv = process.argv, env = process.env) {
+ return argv.includes('--web') || env.GITNEXUS_BUILD_WEB === '1';
+}
+
+export function shouldPreserveWebOutput(env = process.env) {
+ return (
+ env.npm_lifecycle_event === 'prepare' &&
+ (env.npm_command === 'pack' || env.npm_command === 'publish')
+ );
+}
+
+/** Build and copy the web UI when `--web` / GITNEXUS_BUILD_WEB=1 is set. */
+export function runWebBuild({
+ root,
+ dist,
+ timeoutMs,
+ argv = process.argv,
+ env = process.env,
+ fsImpl = fs,
+ exec = execSync,
+}) {
+ const webRoot = path.resolve(root, '..', 'gitnexus-web');
+ const webDest = path.join(dist, '..', 'web');
+
+ if (!shouldBuildWeb(argv, env)) {
+ if (shouldPreserveWebOutput(env)) {
+ console.log('[build] preserving prepack web UI during npm prepare');
+ } else {
+ fsImpl.rmSync(webDest, { recursive: true, force: true });
+ console.log(
+ '[build] skipping web UI and removed stale output ' +
+ '(pass --web or set GITNEXUS_BUILD_WEB=1 to include it)',
+ );
+ }
+ return { status: 'skipped', webDest };
+ }
+
+ if (!fsImpl.existsSync(path.join(webRoot, 'package.json'))) {
+ throw new Error(
+ `[build] web UI requested, but gitnexus-web was not found at ${webRoot}. ` +
+ 'Run this command from the complete monorepo checkout.',
+ );
+ }
+
+ console.log('[build] building gitnexus-web…');
+ if (!fsImpl.existsSync(path.join(webRoot, 'node_modules'))) {
+ // Deliberately untimed: this is a full second install, and killing it
+ // partway through leaves a broken tree and a misleading ETIMEDOUT.
+ // CI should install gitnexus-web itself (cached, its own step) so this
+ // fallback only fires for a local `npm pack` / `npm publish`.
+ console.log('[build] installing gitnexus-web dependencies (no local node_modules)…');
+ // String form uses the platform shell (cmd.exe / sh) so `npm` resolves to
+ // npm.cmd on Windows. execFileSync('npm') / execFileSync('npm.cmd')
+ // without a shell fails on Windows.
+ exec('npm ci', { cwd: webRoot, stdio: 'inherit' });
+ }
+ exec('npm run build', { cwd: webRoot, stdio: 'inherit', timeout: timeoutMs });
+
+ const builtIndex = path.join(webRoot, 'dist', 'index.html');
+ if (!fsImpl.existsSync(builtIndex)) {
+ throw new Error(`[build] gitnexus-web build completed without ${builtIndex}`);
+ }
+
+ fsImpl.rmSync(webDest, { recursive: true, force: true });
+ fsImpl.cpSync(path.join(webRoot, 'dist'), webDest, { recursive: true });
+ console.log('[build] copied web UI → gitnexus/web/');
+ return { status: 'built', webDest };
+}
diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js
index 044fc36ee..6cead2784 100644
--- a/gitnexus/scripts/build.js
+++ b/gitnexus/scripts/build.js
@@ -8,17 +8,18 @@
* 3. Copy gitnexus-shared/dist → dist/_shared
* 4. Rewrite bare 'gitnexus-shared' specifiers → relative paths
*/
-import { execSync } from 'node:child_process';
+import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+import { runWebBuild } from './build-web.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared');
const DIST = path.join(ROOT, 'dist');
const SHARED_DEST = path.join(DIST, '_shared');
-const DEFAULT_BUILD_TIMEOUT_MS = 300_000;
+const DEFAULT_BUILD_TIMEOUT_MS = 600_000;
function getBuildTimeoutMs() {
const raw = process.env.GITNEXUS_BUILD_TIMEOUT_MS;
@@ -51,17 +52,29 @@ if (!fs.existsSync(SHARED_ROOT)) {
process.exit(1);
}
+// Launch tsc as `node typescript/lib/tsc.js` on every OS. The `.bin/tsc` /
+// `tsc.cmd` shims are Windows-only wrappers; `execFileSync` cannot spawn a
+// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared pulls
+// TypeScript 7 optional platform packages (7+ minutes in CI).
+const tscJs = path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js');
+if (!fs.existsSync(tscJs)) {
+ console.error(
+ `[build] missing ${tscJs}. Install gitnexus dependencies first (npm ci in gitnexus/).`,
+ );
+ process.exit(1);
+}
+
+function runTsc(cwd) {
+ execFileSync(process.execPath, [tscJs], { cwd, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
+}
+
// ── 1. Build gitnexus-shared ───────────────────────────────────────
console.log('[build] compiling gitnexus-shared…');
-const tscCmd =
- process.platform === 'win32'
- ? path.join('node_modules', '.bin', 'tsc.cmd')
- : path.join('node_modules', '.bin', 'tsc');
-execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
+runTsc(SHARED_ROOT);
// ── 2. Build gitnexus ──────────────────────────────────────────────
console.log('[build] compiling gitnexus…');
-execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
+runTsc(ROOT);
// ── 3. Copy shared dist ────────────────────────────────────────────
console.log('[build] copying shared module into dist/_shared…');
@@ -104,26 +117,16 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile);
// ── 5. Make CLI entry executable ────────────────────────────────────
const cliEntry = path.join(DIST, 'cli', 'index.js');
-if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
-
-// ── 6. Build & copy web UI ──────────────────────────────────────────
-const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web');
-const WEB_DEST = path.join(DIST, '..', 'web');
-
-if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) {
- console.log('[build] building gitnexus-web…');
- if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) {
- console.log('[build] installing gitnexus-web dependencies…');
- execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
- }
- execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
-
- // Copy dist → gitnexus/web/ (shipped in the npm package)
- fs.rmSync(WEB_DEST, { recursive: true, force: true });
- fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true });
- console.log('[build] copied web UI → gitnexus/web/');
-} else {
- console.log('[build] skipping web UI (gitnexus-web not found)');
+if (process.platform !== 'win32' && fs.existsSync(cliEntry)) {
+ fs.chmodSync(cliEntry, 0o755);
}
+// ── 6. Build & copy web UI (opt-in) ─────────────────────────────────
+// Web UI is a separate package and is only required in the published
+// tarball, so it is built by `prepack --web`, not by `prepare`. Serve
+// falls back to the landing page when web/ is absent. CLI-only builds
+// delete stale web/ except during npm pack/publish prepare, which must
+// keep the prepack output.
+runWebBuild({ root: ROOT, dist: DIST, timeoutMs: BUILD_TIMEOUT_MS });
+
console.log(`[build] done — rewrote ${rewritten} files.`);
diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts
index 44e45fbbb..79169214b 100644
--- a/gitnexus/scripts/cross-platform-tests.ts
+++ b/gitnexus/scripts/cross-platform-tests.ts
@@ -200,6 +200,8 @@ const SPAWN_CLI = [
'test/integration/analyze-heap-oom-e2e.test.ts',
'test/integration/group/group-cli.test.ts',
'test/integration/cli/tool-no-index-stderr.test.ts',
+ // Real CLI spawn + directory symlinks for the update-notice parent/child path.
+ 'test/integration/cli/update-notice.test.ts',
'test/integration/setup-skills.test.ts',
'test/integration/setup-antigravity.test.ts',
'test/integration/antigravity-hook-e2e.test.ts',
diff --git a/gitnexus/src/cli/analyze-options.ts b/gitnexus/src/cli/analyze-options.ts
index 646fe8494..36f8925c5 100644
--- a/gitnexus/src/cli/analyze-options.ts
+++ b/gitnexus/src/cli/analyze-options.ts
@@ -20,6 +20,8 @@ export interface AnalyzeOptions {
/** Watch quiet period in milliseconds. */
debounce?: string;
force?: boolean;
+ /** Commander negated flag: false only when --no-parse-cache is passed. */
+ parseCache?: boolean;
repairFts?: boolean;
/**
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
diff --git a/gitnexus/src/cli/analyze-watch.ts b/gitnexus/src/cli/analyze-watch.ts
index 172a3a090..189728963 100644
--- a/gitnexus/src/cli/analyze-watch.ts
+++ b/gitnexus/src/cli/analyze-watch.ts
@@ -104,6 +104,7 @@ export async function resolveWatchOptions(
const merged = mergeAnalyzeOptions(cli, config);
const unsupported = [
['--force', cli.force],
+ ['--no-parse-cache', cli.parseCache === false],
['--repair-fts', cli.repairFts],
['--embeddings', cli.embeddings],
['--drop-embeddings', cli.dropEmbeddings],
diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts
index f2a06059e..78d13de39 100644
--- a/gitnexus/src/cli/analyze.ts
+++ b/gitnexus/src/cli/analyze.ts
@@ -788,8 +788,6 @@ const analyzeCommandImpl = async (
cliOptions?: AnalyzeOptions,
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
): Promise => {
- console.log('\n GitNexus Analyzer\n');
-
// ── Resolve the target repo root ──────────────────────────────────
// Resolved FIRST because `.gitnexusrc` is read from the repo root (not the
// caller's cwd), and config can set defaults that the validation below
@@ -1184,10 +1182,10 @@ const analyzeCommandImpl = async (
}
}
- if (options.repairFts && options.force) {
+ if (options.repairFts && (options.force || options.parseCache === false)) {
cliError(
- ' Cannot combine `--repair-fts` with `--force`. ' +
- 'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n',
+ ' Cannot combine `--repair-fts` with a full rebuild. ' +
+ 'Use `--repair-fts` alone for fast FTS-only repair.\n',
);
process.exitCode = 1;
return;
@@ -1345,10 +1343,11 @@ const analyzeCommandImpl = async (
const skipAgentsMd = skipAll || options.skipAgentsMd;
const skipSkills = skipAll || options.skipSkills;
const runOptions = {
- // Pipeline re-index — OR'd with --skills because skill generation
- // needs a fresh pipelineResult. Has no bearing on the registry
- // collision guard (see allowDuplicateName below).
- force: options.force || options.skills,
+ // Pipeline re-index — OR'd with --skills because skill generation needs
+ // a fresh pipelineResult, and with --no-parse-cache because bypassing
+ // parser output is meaningful only when the pipeline runs.
+ force: options.force || options.skills || options.parseCache === false,
+ useParseCache: options.parseCache !== false,
repairFts: options.repairFts,
embeddings: embeddingsEnabled,
embeddingsNodeLimit,
diff --git a/gitnexus/src/cli/command-banner.ts b/gitnexus/src/cli/command-banner.ts
new file mode 100644
index 000000000..bfc921894
--- /dev/null
+++ b/gitnexus/src/cli/command-banner.ts
@@ -0,0 +1,68 @@
+/**
+ * One-line identity printed before every CLI action:
+ * `GitNexus Analyzer (1.6.10)`. Goes to stderr so stdout stays
+ * pipe/JSON-safe (`gitnexus query … | jq`, `status --json`).
+ */
+
+import type { Command } from 'commander';
+import { packageVersion } from '../core/package-version.js';
+
+const SKIP_COMMAND_BANNER = new Set(['__update-check', 'help']);
+
+const SPECIAL_TITLES: Record = {
+ analyze: 'Analyzer',
+ mcp: 'MCP',
+};
+
+export function installedCliVersion(): string {
+ return packageVersion();
+}
+
+export function commandDisplayName(name: string): string {
+ return (
+ SPECIAL_TITLES[name] ??
+ name
+ .split('-')
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ')
+ );
+}
+
+export function commandBannerTitle(command: Command): string {
+ const names: string[] = [];
+ for (
+ let current: Command | null | undefined = command;
+ current?.parent;
+ current = current.parent
+ ) {
+ names.unshift(current.name());
+ }
+ return names.map(commandDisplayName).join(' ');
+}
+
+export function formatCommandBanner(
+ title: string,
+ version: string = installedCliVersion(),
+): string {
+ if (!title) return '';
+ return version ? `\n GitNexus ${title} (${version})\n` : `\n GitNexus ${title}\n`;
+}
+
+export interface WriteCommandBannerDependencies {
+ write?: (text: string) => void;
+ version?: string;
+}
+
+export function writeCommandBanner(
+ command: Command,
+ deps: WriteCommandBannerDependencies = {},
+): void {
+ if (SKIP_COMMAND_BANNER.has(command.name())) return;
+ const text = formatCommandBanner(
+ commandBannerTitle(command),
+ deps.version ?? installedCliVersion(),
+ );
+ if (!text) return;
+ (deps.write ?? ((line) => process.stderr.write(line)))(text);
+}
diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts
index 990e1c102..0c8e72e9e 100644
--- a/gitnexus/src/cli/doctor.ts
+++ b/gitnexus/src/cli/doctor.ts
@@ -25,7 +25,10 @@ import {
} from '../core/lbug/lbug-config.js';
import { diagnoseExtensionLoad } from '../core/lbug/extension-load-error.js';
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
+import { updateEligibleInstallSync } from '../core/install-context.js';
+import { readValidatedUpdateCacheSync, type ValidatedUpdateCache } from '../core/update-cache.js';
import { t } from './i18n/index.js';
+import { cachedUpdateNoticeLine } from './update-notice.js';
function isCombiningMark(codePoint: number): boolean {
return (
@@ -202,6 +205,15 @@ function nativeStatusText(check: NativeCheckResult): string {
}
}
+export function cachedUpdateDoctorLine(options: {
+ installedVersion: string;
+ eligible: boolean;
+ env: NodeJS.ProcessEnv;
+ readCache: () => ValidatedUpdateCache | null;
+}): string | null {
+ return cachedUpdateNoticeLine(options);
+}
+
export const doctorCommand = async () => {
const fingerprint = getRuntimeFingerprint();
const capabilities = getRuntimeCapabilities();
@@ -212,6 +224,13 @@ export const doctorCommand = async () => {
console.log(` ${label('doctor.labels.os', 10)}${fingerprint.platform}/${fingerprint.arch}`);
console.log(` ${label('doctor.labels.node', 10)}${fingerprint.node}`);
console.log(` ${label('doctor.labels.gitnexus', 10)}${fingerprint.gitnexus}`);
+ const updateLine = cachedUpdateDoctorLine({
+ installedVersion: fingerprint.gitnexus,
+ eligible: updateEligibleInstallSync(),
+ env: process.env,
+ readCache: () => readValidatedUpdateCacheSync(),
+ });
+ if (updateLine) console.log(` ${updateLine}`);
console.log(` ${label('doctor.labels.ladybugdb', 10)}${fingerprint.ladybugdb ?? 'unknown'}`);
// OS page size next to the LadybugDB version because the two interact:
// @ladybugdb/core < 0.18.0 assumed 4 KiB pages in its buffer manager and
diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts
index 283e99832..ede409763 100644
--- a/gitnexus/src/cli/help-i18n.ts
+++ b/gitnexus/src/cli/help-i18n.ts
@@ -22,6 +22,7 @@ const COMMAND_DESCRIPTION_KEYS = {
list: 'help.command.list.description',
status: 'help.command.status.description',
doctor: 'help.command.doctor.description',
+ update: 'help.command.update.description',
embeddings: 'help.command.embeddings.description',
'embeddings install': 'help.command.embeddings.install.description',
clean: 'help.command.clean.description',
@@ -53,6 +54,7 @@ const OPTION_DESCRIPTION_KEYS = {
'|-V, --version': 'help.option.version',
'setup|-c, --coding-agent ': 'help.option.setup.codingAgent',
'analyze|-f, --force': 'help.option.analyze.force',
+ 'analyze|--no-parse-cache': 'help.option.analyze.noParseCache',
'analyze|--repair-fts': 'help.option.analyze.repairFts',
'analyze|--embeddings [limit]': 'help.option.analyze.embeddings',
'analyze|--drop-embeddings': 'help.option.analyze.dropEmbeddings',
diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts
index 58904c48f..a560fa23e 100644
--- a/gitnexus/src/cli/i18n/en.ts
+++ b/gitnexus/src/cli/i18n/en.ts
@@ -7,6 +7,16 @@ export const en = {
'common.storage': 'Storage',
'common.deleted': 'Deleted: {{target}}',
'common.error': 'Error: {{message}}',
+ 'update.available':
+ 'GitNexus {{latestVersion}} is available (you are running {{installedVersion}}).',
+ 'update.current':
+ 'GitNexus {{installedVersion}} is current or newer than the latest stable version.',
+ 'update.installing': 'Installing with {{command}}…',
+ 'update.installed': 'Installed gitnexus@{{version}}. Restart long-running mcp/serve processes.',
+ 'update.installFailed': 'npm install failed. You can retry: {{command}}',
+ 'update.installError': 'Could not run npm: {{message}}',
+ 'update.checkFailed':
+ 'Could not check for updates (offline, private registry, or the check failed open).',
'list.title': 'Indexed Repositories ({{count}})',
'list.indexed': 'Indexed',
'list.commit': 'Commit',
@@ -165,6 +175,8 @@ export const en = {
'help.command.status.description': 'Show index status for current repo',
'help.command.doctor.description':
'Show runtime platform capabilities and embedding configuration',
+ 'help.command.update.description':
+ 'Install the latest published GitNexus globally (`npm i -g gitnexus@`).',
'help.command.embeddings.description': 'Manage the on-demand local embedding runtime',
'help.command.embeddings.install.description':
'Install the local embedding stack (@huggingface/transformers + onnxruntime-node) on demand. Heals installs where npm skipped the optional packages (e.g. behind an HTTP proxy, #2370). Downloads only from your configured npm registry — mirrors and proxies apply.',
@@ -204,7 +216,9 @@ export const en = {
'help.command.group.contracts.description': 'Inspect Contract Registry',
'help.option.setup.codingAgent':
'Configure only these coding agents (comma-separated or repeatable)',
- 'help.option.analyze.force': 'Force full re-index even if up to date',
+ 'help.option.analyze.force': 'Force graph and FTS rebuild; unchanged parser output may be reused',
+ 'help.option.analyze.noParseCache':
+ 'Re-parse every source file instead of replaying cached parser output',
'help.option.analyze.repairFts': 'Repair/rebuild search FTS indexes without full re-analysis',
'help.option.analyze.embeddings':
'Enable embedding generation for semantic search (off by default). Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.',
diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts
index de9249cc4..536b8ffb9 100644
--- a/gitnexus/src/cli/i18n/zh-CN.ts
+++ b/gitnexus/src/cli/i18n/zh-CN.ts
@@ -11,6 +11,13 @@ export const zhCN = {
'common.storage': '存储',
'common.deleted': '已删除:{{target}}',
'common.error': '错误:{{message}}',
+ 'update.available': 'GitNexus {{latestVersion}} 已发布(当前运行 {{installedVersion}})。',
+ 'update.current': 'GitNexus {{installedVersion}} 已是最新稳定版或不低于该版本。',
+ 'update.installing': '正在执行 {{command}}…',
+ 'update.installed': '已安装 gitnexus@{{version}}。请重启仍在运行的 mcp/serve 进程。',
+ 'update.installFailed': 'npm 安装失败。可重试:{{command}}',
+ 'update.installError': '无法运行 npm:{{message}}',
+ 'update.checkFailed': '无法检查更新(离线、私有仓库,或检查失败)。',
'list.title': '已索引仓库({{count}})',
'list.indexed': '索引时间',
'list.commit': '提交',
@@ -162,6 +169,8 @@ export const zhCN = {
'help.command.list.description': '列出所有已索引仓库',
'help.command.status.description': '显示当前仓库的索引状态',
'help.command.doctor.description': '显示运行平台能力和嵌入配置',
+ 'help.command.update.description':
+ '通过 npm 全局安装最新发布的 GitNexus(`npm i -g gitnexus@`)。',
'help.command.embeddings.description': '管理按需安装的本地嵌入运行时',
'help.command.embeddings.install.description':
'按需安装本地嵌入组件(@huggingface/transformers + onnxruntime-node)。修复 npm 跳过可选包的安装(例如在 HTTP 代理后,#2370)。仅从你配置的 npm registry 下载 — 镜像和代理均生效。',
@@ -192,7 +201,8 @@ export const zhCN = {
'help.command.group.query.description': '跨仓库组所有仓库搜索执行流程',
'help.command.group.contracts.description': '查看 Contract Registry',
'help.option.setup.codingAgent': '仅配置这些编码代理(逗号分隔或重复传入)',
- 'help.option.analyze.force': '即使已是最新也强制完整重建索引',
+ 'help.option.analyze.force': '强制重建图和 FTS;未更改的解析器输出可能被复用',
+ 'help.option.analyze.noParseCache': '重新解析每个源文件,不重放缓存的解析器输出',
'help.option.analyze.repairFts': '修复/重建搜索 FTS 索引,不执行完整重新分析',
'help.option.analyze.embeddings':
'启用语义搜索的嵌入生成(默认关闭)。可选 [limit] 覆盖 50,000 节点安全上限;传 0 可完全禁用上限。',
diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts
index 6851044c0..70238dc00 100644
--- a/gitnexus/src/cli/index.ts
+++ b/gitnexus/src/cli/index.ts
@@ -4,7 +4,7 @@
// Removing it from here improves MCP server startup time significantly.
import { Command } from 'commander';
-import { createRequire } from 'node:module';
+import { packageVersion } from '../core/package-version.js';
import {
createAnalyzerLbugLazyAction,
createLazyAction,
@@ -14,16 +14,16 @@ import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.j
import { registerGroupCommands } from './group.js';
import { localizeCliHelp } from './help-i18n.js';
import { t } from './i18n/index.js';
+import { writeCommandBanner } from './command-banner.js';
+import { runProcessCliUpdateNotice } from './update-notice.js';
-const _require = createRequire(import.meta.url);
-const pkg = _require('../../package.json');
const program = new Command();
function collectCodingAgents(value: string, previous: string[] | undefined): string[] {
return [...(previous ?? []), ...value.split(',')];
}
-program.name('gitnexus').description('GitNexus local CLI and MCP server').version(pkg.version);
+program.name('gitnexus').description('GitNexus local CLI and MCP server').version(packageVersion());
program
.command('setup')
@@ -75,7 +75,11 @@ program
.description('Index a repository (full analysis)')
.option('--watch', 'Keep the index current with serialized incremental refreshes')
.option('--debounce ', 'Watch quiet period before refreshing (default: 300 milliseconds)')
- .option('-f, --force', 'Force full re-index even if up to date')
+ .option('-f, --force', 'Force graph and FTS rebuild; unchanged parser output may be reused')
+ .option(
+ '--no-parse-cache',
+ 'Re-parse every source file instead of replaying cached parser output',
+ )
.option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis')
.option(
'--embeddings [limit]',
@@ -295,6 +299,11 @@ program
.description('Show runtime platform capabilities and embedding configuration')
.action(createLazyAction(() => import('./doctor.js'), 'doctorCommand'));
+program
+ .command('update')
+ .description('Install the latest published GitNexus globally (`npm i -g gitnexus@`).')
+ .action(createLazyAction(() => import('./update.js'), 'updateCommand'));
+
program
.command('embeddings')
.description('Manage the on-demand local embedding runtime')
@@ -498,7 +507,17 @@ program
.option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
.action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
+program.command('__update-check', { hidden: true }).action(async () => {
+ const { refresh } = await import('../core/update-check.js');
+ await refresh();
+});
+
registerGroupCommands(program);
localizeCliHelp(program);
+program.hook('preAction', (_thisCommand, actionCommand) => {
+ writeCommandBanner(actionCommand);
+});
+
+runProcessCliUpdateNotice(packageVersion());
program.parse(process.argv);
diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts
index 9ad39268d..b1e5dae60 100644
--- a/gitnexus/src/cli/mcp.ts
+++ b/gitnexus/src/cli/mcp.ts
@@ -29,6 +29,71 @@
import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js';
+import type { UpdateState } from '../core/update-check.js';
+
+interface McpUpdateLogger {
+ info(bindings: Record, message: string): unknown;
+}
+
+interface McpUpdateChecker {
+ evaluate(): Promise;
+ armUpdateRefreshScheduler(onState: (state: UpdateState | null) => void): () => void;
+}
+
+type LoadMcpUpdateChecker = () => Promise;
+
+const announcedUpdateVersions = new Set();
+
+/**
+ * Start the process-scoped MCP update adapter after its transport startup
+ * boundary. All failures stay inside this best-effort side channel.
+ */
+export async function startMcpUpdateNotifier(
+ logger: McpUpdateLogger,
+ loadChecker: LoadMcpUpdateChecker = () => import('../core/update-check.js'),
+): Promise {
+ let checker: McpUpdateChecker;
+ try {
+ checker = await loadChecker();
+ } catch {
+ return;
+ }
+
+ const announce = (state: UpdateState | null): void => {
+ try {
+ if (
+ !state?.updateAvailable ||
+ !state.latestVersion ||
+ announcedUpdateVersions.has(state.latestVersion)
+ ) {
+ return;
+ }
+ announcedUpdateVersions.add(state.latestVersion);
+ logger.info(
+ { event: 'gitnexus.update_available', latestVersion: state.latestVersion },
+ 'GitNexus update available',
+ );
+ } catch {
+ // Logging must never escape into MCP startup or scheduler promises.
+ }
+ };
+
+ try {
+ announce(await checker.evaluate());
+ } catch {
+ // Cache evaluation and any detached refresh are best-effort.
+ }
+
+ let stop: (() => void) | undefined;
+ try {
+ stop = checker.armUpdateRefreshScheduler(announce);
+ } catch {
+ return;
+ }
+
+ process.once('exit', () => stop?.());
+}
+
export const mcpCommand = async (options?: {
http?: boolean;
port?: string;
@@ -117,9 +182,11 @@ export const mcpCommand = async (options?: {
);
process.exit(1);
}
+ void startMcpUpdateNotifier(logger).catch(() => {});
return;
}
// Start MCP server (serves all repos, discovers new ones lazily)
await startMCPServer(backend, repositoryPolicy);
+ void startMcpUpdateNotifier(logger).catch(() => {});
};
diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts
index 6dd0f9b85..7ca83643c 100644
--- a/gitnexus/src/cli/setup.ts
+++ b/gitnexus/src/cli/setup.ts
@@ -10,10 +10,10 @@ import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { execFile, execFileSync } from 'child_process';
-import { createRequire } from 'module';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
+import { packageVersion } from '../core/package-version.js';
import { getGlobalDir } from '../storage/repo-manager.js';
import {
getEditorTargets,
@@ -37,9 +37,8 @@ const execFileAsync = promisify(execFile);
// re-stamped every release by scripts/sync-plugin-manifests.mjs (#2445),
// since they too execute `gitnexus@` on connect. Only the READMEs
// stay on `gitnexus@latest` — they're quickstart docs, not executed state.
-const _require = createRequire(import.meta.url);
-const _pkg = _require('../../package.json') as { version?: unknown };
-if (typeof _pkg.version !== 'string' || !_pkg.version) {
+const PKG_VERSION = packageVersion();
+if (!PKG_VERSION) {
throw new Error(
'gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.',
);
@@ -47,7 +46,7 @@ if (typeof _pkg.version !== 'string' || !_pkg.version) {
// Version-pinned ref for the persisted MCP entry — deliberately distinct from
// the cjs's exported `gitnexus@latest` hint ref (resolve-analyze-cmd.cjs); the
// two are not unified (see the comment above and that file's MCP_PINNED_REF).
-const MCP_PINNED_REF = `gitnexus@${_pkg.version}`;
+const MCP_PINNED_REF = `gitnexus@${PKG_VERSION}`;
/**
* Build the `command` string written into an editor's hook settings, which the
diff --git a/gitnexus/src/cli/update-notice.ts b/gitnexus/src/cli/update-notice.ts
new file mode 100644
index 000000000..8c0da20e2
--- /dev/null
+++ b/gitnexus/src/cli/update-notice.ts
@@ -0,0 +1,128 @@
+import { spawn as nodeSpawn } from 'node:child_process';
+import { updateEligibleInstallSync } from '../core/install-context.js';
+import {
+ isNewerVersion,
+ readValidatedUpdateCacheSync,
+ updateNotifierOptedOut,
+ updateRefreshInProgress,
+ type ValidatedUpdateCache,
+} from '../core/update-cache.js';
+import { t } from './i18n/index.js';
+
+const EXCLUDED_COMMANDS = new Set([
+ 'augment',
+ 'mcp',
+ 'serve',
+ 'eval-server',
+ 'update',
+ '__update-check',
+]);
+const EXCLUDED_FLAGS = new Set(['--help', '-h', '--version', '-V']);
+
+type SpawnResult = { unref(): void };
+type SpawnLike = (
+ command: string,
+ args: readonly string[],
+ options: { detached: true; stdio: 'ignore'; windowsHide: true },
+) => SpawnResult;
+
+export interface CliUpdateNoticeDependencies {
+ argv: string[];
+ env: NodeJS.ProcessEnv;
+ installedVersion: string;
+ isTTY: boolean | undefined;
+ eligible: boolean;
+ now: number;
+ readCache: (options: { env: NodeJS.ProcessEnv; now: number }) => ValidatedUpdateCache | null;
+ writeStderr: (line: string) => unknown;
+ spawn: SpawnLike;
+}
+
+function excludedInvocation(argv: string[]): boolean {
+ const args = argv.slice(2);
+ if (args.some((arg) => EXCLUDED_FLAGS.has(arg))) return true;
+ const command = args.find((arg) => !arg.startsWith('-'));
+ return command !== undefined && EXCLUDED_COMMANDS.has(command);
+}
+
+export function updateNoticeText(installedVersion: string, latestVersion: string): string {
+ return t('update.available', { installedVersion, latestVersion });
+}
+
+/** Shared cache-gated notice line used by the CLI banner and `doctor`. */
+export function cachedUpdateNoticeLine(options: {
+ installedVersion: string;
+ eligible: boolean;
+ env: NodeJS.ProcessEnv;
+ readCache: () => ValidatedUpdateCache | null;
+}): string | null {
+ try {
+ if (!options.eligible || updateNotifierOptedOut(options.env)) return null;
+ const cache = options.readCache();
+ if (!cache?.latestVersion || !isNewerVersion(options.installedVersion, cache.latestVersion)) {
+ return null;
+ }
+ return updateNoticeText(options.installedVersion, cache.latestVersion);
+ } catch {
+ return null;
+ }
+}
+
+export function runCliUpdateNotice(deps: CliUpdateNoticeDependencies): void {
+ try {
+ if (
+ deps.isTTY !== true ||
+ updateNotifierOptedOut(deps.env) ||
+ !deps.eligible ||
+ excludedInvocation(deps.argv)
+ ) {
+ return;
+ }
+
+ const cache = deps.readCache({ env: deps.env, now: deps.now });
+ if (cache?.latestVersion && isNewerVersion(deps.installedVersion, cache.latestVersion)) {
+ deps.writeStderr(`${updateNoticeText(deps.installedVersion, cache.latestVersion)}\n`);
+ }
+
+ if (cache === null || cache.stale) {
+ // Coalesce parallel invocations: when a live process holds the refresh
+ // lock, its refresh covers us, so don't fork another CLI.
+ if (!updateRefreshInProgress(deps.env)) {
+ try {
+ deps
+ .spawn(process.execPath, [deps.argv[1] ?? '', '__update-check'], {
+ detached: true,
+ stdio: 'ignore',
+ windowsHide: true,
+ })
+ .unref();
+ } catch {
+ // Update refresh is best-effort and must never reach Commander parsing.
+ }
+ }
+ }
+ } catch {
+ // Cache reads and all adapter logic fail open.
+ }
+}
+
+export function runProcessCliUpdateNotice(installedVersion: string): void {
+ if (
+ process.stderr.isTTY !== true ||
+ updateNotifierOptedOut(process.env) ||
+ excludedInvocation(process.argv)
+ ) {
+ return;
+ }
+ runCliUpdateNotice({
+ argv: process.argv,
+ env: process.env,
+ installedVersion,
+ isTTY: process.stderr.isTTY,
+ eligible: updateEligibleInstallSync(),
+ now: Date.now(),
+ readCache: readValidatedUpdateCacheSync,
+ writeStderr: (line) => process.stderr.write(line),
+ spawn: nodeSpawn as unknown as SpawnLike,
+ });
+}
diff --git a/gitnexus/src/cli/update.ts b/gitnexus/src/cli/update.ts
new file mode 100644
index 000000000..adde2ae2b
--- /dev/null
+++ b/gitnexus/src/cli/update.ts
@@ -0,0 +1,123 @@
+/**
+ * Explicit `gitnexus update`: refresh the latest dist-tag, then install
+ * `gitnexus@` globally with npm — the same shape as `claude update`
+ * / `codex update`. Other commands only notify; they never spawn npm.
+ */
+
+import { spawn } from 'node:child_process';
+import { homedir } from 'node:os';
+import { composeWin32NpmCommand } from '../core/embeddings/runtime-install.js';
+import { packageVersion } from '../core/package-version.js';
+import { STRICT_UPDATE_VERSION } from '../core/update-cache.js';
+import { refresh, type UpdateState } from '../core/update-check.js';
+import { t } from './i18n/index.js';
+
+export const UPDATE_PACKAGE = 'gitnexus';
+
+export interface UpdateCommandDependencies {
+ installedVersion: string;
+ refresh: (options: {
+ eligible: true;
+ ignoreOptOut: true;
+ installedVersion: string;
+ }) => Promise;
+ writeStdout: (line: string) => void;
+ runInstall: (version: string) => Promise;
+ setExitCode: (code: number) => void;
+}
+
+function defaultInstalledVersion(): string {
+ return packageVersion();
+}
+
+export function updateInstallArgs(version: string): string[] {
+ return ['i', '-g', `${UPDATE_PACKAGE}@${version}`];
+}
+
+export function updateInstallCommand(version: string): string {
+ return `npm ${updateInstallArgs(version).join(' ')}`;
+}
+
+function isTestDeps(value: unknown): value is Partial {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ ('refresh' in value || 'runInstall' in value || 'writeStdout' in value)
+ );
+}
+
+function defaultRunInstall(version: string): Promise {
+ const args = updateInstallArgs(version);
+ return new Promise((resolve, reject) => {
+ const child =
+ process.platform === 'win32'
+ ? spawn(composeWin32NpmCommand(args), {
+ cwd: homedir(),
+ windowsHide: true,
+ shell: true,
+ stdio: 'inherit',
+ })
+ : spawn('npm', args, {
+ cwd: homedir(),
+ windowsHide: true,
+ stdio: 'inherit',
+ });
+ child.on('error', reject);
+ child.on('exit', (code, signal) => {
+ resolve(signal ? 1 : (code ?? 1));
+ });
+ });
+}
+
+export async function updateCommand(maybeDeps?: unknown): Promise {
+ const deps = isTestDeps(maybeDeps) ? maybeDeps : {};
+ const installedVersion = deps.installedVersion ?? defaultInstalledVersion();
+ const runRefresh = deps.refresh ?? refresh;
+ const writeStdout = deps.writeStdout ?? ((line: string) => console.log(line));
+ const runInstall = deps.runInstall ?? defaultRunInstall;
+ const setExitCode =
+ deps.setExitCode ??
+ ((code: number) => {
+ process.exitCode = code;
+ });
+
+ const state = await runRefresh({
+ eligible: true,
+ ignoreOptOut: true,
+ installedVersion,
+ });
+
+ const latestVersion =
+ state?.latestVersion && STRICT_UPDATE_VERSION.test(state.latestVersion)
+ ? state.latestVersion
+ : undefined;
+ if (state?.updateAvailable && latestVersion) {
+ writeStdout(t('update.available', { installedVersion, latestVersion }));
+ writeStdout(t('update.installing', { command: updateInstallCommand(latestVersion) }));
+ try {
+ const code = await runInstall(latestVersion);
+ if (code !== 0) {
+ writeStdout(t('update.installFailed', { command: updateInstallCommand(latestVersion) }));
+ setExitCode(code);
+ return;
+ }
+ } catch (error) {
+ writeStdout(
+ t('update.installError', {
+ message: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ setExitCode(1);
+ return;
+ }
+ writeStdout(t('update.installed', { version: latestVersion }));
+ return;
+ }
+
+ if (latestVersion) {
+ writeStdout(t('update.current', { installedVersion }));
+ return;
+ }
+
+ writeStdout(t('update.checkFailed'));
+}
diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts
index e196095ef..9d214d138 100644
--- a/gitnexus/src/core/group/storage.ts
+++ b/gitnexus/src/core/group/storage.ts
@@ -1,14 +1,14 @@
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
-import * as os from 'node:os';
import type { ContractRegistry } from './types.js';
import { writeFileAtomic } from '../../storage/fs-atomic.js';
+import { getGlobalDir } from '../../storage/global-dir.js';
export const CONTRACTS_FILE = 'contracts.json';
export function getDefaultGitnexusDir(): string {
- return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
+ return getGlobalDir();
}
export function getGroupsBaseDir(gitnexusDir?: string): string {
diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts
index f9cbbd47b..b69c09eb7 100644
--- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts
+++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts
@@ -457,6 +457,8 @@ export async function runChunkedParseAndResolve(
usedWorkerPool: boolean;
/** Files dispatched to parser workers after parse-cache lookup. */
reparsedFileCount: number;
+ /** Files restored from parse-cache chunks without parser-worker dispatch. */
+ parseCacheHitFileCount: number;
/** Worker-produced ParsedFile artifacts aggregated across chunks.
* Threaded into scope-resolution as a re-extract cache so the warm-
* cache analyze run can skip the dominant `extractParsedFile` cost
@@ -752,6 +754,7 @@ export async function runChunkedParseAndResolve(
: new Map>();
let chunkCacheHits = 0;
let chunkCacheMisses = 0;
+ let parseCacheHitFileCount = 0;
let reparsedFileCount = 0;
try {
@@ -1044,6 +1047,7 @@ export async function runChunkedParseAndResolve(
pendingWorkerChunk = null;
}
chunkCacheHits++;
+ parseCacheHitFileCount += chunkFiles.length;
const chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw, exportedTypeMap);
if (isDev) {
logger.info(
@@ -1611,6 +1615,7 @@ export async function runChunkedParseAndResolve(
// is intentionally measured at dispatch time rather than inferred from
// the git/hash diff.
reparsedFileCount,
+ parseCacheHitFileCount,
// Per-file ParsedFile artifacts produced by workers' calls to
// `extractParsedFile`. Consumed by scope-resolution as a re-extraction
// cache: when the file's ParsedFile is here, scope-resolution skips its own
diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts
index ac1b120e8..8ee94e265 100644
--- a/gitnexus/src/core/ingestion/pipeline.ts
+++ b/gitnexus/src/core/ingestion/pipeline.ts
@@ -411,13 +411,19 @@ export const runPipelineFromRepo = async (
}
// Extract final results for the PipelineResult contract
- const { totalFiles, usedWorkerPool, reparsedFileCount, unavailableScopeLanguageFiles } =
- getPhaseOutput<{
- totalFiles: number;
- usedWorkerPool: boolean;
- reparsedFileCount: number;
- unavailableScopeLanguageFiles: number;
- }>(results, 'parse');
+ const {
+ totalFiles,
+ usedWorkerPool,
+ reparsedFileCount,
+ parseCacheHitFileCount,
+ unavailableScopeLanguageFiles,
+ } = getPhaseOutput<{
+ totalFiles: number;
+ usedWorkerPool: boolean;
+ reparsedFileCount: number;
+ parseCacheHitFileCount: number;
+ unavailableScopeLanguageFiles: number;
+ }>(results, 'parse');
let communityResult: CommunitiesOutput['communityResult'] | undefined;
let processResult: ProcessesOutput['processResult'] | undefined;
@@ -470,6 +476,7 @@ export const runPipelineFromRepo = async (
undecidedSatisfaction,
usedWorkerPool,
reparsedFileCount,
+ parseCacheHitFileCount,
scopeExtractionFailures,
unavailableScopeLanguageFiles,
pdgEmitManifest,
diff --git a/gitnexus/src/core/install-context.ts b/gitnexus/src/core/install-context.ts
new file mode 100644
index 000000000..79bd09a00
--- /dev/null
+++ b/gitnexus/src/core/install-context.ts
@@ -0,0 +1,170 @@
+import fs from 'node:fs/promises';
+import fsSync from 'node:fs';
+import path from 'node:path';
+
+const EPHEMERAL_SEGMENTS = new Set(['_npx', '_cacache']);
+const EPHEMERAL_DLX_OWNERS = new Set(['pnpm', 'yarn']);
+
+function isInside(parent: string, child: string): boolean {
+ const relative = path.relative(parent, child);
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
+}
+
+function hasEphemeralMarker(candidate: string): boolean {
+ const normalized = candidate.replaceAll('\\', '/').toLowerCase();
+ const segments = normalized.split('/').filter(Boolean);
+ if (segments.some((segment) => EPHEMERAL_SEGMENTS.has(segment))) return true;
+ // `dlx` is only ephemeral next to a package-manager owner (pnpm dlx / yarn
+ // dlx). A project directory that happens to be named `dlx` is a normal install.
+ if (segments.includes('dlx') && segments.some((segment) => EPHEMERAL_DLX_OWNERS.has(segment))) {
+ return true;
+ }
+ return normalized.includes('/.bun/install/cache/') || normalized.includes('/bun/install/cache/');
+}
+
+function findPackageDir(entryPath: string): string | null {
+ let current = path.dirname(path.resolve(entryPath));
+ for (;;) {
+ if (
+ path.basename(current) === 'gitnexus' &&
+ path.basename(path.dirname(current)) === 'node_modules'
+ ) {
+ return current;
+ }
+ const parent = path.dirname(current);
+ if (parent === current) return null;
+ current = parent;
+ }
+}
+
+interface EligibilityProbes {
+ realEntry: string;
+ env: NodeJS.ProcessEnv;
+ /** Resolved npm cache dir, when npm_config_cache is set. */
+ realCache: string | null;
+ packageDir: string | null;
+ /** Whether the resolved package directory carries a .git checkout marker. */
+ packageDirHasGit: boolean;
+ /** Resolved npm prefix, when npm_config_prefix is set. */
+ realPrefix: string | null;
+}
+
+/**
+ * Pure classification shared by the async and sync variants. Realpath
+ * resolution deliberately makes a linked node_modules entry point at its
+ * development checkout, which is therefore ineligible.
+ */
+function classifyEligibility(probes: EligibilityProbes): boolean {
+ const { realEntry, env, realCache, packageDir, packageDirHasGit, realPrefix } = probes;
+ const corroboratingPaths = [
+ realEntry,
+ env.npm_execpath,
+ env.npm_config_cache && path.resolve(env.npm_config_cache),
+ ].filter((value): value is string => Boolean(value));
+ if (corroboratingPaths.some(hasEphemeralMarker)) return false;
+
+ if (realCache && isInside(realCache, realEntry)) return false;
+
+ // Published packages do not carry .git. This also rejects unusual installs
+ // copied wholesale from a checkout.
+ if (!packageDir || packageDirHasGit) return false;
+
+ if (realPrefix && isInside(realPrefix, realEntry)) return true;
+
+ // A package rooted at node_modules/gitnexus is a persistent project-local
+ // install after ephemeral/cache layouts have been excluded above.
+ return true;
+}
+
+let memoizedEligible: boolean | undefined;
+
+/** True only for a persistent npm global or project-local installation. */
+export async function updateEligibleInstall(
+ entryPath?: string,
+ env: NodeJS.ProcessEnv = process.env,
+): Promise {
+ const useMemo = entryPath === undefined && env === process.env;
+ if (useMemo && memoizedEligible !== undefined) return memoizedEligible;
+ const result = await classifyAsync(entryPath ?? process.argv[1] ?? '', env);
+ if (useMemo) memoizedEligible = result;
+ return result;
+}
+
+async function classifyAsync(entryPath: string, env: NodeJS.ProcessEnv): Promise {
+ if (!entryPath) return false;
+ try {
+ const realEntry = await fs.realpath(entryPath);
+ const realCache = env.npm_config_cache
+ ? await fs
+ .realpath(env.npm_config_cache)
+ .catch(() => path.resolve(env.npm_config_cache as string))
+ : null;
+ const packageDir = findPackageDir(realEntry);
+ const packageDirHasGit = packageDir
+ ? await fs
+ .access(path.join(packageDir, '.git'))
+ .then(() => true)
+ .catch(() => false)
+ : false;
+ const realPrefix = env.npm_config_prefix
+ ? await fs.realpath(env.npm_config_prefix).catch(() => path.resolve(env.npm_config_prefix))
+ : null;
+ return classifyEligibility({
+ realEntry,
+ env,
+ realCache,
+ packageDir,
+ packageDirHasGit,
+ realPrefix,
+ });
+ } catch {
+ return false;
+ }
+}
+
+/** Synchronous entry-point variant for pre-Commander startup checks. */
+export function updateEligibleInstallSync(
+ entryPath?: string,
+ env: NodeJS.ProcessEnv = process.env,
+): boolean {
+ const useMemo = entryPath === undefined && env === process.env;
+ if (useMemo && memoizedEligible !== undefined) return memoizedEligible;
+ const result = classifySync(entryPath ?? process.argv[1] ?? '', env);
+ if (useMemo) memoizedEligible = result;
+ return result;
+}
+
+function classifySync(entryPath: string, env: NodeJS.ProcessEnv): boolean {
+ if (!entryPath) return false;
+ try {
+ const realEntry = fsSync.realpathSync(entryPath);
+ let realCache: string | null = null;
+ if (env.npm_config_cache) {
+ try {
+ realCache = fsSync.realpathSync(env.npm_config_cache);
+ } catch {
+ realCache = path.resolve(env.npm_config_cache);
+ }
+ }
+ const packageDir = findPackageDir(realEntry);
+ const packageDirHasGit = packageDir ? fsSync.existsSync(path.join(packageDir, '.git')) : false;
+ let realPrefix: string | null = null;
+ if (env.npm_config_prefix) {
+ try {
+ realPrefix = fsSync.realpathSync(env.npm_config_prefix);
+ } catch {
+ realPrefix = path.resolve(env.npm_config_prefix);
+ }
+ }
+ return classifyEligibility({
+ realEntry,
+ env,
+ realCache,
+ packageDir,
+ packageDirHasGit,
+ realPrefix,
+ });
+ } catch {
+ return false;
+ }
+}
diff --git a/gitnexus/src/core/net/url-guard.ts b/gitnexus/src/core/net/url-guard.ts
new file mode 100644
index 000000000..7eb38fa12
--- /dev/null
+++ b/gitnexus/src/core/net/url-guard.ts
@@ -0,0 +1,174 @@
+import { isIP } from 'net';
+
+// Cloud metadata hostnames that must never be reachable via user-supplied URLs
+const BLOCKED_HOSTNAMES = new Set([
+ 'localhost',
+ 'metadata.google.internal',
+ 'metadata.azure.com',
+ 'metadata.internal',
+]);
+
+/**
+ * Validate an outbound http(s) URL to prevent SSRF.
+ * Only allows https:// and http:// schemes. Blocks private/internal addresses,
+ * IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
+ */
+export function validateGitUrl(url: string): void {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ throw new Error('Invalid URL');
+ }
+
+ if (!['https:', 'http:'].includes(parsed.protocol)) {
+ throw new Error('Only https:// and http:// git URLs are allowed');
+ }
+
+ if (parsed.search || parsed.hash) {
+ throw new Error('Git URLs must not include query strings or fragments');
+ }
+
+ const host = parsed.hostname.toLowerCase();
+
+ // Block known dangerous hostnames (cloud metadata services)
+ if (BLOCKED_HOSTNAMES.has(host)) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // Strip IPv6 brackets if present (URL parser behavior varies across Node versions)
+ let normalizedHost = host;
+ if (host.startsWith('[') && host.endsWith(']')) {
+ normalizedHost = host.slice(1, -1);
+ }
+
+ // Check if this is an IPv6 address
+ // Use manual colon detection as fallback since isIP may return 0 for some
+ // normalized IPv6 forms (e.g. ::ffff:7f00:1)
+ const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':');
+ if (isIPv6) {
+ assertNotPrivateIPv6(normalizedHost);
+ return;
+ }
+
+ // Check if this is an IPv4 address (including numeric encodings)
+ if (isIP(normalizedHost) === 4) {
+ assertNotPrivateIPv4(normalizedHost);
+ return;
+ }
+
+ // For non-IP hostnames, check for numeric IP tricks
+ // Decimal encoding: 2130706433 = 127.0.0.1
+ // Hex encoding: 0x7f000001 = 127.0.0.1
+ if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // Standard IPv4 regex checks for dotted notation
+ if (
+ /^127\./.test(host) ||
+ /^10\./.test(host) ||
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
+ /^192\.168\./.test(host) ||
+ /^169\.254\./.test(host) ||
+ /^0\./.test(host) ||
+ host === '0.0.0.0' ||
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
+ /^198\.1[89]\./.test(host)
+ ) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+}
+
+function assertNotPrivateIPv6(ip: string): void {
+ // Expand common compressed forms for comparison
+ const lower = ip.toLowerCase();
+
+ // IPv6 loopback
+ if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // Unspecified address
+ if (lower === '::' || lower === '0:0:0:0:0:0:0:0') {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes)
+ if (lower.startsWith('fc') || lower.startsWith('fd')) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // IPv6 link-local (fe80::/10)
+ if (
+ lower.startsWith('fe80') ||
+ lower.startsWith('fe8') ||
+ lower.startsWith('fe9') ||
+ lower.startsWith('fea') ||
+ lower.startsWith('feb')
+ ) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex)
+ // Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1
+ if (lower.startsWith('::ffff:')) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // Expanded IPv4-mapped form only (0:0:0:0:0:ffff:…). A public address that
+ // merely contains a `ffff` hextet is not mapped.
+ if (lower.startsWith('0:0:0:0:0:ffff:')) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
+ // Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
+ // is hidden in the last 32 bits without the ::ffff: marker, so the check
+ // above misses it. The form is still routable to the embedded IPv4 on most
+ // network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
+ if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
+ // 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
+ // ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
+ // e.g. 64:ff9b::7f00:1 → 127.0.0.1.
+ // The check intentionally covers the full 64:ff9b::/32 block (broader than
+ // the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
+ // blocking the whole prefix is defensively sound and prevents a narrower
+ // CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
+ // future translation assignment.
+ if (lower.startsWith('64:ff9b:')) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
+ // 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
+ // protocol was deprecated by RFC 7526 and the public relay anycast
+ // (192.88.99.1) has been retired, so broad-blocking the prefix has near-
+ // zero false-positive cost while closing the IPv4-embedded bypass.
+ // Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
+ // impractical and is out of scope here.
+ if (lower.startsWith('2002:')) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+}
+
+function assertNotPrivateIPv4(ip: string): void {
+ const parts = ip.split('.').map(Number);
+ const [a, b] = parts;
+ if (
+ a === 127 ||
+ a === 10 ||
+ (a === 172 && b >= 16 && b <= 31) ||
+ (a === 192 && b === 168) ||
+ (a === 169 && b === 254) ||
+ a === 0 ||
+ (a === 100 && b >= 64 && b <= 127) ||
+ (a === 198 && (b === 18 || b === 19))
+ ) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+}
diff --git a/gitnexus/src/core/package-version.ts b/gitnexus/src/core/package-version.ts
new file mode 100644
index 000000000..c4e9ab170
--- /dev/null
+++ b/gitnexus/src/core/package-version.ts
@@ -0,0 +1,12 @@
+import { createRequire } from 'node:module';
+
+const _require = createRequire(import.meta.url);
+const pkg = _require('../../package.json') as { version?: unknown };
+
+/**
+ * Published version from this package's `package.json`.
+ * Empty string if the field is missing or not a string.
+ */
+export function packageVersion(): string {
+ return typeof pkg.version === 'string' ? pkg.version : '';
+}
diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts
index 2dfd80b93..8b70ef10e 100644
--- a/gitnexus/src/core/run-analyze.ts
+++ b/gitnexus/src/core/run-analyze.ts
@@ -157,11 +157,11 @@ import {
saveParseCache,
pruneCache,
PARSE_CACHE_VERSION,
+ createColdParseRebuildDir,
+ emptyParseCache,
+ forgetCreatedParseCacheDir,
} from '../storage/parse-cache.js';
-import {
- getDurableParsedFileDir,
- pruneAndSaveDurableParsedFileStore,
-} from '../storage/parsedfile-store.js';
+import { mergeStagedDurableParsedFileStore } from '../storage/parsedfile-store.js';
import {
getCurrentCommit,
getCurrentBranch,
@@ -332,12 +332,19 @@ export interface AnalyzeCallbacks {
export interface AnalyzeOptions {
/**
- * Force a full re-index of the pipeline. Callers may OR this with
- * other flags that imply re-analysis (e.g. `--skills`), so the value
- * here is the PIPELINE-force signal, NOT the registry-collision
- * bypass. See `allowDuplicateName` below.
+ * Rebuild the graph and FTS. Parser output is still reused from the
+ * content-addressed parse cache unless `useParseCache` is false.
+ * Callers may OR this with other flags that imply re-analysis
+ * (e.g. `--skills`), so the value here is the PIPELINE-force signal,
+ * NOT the registry-collision bypass. See `allowDuplicateName` below.
*/
force?: boolean;
+ /**
+ * Reuse content-addressed parser output. Defaults to true. When false,
+ * analysis reparses every file and publishes a new parse-cache generation
+ * only after a successful run (live shards stay untouched if the run fails).
+ */
+ useParseCache?: boolean;
/** Repair only search indexes without re-running full parsing/indexing. */
repairFts?: boolean;
/** Emit per-index FTS create logs. */
@@ -1029,6 +1036,18 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
};
}
+async function removeColdParseRebuildDir(
+ dir: string | undefined,
+ ignoreErrors: boolean,
+): Promise {
+ if (!dir) return;
+ try {
+ await fs.rm(dir, { recursive: true, force: true });
+ } catch (err) {
+ if (!ignoreErrors) throw err;
+ }
+}
+
/**
* Run the full analysis under an exclusive, index-directory-scoped write lock
* (#2658). A second concurrent `analyze` on the same slot waits here for the
@@ -1132,6 +1151,7 @@ async function runFullAnalysisInner(
// does not own the flat slot. See resolveWriteTarget for the full contract.
const { storagePath, repoHasGit, currentCommit, branchLabel, placement, lbugPath, metaDir } =
writeTarget;
+ let coldParseRebuildDir: string | undefined;
// Start each analyze with a clean buffer-pool hint: any pre-pipeline DB open
// (e.g. the embeddings-cache open) falls back to the default until the hint is
@@ -1742,6 +1762,13 @@ async function runFullAnalysisInner(
options = { ...options, force: true };
}
+ // Programmatic `useParseCache: false` must set force or the up-to-date
+ // guard returns before the empty-cache construction below.
+ if (options.useParseCache === false && !options.force) {
+ log('Parser cache bypass requested; forcing a full rebuild so unchanged files are re-parsed.');
+ options = { ...options, force: true };
+ }
+
// ── Early-return: already up to date ──────────────────────────────
if (
existingMeta &&
@@ -1964,11 +1991,18 @@ async function runFullAnalysisInner(
}
// ── Load incremental parse cache ──────────────────────────────────
- // Content-addressed: safe to reuse across `--force` runs (chunks whose
- // file contents haven't changed produce identical worker output).
- // Loaded into a single ParseCache object that the pipeline mutates
- // in-place (cache hits leave entries unchanged; misses add new ones).
- const parseCache = await loadParseCache(storagePath);
+ // Content-addressed: `--force` reuses parser shards; `useParseCache: false`
+ // stages a new generation under a run-unique parse-rebuild.* dir and publishes
+ // after success. Unique because index locks are per branch slot while this
+ // cache root is shared across branches.
+ if (options.useParseCache === false) {
+ coldParseRebuildDir = await createColdParseRebuildDir(storagePath);
+ forgetCreatedParseCacheDir(coldParseRebuildDir);
+ }
+ const parseCache =
+ options.useParseCache === false
+ ? emptyParseCache(coldParseRebuildDir)
+ : await loadParseCache(storagePath);
// Streamed structural emit (#2680). Resolved ONCE, so the pipeline flag and
// the CSV-dir resolution below cannot disagree — and resolved HERE, not at
@@ -2006,53 +2040,69 @@ async function runFullAnalysisInner(
!schemaFingerprintMismatch(existingMeta.schemaFingerprint);
// ── Phase 1: Full Pipeline (0–60%) ────────────────────────────────
- const pipelineResult = await runPipelineFromRepo(
- repoPath,
- (p) => {
- const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
- const scaled = Math.round(p.percent * 0.6);
- const message = p.detail
- ? `${p.message || phaseLabel} (${p.detail})`
- : p.message || phaseLabel;
- progress(p.phase, scaled, message);
- },
- {
- parseCache,
- workerPoolSize: options.workerPoolSize,
- // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
- // build gate (workerData.pdg) and the scope-resolution emit gate.
- pdg: options.pdg === true,
- pdgMaxFunctionLines: options.pdgMaxFunctionLines,
- pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
- pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
- pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
- pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
- pdgMaxTaintHops: options.pdgMaxTaintHops,
- pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
- pdgMaxInterprocHops: options.pdgMaxInterprocHops,
- pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
- // Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
- // (force === true) so the incremental writeback never reads back an
- // offloaded BasicBlock layer. Memory-only; byte-identical output.
- streamPdgEmit: resolveStreamPdgEmit(options),
- pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
- // Streamed structural emit (#2680) — same full-rebuild gate as the PDG
- // toggle above, for the same incremental-writeback reason.
- streamGraphEmit: streamGraphEmitActive,
- // Resolved ONLY when streaming is active: on a Windows non-ASCII storage
- // path this helper mkdtempSyncs a real directory, so evaluating it
- // unconditionally would leak one temp dir per analyze even with the flag
- // off. The PDG sibling resolves inside its guard for the same reason.
- graphEmitCsvDir: streamGraphEmitActive
- ? resolveNativeSafeStorageDir(storagePath, 'graph-csv')
- : undefined,
- fetchWrappers: options.fetchWrappers,
- skipDerivedGraphPhases,
- springActuatorPath: options.springActuatorPath,
- asyncApiSpecPath: options.asyncApiSpecPath,
- springActuatorScanExclusions,
- },
- );
+ let pipelineResult;
+ try {
+ pipelineResult = await runPipelineFromRepo(
+ repoPath,
+ (p) => {
+ const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
+ const scaled = Math.round(p.percent * 0.6);
+ const message = p.detail
+ ? `${p.message || phaseLabel} (${p.detail})`
+ : p.message || phaseLabel;
+ progress(p.phase, scaled, message);
+ },
+ {
+ parseCache,
+ workerPoolSize: options.workerPoolSize,
+ // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
+ // build gate (workerData.pdg) and the scope-resolution emit gate.
+ pdg: options.pdg === true,
+ pdgMaxFunctionLines: options.pdgMaxFunctionLines,
+ pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
+ pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
+ pdgMaxCdgEdgesPerFunction: options.pdgMaxCdgEdgesPerFunction,
+ pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
+ pdgMaxTaintHops: options.pdgMaxTaintHops,
+ pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
+ pdgMaxInterprocHops: options.pdgMaxInterprocHops,
+ pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
+ // Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
+ // (force === true) so the incremental writeback never reads back an
+ // offloaded BasicBlock layer. Memory-only; byte-identical output.
+ streamPdgEmit: resolveStreamPdgEmit(options),
+ pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
+ // Streamed structural emit (#2680) — same full-rebuild gate as the PDG
+ // toggle above, for the same incremental-writeback reason.
+ streamGraphEmit: streamGraphEmitActive,
+ // Resolved ONLY when streaming is active: on a Windows non-ASCII storage
+ // path this helper mkdtempSyncs a real directory, so evaluating it
+ // unconditionally would leak one temp dir per analyze even with the flag
+ // off. The PDG sibling resolves inside its guard for the same reason.
+ graphEmitCsvDir: streamGraphEmitActive
+ ? resolveNativeSafeStorageDir(storagePath, 'graph-csv')
+ : undefined,
+ fetchWrappers: options.fetchWrappers,
+ skipDerivedGraphPhases,
+ springActuatorPath: options.springActuatorPath,
+ asyncApiSpecPath: options.asyncApiSpecPath,
+ springActuatorScanExclusions,
+ },
+ );
+ } catch (err) {
+ await removeColdParseRebuildDir(coldParseRebuildDir, true);
+ throw err;
+ }
+
+ if (options.force && (pipelineResult.parseCacheHitFileCount ?? 0) > 0) {
+ log(
+ `Rebuilt the graph and FTS while reusing cached parser output for ` +
+ `${pipelineResult.parseCacheHitFileCount} file(s) ` +
+ `(parse cache ${PARSE_CACHE_VERSION}). ` +
+ `For same-version capture/query development changes, increment SCHEMA_BUMP in ` +
+ `src/storage/parse-cache.ts to invalidate parser output.`,
+ );
+ }
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
progress('lbug', 60, 'Loading into LadybugDB...');
@@ -3994,51 +4044,8 @@ async function runFullAnalysisInner(
// meta.indexedAt = T_new while lbugPath still resolves to the pre-swap
// inode (which latched the reader on the stale index permanently). The meta
// object is fully computed at this point; only its write is deferred.
-
- // Persist the incremental parse cache for the next run. Wraps in
- // try/catch so a cache-write failure never breaks an otherwise
- // successful indexing run. Prune stale chunk-hash entries first so
- // the cache file size stays bounded across runs (chunks whose
- // composition no longer matches anything in the current scan are
- // dead weight; the parse phase populates `usedKeys` as it processes
- // chunks).
- try {
- // #2106 R6: the parse cache + durable store are shared across branches.
- // Before pruning to this run's keys, fold in the OTHER branches' recorded
- // chunk keys so a branch switch doesn't evict their still-live shards.
- // Adding to usedKeys makes them survive pruneCache AND land in the saved
- // index (saveParseCache builds the index from usedKeys). Excludes this
- // run's own meta dir, so a single-branch repo folds in nothing → prune
- // set byte-identical to today.
- const { keys: siblingKeys, complete } = await collectBranchCacheKeys(storagePath, metaDir);
- if (complete) {
- for (const k of siblingKeys) parseCache.usedKeys.add(k);
- } else {
- // Fail-safe toward retention: a sibling meta was unreadable, so keep
- // everything currently loaded rather than evict on incomplete info.
- log('Parse cache: a branch meta was unreadable — retaining all cached chunks (#2106).');
- for (const k of parseCache.entries.keys()) parseCache.usedKeys.add(k);
- }
- const pruned = pruneCache(parseCache, parseCache.usedKeys);
- if (pruned > 0) {
- log(`Parse cache: pruned ${pruned} stale chunk entries`);
- }
- const savedKeys = await saveParseCache(storagePath, parseCache);
- // Prune the durable ParsedFile store to EXACTLY the parse cache's
- // surviving keys (#2038 warm-cache coverage), so the two content-addressed
- // stores stay coherent: a chunk is "cached" iff both its parse-cache shard
- // and its durable shards exist. A quarantined chunk (in usedKeys but with
- // no parse-cache shard) drops its durable subdir here and re-dispatches
- // next run. Same try/catch — a durable-store write failure must never
- // break an otherwise successful run (next run treats it as a miss).
- await pruneAndSaveDurableParsedFileStore(
- getDurableParsedFileDir(storagePath),
- PARSE_CACHE_VERSION,
- new Set(savedKeys),
- );
- } catch (e) {
- log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`);
- }
+ // Parse-cache publish waits until after that swap + saveMeta so a failed
+ // registerRepo / close / swap cannot replace live shards (#3153).
// Forward the --name alias and the registry-collision bypass bit.
// `allowDuplicateName` is its own concern — independent from the
@@ -4061,8 +4068,8 @@ async function runFullAnalysisInner(
// ── #2354: the flat workspace slot has adopted this run's branch ──────
// Drop a now-shadowed `branches//` sub-index for the same label
// (unreachable once the flat slot serves it) and align the registry's
- // top-level branch label. Best-effort like the parse-cache save above
- // (#2364 review F5): the index is complete and registered, and a failure
+ // top-level branch label. Best-effort (#2364 review F5): the index is
+ // complete and registered, and a failure
// here leaves only a stale registry label / undeleted shadowed dir —
// never wrong routing, because the flat meta this run already stamped is
// what applyBranchScope trusts. Retried by the next content-changing run
@@ -4190,8 +4197,55 @@ async function runFullAnalysisInner(
// live and the next run recovers via the full-rebuild path.
await saveMeta(metaDir, meta);
+ // Persist the incremental parse cache only after a successful graph
+ // publish (#3153). try/catch so a cache-write failure never breaks an
+ // otherwise successful indexing run. Prune stale chunk-hash entries first
+ // so the cache file size stays bounded across runs (chunks whose
+ // composition no longer matches anything in the current scan are dead
+ // weight; the parse phase populates `usedKeys` as it processes chunks).
+ try {
+ // #2106 R6: the parse cache + durable store are shared across branches.
+ // Before pruning to this run's keys, fold in the OTHER branches' recorded
+ // chunk keys so a branch switch doesn't evict their still-live shards.
+ // Adding to usedKeys makes them survive pruneCache AND land in the saved
+ // index (saveParseCache builds the index from usedKeys). Excludes this
+ // run's own meta dir, so a single-branch repo folds in nothing → prune
+ // set byte-identical to today.
+ const { keys: siblingKeys, complete } = await collectBranchCacheKeys(storagePath, metaDir);
+ if (complete) {
+ for (const k of siblingKeys) parseCache.usedKeys.add(k);
+ } else {
+ // Fail-safe toward retention: a sibling meta was unreadable, so keep
+ // everything currently loaded rather than evict on incomplete info.
+ log('Parse cache: a branch meta was unreadable — retaining all cached chunks (#2106).');
+ for (const k of parseCache.entries.keys()) parseCache.usedKeys.add(k);
+ }
+ const pruned = pruneCache(parseCache, parseCache.usedKeys);
+ if (pruned > 0) {
+ log(`Parse cache: pruned ${pruned} stale chunk entries`);
+ }
+ const savedKeys = await saveParseCache(storagePath, parseCache);
+ // Prune the durable ParsedFile store to EXACTLY the parse cache's
+ // surviving keys (#2038 warm-cache coverage), so the two content-addressed
+ // stores stay coherent: a chunk is "cached" iff both its parse-cache shard
+ // and its durable shards exist. A quarantined chunk (in usedKeys but with
+ // no parse-cache shard) drops its durable subdir here and re-dispatches
+ // next run. Same try/catch — a durable-store write failure must never
+ // break an otherwise successful run (next run treats it as a miss).
+ await mergeStagedDurableParsedFileStore(
+ storagePath,
+ parseCache.storagePath ?? storagePath,
+ PARSE_CACHE_VERSION,
+ new Set(savedKeys),
+ );
+ } catch (e) {
+ log(`Warning: could not save parse cache (${(e as Error).message}); continuing.`);
+ }
+
progress('done', 100, 'Done');
+ await removeColdParseRebuildDir(coldParseRebuildDir, true);
+
return {
repoName: projectName,
repoPath,
@@ -4245,6 +4299,7 @@ async function runFullAnalysisInner(
/* swallow — orphan reclamation must never mask the real failure */
}
}
+ await removeColdParseRebuildDir(coldParseRebuildDir, true);
if (liveIndexMutationStarted) {
// Preserve the original error identity/prototype: callers distinguish
// IndexLockTimeoutError and other domain failures with `instanceof`.
diff --git a/gitnexus/src/core/update-cache.ts b/gitnexus/src/core/update-cache.ts
new file mode 100644
index 000000000..c055faa9c
--- /dev/null
+++ b/gitnexus/src/core/update-cache.ts
@@ -0,0 +1,172 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { getGlobalDir } from '../storage/global-dir.js';
+import { isProcessAlive, readProcessStartTime } from '../utils/process-identity.js';
+
+export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
+export const STRICT_UPDATE_VERSION = /^\d+\.\d+\.\d+$/;
+export const DEFAULT_UPDATE_REGISTRY = 'https://registry.npmjs.org';
+
+export interface UpdateCacheEntry {
+ lastCheckAt: string;
+ registry: string;
+ latestVersion?: string;
+}
+
+export interface ValidatedUpdateCache {
+ lastCheckAt: number;
+ latestVersion?: string;
+ stale: boolean;
+}
+
+/** Broad truthy parsing shared by the update-notifier guards. */
+export function isTruthyEnv(value: string | undefined): boolean {
+ if (!value) return false;
+ return !['', '0', 'false', 'no', 'off'].includes(value.toLowerCase());
+}
+
+/** The update notifier is disabled by either opt-out env or a CI environment. */
+export function updateNotifierOptedOut(env: NodeJS.ProcessEnv): boolean {
+ return (
+ isTruthyEnv(env.GITNEXUS_NO_UPDATE_NOTIFIER) ||
+ isTruthyEnv(env.NO_UPDATE_NOTIFIER) ||
+ isTruthyEnv(env.CI)
+ );
+}
+
+/** Freshness gate for the 24h TTL; future-dated timestamps are stale. */
+export function isUpdateCacheFresh(lastCheckAt: string, now: number): boolean {
+ const checkedAt = Date.parse(lastCheckAt);
+ return checkedAt <= now && now - checkedAt < UPDATE_CACHE_TTL_MS;
+}
+
+/** Strict x.y.z numeric comparison. Invalid or prerelease versions are silent. */
+export function isNewerVersion(installedVersion: string, latestVersion: string): boolean {
+ if (!STRICT_UPDATE_VERSION.test(installedVersion) || !STRICT_UPDATE_VERSION.test(latestVersion)) {
+ return false;
+ }
+ const installed = installedVersion.split('.').map(BigInt);
+ const latest = latestVersion.split('.').map(BigInt);
+ for (let index = 0; index < 3; index += 1) {
+ if (latest[index] !== installed[index]) return latest[index] > installed[index];
+ }
+ return false;
+}
+
+let registryMemo: { key: string; value: { identity: string; packageUrl: string } } | undefined;
+
+export function normalizedUpdateRegistry(env: NodeJS.ProcessEnv = process.env): {
+ identity: string;
+ packageUrl: string;
+} {
+ const key = env.npm_config_registry ?? '';
+ if (registryMemo?.key === key) return registryMemo.value;
+ const value = buildUpdateRegistry(key);
+ registryMemo = { key, value };
+ return value;
+}
+
+function buildUpdateRegistry(rawRegistry: string): { identity: string; packageUrl: string } {
+ const parsed = new URL(rawRegistry || DEFAULT_UPDATE_REGISTRY);
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
+ throw new Error('Unsupported npm registry protocol');
+ }
+ if (parsed.search || parsed.hash) {
+ throw new Error('Registry URL cannot contain query or fragment');
+ }
+ parsed.username = '';
+ parsed.password = '';
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
+
+ const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
+ const identity = `${parsed.protocol}//${parsed.host}${pathname}`;
+ // `//latest` is the small dist-tag document. The full packument at
+ // `/` is multi-megabyte on this package and cannot fit the fetch cap.
+ const packagePath = `${pathname}/gitnexus/latest`.replace(/\/{2,}/g, '/');
+ return { identity, packageUrl: `${parsed.protocol}//${parsed.host}${packagePath}` };
+}
+
+export function updateCheckCachePath(env: NodeJS.ProcessEnv = process.env): string {
+ return path.join(env.GITNEXUS_HOME || getGlobalDir(), 'update-check.json');
+}
+
+export function updateCheckLockPath(env: NodeJS.ProcessEnv = process.env): string {
+ return path.join(env.GITNEXUS_HOME || getGlobalDir(), 'update-check.lock');
+}
+
+/**
+ * Best-effort synchronous probe: is a refresh plausibly in flight? Used to
+ * coalesce detached refresh spawns. A dead same-host owner returns false so
+ * the spawned child can reclaim the stale lock; a foreign-host owner returns
+ * false and lets the child's full lock logic decide.
+ */
+export function updateRefreshInProgress(env: NodeJS.ProcessEnv = process.env): boolean {
+ try {
+ const owner = JSON.parse(fs.readFileSync(updateCheckLockPath(env), 'utf8')) as {
+ pid?: unknown;
+ hostname?: unknown;
+ processStartTime?: unknown;
+ };
+ if (typeof owner.pid !== 'number' || owner.pid <= 0) return false;
+ if (owner.hostname !== os.hostname()) return false;
+ if (!isProcessAlive(owner.pid)) return false;
+ // Same rule as acquireFileLock: a live PID with a different start time is
+ // reuse, not the lock owner. Unreadable start time stays conservative
+ // (treat as in progress) so we don't spawn a racing child.
+ if (typeof owner.processStartTime === 'string' && owner.processStartTime) {
+ const currentStartTime = readProcessStartTime(owner.pid);
+ if (currentStartTime && currentStartTime !== owner.processStartTime) return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function parseUpdateCache(raw: string, registry: string): UpdateCacheEntry | null {
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ if (
+ typeof parsed.lastCheckAt !== 'string' ||
+ !Number.isFinite(Date.parse(parsed.lastCheckAt)) ||
+ parsed.registry !== registry ||
+ (parsed.latestVersion !== undefined &&
+ (typeof parsed.latestVersion !== 'string' ||
+ !STRICT_UPDATE_VERSION.test(parsed.latestVersion)))
+ ) {
+ return null;
+ }
+ return {
+ lastCheckAt: parsed.lastCheckAt,
+ registry: parsed.registry,
+ ...(parsed.latestVersion === undefined ? {} : { latestVersion: parsed.latestVersion }),
+ };
+ } catch {
+ return null;
+ }
+}
+
+export function readValidatedUpdateCacheSync(
+ options: {
+ env?: NodeJS.ProcessEnv;
+ now?: number;
+ } = {},
+): ValidatedUpdateCache | null {
+ try {
+ const env = options.env ?? process.env;
+ const registry = normalizedUpdateRegistry(env);
+ const raw = fs.readFileSync(updateCheckCachePath(env), 'utf8');
+ const entry = parseUpdateCache(raw, registry.identity);
+ if (!entry) return null;
+ const lastCheckAt = Date.parse(entry.lastCheckAt);
+ const now = options.now ?? Date.now();
+ return {
+ lastCheckAt,
+ ...(entry.latestVersion === undefined ? {} : { latestVersion: entry.latestVersion }),
+ stale: !isUpdateCacheFresh(entry.lastCheckAt, now),
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/gitnexus/src/core/update-check.ts b/gitnexus/src/core/update-check.ts
new file mode 100644
index 000000000..265bf912b
--- /dev/null
+++ b/gitnexus/src/core/update-check.ts
@@ -0,0 +1,328 @@
+import fs from 'node:fs/promises';
+import { getGlobalDir } from '../storage/global-dir.js';
+import { writeFileAtomic } from '../storage/fs-atomic.js';
+import { acquireFileLock, FileLockBusyError } from '../storage/file-lock.js';
+import { validateGitUrl } from './net/url-guard.js';
+import { updateEligibleInstall } from './install-context.js';
+import { createLogger } from './logger.js';
+import { packageVersion } from './package-version.js';
+import {
+ isNewerVersion,
+ isUpdateCacheFresh,
+ normalizedUpdateRegistry,
+ parseUpdateCache,
+ STRICT_UPDATE_VERSION,
+ UPDATE_CACHE_TTL_MS,
+ updateCheckCachePath,
+ updateCheckLockPath,
+ updateNotifierOptedOut,
+ type UpdateCacheEntry,
+} from './update-cache.js';
+
+const FETCH_TIMEOUT_MS = 3_000;
+const MAX_RESPONSE_BYTES = 64 * 1024;
+const MAX_REDIRECTS = 5;
+/** Backoff when refresh cannot publish (lock-busy or still-stale cache). */
+const LOCK_BUSY_RETRY_MIN_MS = 30_000;
+const LOCK_BUSY_RETRY_JITTER_MS = 30_000;
+const updateLogger = createLogger('update-check');
+
+function defaultInstalledVersion(): string {
+ return packageVersion();
+}
+
+function nextSchedulerDelay(entry: UpdateCacheEntry | null, now: number): number {
+ if (entry && isUpdateCacheFresh(entry.lastCheckAt, now)) {
+ return Math.max(1, Date.parse(entry.lastCheckAt) + UPDATE_CACHE_TTL_MS - now);
+ }
+ // Missing, future-dated, or still stale after a lock-busy skip: back off
+ // from now instead of deriving 1ms from a past-due timestamp.
+ return LOCK_BUSY_RETRY_MIN_MS + Math.floor(Math.random() * LOCK_BUSY_RETRY_JITTER_MS);
+}
+
+export interface UpdateState {
+ updateAvailable: boolean;
+ latestVersion?: string;
+}
+
+export interface UpdateCheckOptions {
+ /** Test/adapter override; omitted means classify process.argv[1]. */
+ eligible?: boolean;
+ /** Test override; omitted means this package's installed version. */
+ installedVersion?: string;
+ /** Test override in epoch milliseconds. */
+ now?: number;
+ /** Cache-only consumers can suppress stale-while-revalidate. */
+ refreshIfStale?: boolean;
+ /** Explicit `gitnexus update`: check even when CI/opt-out env is set. */
+ ignoreOptOut?: boolean;
+}
+
+export interface UpdateRefreshSchedulerOptions extends Omit<
+ UpdateCheckOptions,
+ 'now' | 'refreshIfStale'
+> {
+ now?: () => number;
+}
+
+function isOptedOut(): boolean {
+ return updateNotifierOptedOut(process.env);
+}
+
+async function isEligible(override: boolean | undefined): Promise {
+ return override ?? (await updateEligibleInstall());
+}
+
+function cacheFile(): string {
+ return updateCheckCachePath();
+}
+
+function lockFile(): string {
+ return updateCheckLockPath();
+}
+
+function normalizedRegistry(): { identity: string; packageUrl: string } {
+ const registry = normalizedUpdateRegistry();
+ validateGitUrl(registry.packageUrl);
+ return registry;
+}
+
+async function readCache(registry: string): Promise {
+ try {
+ return parseUpdateCache(await fs.readFile(cacheFile(), 'utf8'), registry);
+ } catch {
+ return null;
+ }
+}
+
+function stateFrom(entry: UpdateCacheEntry, installedVersion: string): UpdateState {
+ return {
+ updateAvailable:
+ entry.latestVersion !== undefined && isNewerVersion(installedVersion, entry.latestVersion),
+ ...(entry.latestVersion === undefined ? {} : { latestVersion: entry.latestVersion }),
+ };
+}
+
+function installedVersionOf(options: { installedVersion?: string }): string {
+ return options.installedVersion ?? defaultInstalledVersion();
+}
+
+async function isNotifierActive(
+ options: {
+ eligible?: boolean;
+ ignoreOptOut?: boolean;
+ } = {},
+): Promise {
+ return (options.ignoreOptOut === true || !isOptedOut()) && (await isEligible(options.eligible));
+}
+
+/**
+ * Read update state cache-first. Every invalid/missing/stale cache starts one
+ * catch-isolated refresh unless the caller explicitly requests cache-only.
+ */
+export async function evaluate(options: UpdateCheckOptions = {}): Promise {
+ try {
+ if (!(await isNotifierActive(options))) return null;
+ const registry = normalizedRegistry();
+ const now = options.now ?? Date.now();
+ const entry = await readCache(registry.identity);
+ if (
+ (!entry || !isUpdateCacheFresh(entry.lastCheckAt, now)) &&
+ options.refreshIfStale !== false
+ ) {
+ void refresh(options).catch(() => {});
+ }
+ if (!entry) return null;
+ return stateFrom(entry, installedVersionOf(options));
+ } catch {
+ return null;
+ }
+}
+
+async function readResponseBody(response: Response): Promise {
+ const advertised = Number(response.headers.get('content-length'));
+ if (Number.isFinite(advertised) && advertised > MAX_RESPONSE_BYTES) {
+ await response.body?.cancel().catch(() => {});
+ throw new Error('Registry response too large');
+ }
+ if (!response.body) return '';
+
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let bytes = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ bytes += value.byteLength;
+ if (bytes > MAX_RESPONSE_BYTES) throw new Error('Registry response too large');
+ chunks.push(value);
+ }
+ } finally {
+ if (bytes > MAX_RESPONSE_BYTES) await reader.cancel().catch(() => {});
+ reader.releaseLock();
+ }
+ return Buffer.concat(chunks).toString('utf8');
+}
+
+function sanitizedHttpUrl(input: string | URL, base?: string): URL {
+ const parsed = new URL(input, base);
+ parsed.username = '';
+ parsed.password = '';
+ validateGitUrl(parsed.toString());
+ return parsed;
+}
+
+async function fetchLatest(packageUrl: string): Promise {
+ let url = sanitizedHttpUrl(packageUrl);
+ for (let redirects = 0; ; redirects += 1) {
+ const response = await fetch(url.toString(), {
+ method: 'GET',
+ redirect: 'manual',
+ headers: { accept: 'application/json' },
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+ });
+ if (response.status >= 300 && response.status < 400) {
+ if (redirects >= MAX_REDIRECTS) throw new Error('Too many registry redirects');
+ const location = response.headers.get('location');
+ await response.body?.cancel().catch(() => {});
+ if (!location) throw new Error('Registry redirect missing location');
+ url = sanitizedHttpUrl(location, url.toString());
+ continue;
+ }
+ if (!response.ok) {
+ await response.body?.cancel().catch(() => {});
+ throw new Error(`Registry returned ${response.status}`);
+ }
+ const parsed = JSON.parse(await readResponseBody(response)) as {
+ version?: unknown;
+ 'dist-tags'?: { latest?: unknown };
+ };
+ const latest =
+ (typeof parsed.version === 'string' ? parsed.version : undefined) ??
+ (typeof parsed['dist-tags']?.latest === 'string' ? parsed['dist-tags'].latest : undefined);
+ if (typeof latest !== 'string' || !STRICT_UPDATE_VERSION.test(latest)) {
+ throw new Error('Registry latest version is invalid');
+ }
+ return latest;
+ }
+}
+
+async function publishMonotonically(
+ entry: UpdateCacheEntry,
+ attemptStartedAt: number,
+): Promise {
+ const current = await readCache(entry.registry);
+ const currentAt = current ? Date.parse(current.lastCheckAt) : Number.NaN;
+ // A later in-the-past write wins. Future-dated entries (wall-clock) are
+ // clock-skew poison and must stay replaceable so a later holder can repair them.
+ if (Number.isFinite(currentAt) && currentAt <= Date.now() && currentAt > attemptStartedAt) {
+ return;
+ }
+ await fs.mkdir(getGlobalDir(), { recursive: true });
+ await writeFileAtomic(cacheFile(), `${JSON.stringify(entry)}\n`, 1);
+}
+
+let refreshInFlight: Promise | null = null;
+
+/** Run one locked, fail-open registry refresh. */
+export function refresh(options: UpdateCheckOptions = {}): Promise {
+ if (refreshInFlight) return refreshInFlight;
+ const run = async (): Promise => {
+ let release: (() => Promise) | undefined;
+ try {
+ if (!(await isNotifierActive(options))) return null;
+ const registry = normalizedRegistry();
+ const attemptStartedAt = options.now ?? Date.now();
+ try {
+ release = await acquireFileLock(lockFile(), { retries: 0 });
+ } catch (error) {
+ if (error instanceof FileLockBusyError) return null;
+ throw error;
+ }
+
+ let fetched: string | undefined;
+ try {
+ fetched = await fetchLatest(registry.packageUrl);
+ } catch {
+ // Negative entries enforce the same TTL on offline/authenticated-only
+ // registries as successful checks. A known same-identity latestVersion
+ // must survive a later failed refresh so notices do not go silent
+ // for a day; only a first-ever miss stays version-less.
+ }
+ const latestVersion = fetched ?? (await readCache(registry.identity))?.latestVersion;
+ const entry: UpdateCacheEntry = {
+ lastCheckAt: new Date(attemptStartedAt).toISOString(),
+ registry: registry.identity,
+ ...(latestVersion === undefined ? {} : { latestVersion }),
+ };
+ await publishMonotonically(entry, attemptStartedAt);
+ // Live fetch failed: keep the on-disk pin for notices, but do not
+ // return it as a confirmed refresh so `gitnexus update` cannot install
+ // from an unconfirmed cache.
+ if (fetched === undefined) return null;
+ return stateFrom(entry, installedVersionOf(options));
+ } catch (error) {
+ updateLogger.debug(
+ { code: (error as NodeJS.ErrnoException).code },
+ 'Update check failed open',
+ );
+ return null;
+ } finally {
+ if (release) await release().catch(() => {});
+ }
+ };
+ refreshInFlight = run().finally(() => {
+ refreshInFlight = null;
+ });
+ return refreshInFlight;
+}
+
+/**
+ * Start an immediate evaluation and repeat on the cache TTL cadence. Timers
+ * never keep the process alive; refresh() supplies process-wide single-flight.
+ */
+export function armUpdateRefreshScheduler(
+ onState: (state: UpdateState | null) => void,
+ options: UpdateRefreshSchedulerOptions = {},
+): () => void {
+ let stopped = false;
+ let timer: NodeJS.Timeout | undefined;
+ const cycle = async (): Promise => {
+ if (stopped) return;
+ const now = options.now?.() ?? Date.now();
+ let entry: UpdateCacheEntry | null = null;
+ try {
+ const registry = normalizedRegistry();
+ entry = await readCache(registry.identity);
+ if (!entry || !isUpdateCacheFresh(entry.lastCheckAt, now)) {
+ await refresh({ ...options, now });
+ entry = await readCache(registry.identity);
+ }
+ } catch {
+ // The public scheduler shares the service's fail-open contract.
+ }
+ // Derive state from the entry already read above; a full evaluate() here
+ // would re-run guards and re-read the cache on every tick.
+ let state: UpdateState | null = null;
+ try {
+ state =
+ !entry || !(await isNotifierActive(options))
+ ? null
+ : stateFrom(entry, installedVersionOf(options));
+ } catch {
+ state = null;
+ }
+ if (!stopped) onState(state);
+ if (!stopped) {
+ timer = setTimeout(() => void cycle(), nextSchedulerDelay(entry, now));
+ timer.unref();
+ }
+ };
+ timer = setTimeout(() => void cycle(), 0);
+ timer.unref();
+ return () => {
+ stopped = true;
+ if (timer) clearTimeout(timer);
+ };
+}
diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts
index a3df48b4c..118f28fa1 100644
--- a/gitnexus/src/mcp/server.ts
+++ b/gitnexus/src/mcp/server.ts
@@ -11,8 +11,8 @@
* Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
*/
-import { createRequire } from 'module';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
+import { packageVersion } from '../core/package-version.js';
import { CompatibleStdioServerTransport } from './compatible-stdio-transport.js';
import {
CallToolRequestSchema,
@@ -106,12 +106,10 @@ export function createMCPServer(
}
const repositoryPolicy = options.repositoryPolicy ?? McpRepositoryPolicy.unrestricted();
const scopedBackend = repositoryPolicy.scopeBackend(backend);
- const require = createRequire(import.meta.url);
- const pkgVersion: string = require('../../package.json').version;
const server = new Server(
{
name: 'gitnexus',
- version: pkgVersion,
+ version: packageVersion(),
},
{
capabilities: {
diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts
index e91d40d17..0843d0b63 100644
--- a/gitnexus/src/server/api.ts
+++ b/gitnexus/src/server/api.ts
@@ -12,7 +12,6 @@ import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs/promises';
-import { createRequire } from 'node:module';
import {
canonicalizePath,
cloneDirBelongsToEntry,
@@ -80,9 +79,19 @@ import { UPLOAD_ROOT } from './upload-paths.js';
import { sweepStaleUploads } from './upload-sweep.js';
import { isRfc1918PrivateIpv4 } from './private-ip.js';
import { logger, flushLoggerSync } from '../core/logger.js';
+import {
+ bindServeUpdateControllerLifecycle,
+ buildServerInfo,
+ createServeUpdateController,
+} from './update-controller.js';
-const _require = createRequire(import.meta.url);
-const pkg = _require('../../package.json');
+export {
+ bindServeUpdateControllerLifecycle,
+ buildServerInfo,
+ createServeUpdateController,
+ type ServerInfoResponse,
+ type ServeUpdateController,
+} from './update-controller.js';
/**
* Determine whether an HTTP Origin header value is allowed by CORS policy.
@@ -844,6 +853,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
await backend.init();
const cleanupMcp = await mountMCPEndpoints(app, backend);
const jobManager = new JobManager();
+ const updateController = createServeUpdateController();
// Backstop: remove any upload staging dirs orphaned by a previous crash.
void sweepStaleUploads().catch(() => {});
@@ -981,21 +991,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Server info: version and launch context (npx / global / local dev)
app.get('/api/info', (_req, res) => {
- const execPath = process.env.npm_execpath ?? '';
- const argv0 = process.argv[1] ?? '';
- let launchContext: 'npx' | 'global' | 'local';
- if (
- execPath.includes('npx') ||
- argv0.includes('_npx') ||
- process.env.npm_config_prefix?.includes('_npx')
- ) {
- launchContext = 'npx';
- } else if (argv0.includes('node_modules')) {
- launchContext = 'local';
- } else {
- launchContext = 'global';
- }
- res.json({ version: pkg.version, launchContext, nodeVersion: process.version });
+ res.json(buildServerInfo(updateController.snapshot()));
});
// List all registered repos
@@ -2060,12 +2056,15 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
resolve();
});
server.on('error', (err) => reject(err));
+ // `listening` is the successful startup boundary for notifier work.
+ bindServeUpdateControllerLifecycle(server, updateController);
// Graceful shutdown — close Express + LadybugDB cleanly. Pino's default
// destination is `sync: false` (buffered); `flushLoggerSync()` before
// `process.exit` so records emitted during cleanup reach stderr.
const shutdown = async () => {
console.log('\nShutting down...');
+ updateController.stop();
server.close();
jobManager.dispose();
embedJobManager.dispose();
diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts
index 1cd204761..6fb1e25c9 100644
--- a/gitnexus/src/server/git-clone.ts
+++ b/gitnexus/src/server/git-clone.ts
@@ -8,17 +8,19 @@
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs/promises';
-import { isIP } from 'net';
import os from 'node:os';
import { logger } from '../core/logger.js';
import { getGlobalDir } from '../storage/repo-manager.js';
import { sanitizeRepoName, stripUrlCredentials } from '../storage/git.js';
+import { validateGitUrl } from '../core/net/url-guard.js';
import {
assertDirectoryOwnerAndPermissions,
quarantineAutoSyncPartial,
} from '../core/auto-sync/path-security.js';
import { validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
+export { validateGitUrl };
+
/**
* Root directory for all cloned repositories. Targets must resolve inside this.
*
@@ -92,178 +94,6 @@ export function getCloneDir(repoName: string): string {
return path.join(CLONE_ROOT, repoName);
}
-// Cloud metadata hostnames that must never be reachable via user-supplied URLs
-const BLOCKED_HOSTNAMES = new Set([
- 'localhost',
- 'metadata.google.internal',
- 'metadata.azure.com',
- 'metadata.internal',
-]);
-
-/**
- * Validate a git URL to prevent SSRF attacks.
- * Only allows https:// and http:// schemes. Blocks private/internal addresses,
- * IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
- */
-export function validateGitUrl(url: string): void {
- let parsed: URL;
- try {
- parsed = new URL(url);
- } catch {
- throw new Error('Invalid URL');
- }
-
- if (!['https:', 'http:'].includes(parsed.protocol)) {
- throw new Error('Only https:// and http:// git URLs are allowed');
- }
-
- if (parsed.search || parsed.hash) {
- throw new Error('Git URLs must not include query strings or fragments');
- }
-
- const host = parsed.hostname.toLowerCase();
-
- // Block known dangerous hostnames (cloud metadata services)
- if (BLOCKED_HOSTNAMES.has(host)) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // Strip IPv6 brackets if present (URL parser behavior varies across Node versions)
- let normalizedHost = host;
- if (host.startsWith('[') && host.endsWith(']')) {
- normalizedHost = host.slice(1, -1);
- }
-
- // Check if this is an IPv6 address
- // Use manual colon detection as fallback since isIP may return 0 for some
- // normalized IPv6 forms (e.g. ::ffff:7f00:1)
- const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':');
- if (isIPv6) {
- assertNotPrivateIPv6(normalizedHost);
- return;
- }
-
- // Check if this is an IPv4 address (including numeric encodings)
- if (isIP(normalizedHost) === 4) {
- assertNotPrivateIPv4(normalizedHost);
- return;
- }
-
- // For non-IP hostnames, check for numeric IP tricks
- // Decimal encoding: 2130706433 = 127.0.0.1
- // Hex encoding: 0x7f000001 = 127.0.0.1
- if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // Standard IPv4 regex checks for dotted notation
- if (
- /^127\./.test(host) ||
- /^10\./.test(host) ||
- /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
- /^192\.168\./.test(host) ||
- /^169\.254\./.test(host) ||
- /^0\./.test(host) ||
- host === '0.0.0.0' ||
- /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) ||
- /^198\.1[89]\./.test(host)
- ) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-}
-
-function assertNotPrivateIPv6(ip: string): void {
- // Expand common compressed forms for comparison
- const lower = ip.toLowerCase();
-
- // IPv6 loopback
- if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // Unspecified address
- if (lower === '::' || lower === '0:0:0:0:0:0:0:0') {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes)
- if (lower.startsWith('fc') || lower.startsWith('fd')) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // IPv6 link-local (fe80::/10)
- if (
- lower.startsWith('fe80') ||
- lower.startsWith('fe8') ||
- lower.startsWith('fe9') ||
- lower.startsWith('fea') ||
- lower.startsWith('feb')
- ) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex)
- // Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1
- if (lower.startsWith('::ffff:')) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // Also catch the expanded form: 0:0:0:0:0:ffff:
- if (lower.includes(':ffff:')) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
- // Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
- // is hidden in the last 32 bits without the ::ffff: marker, so the check
- // above misses it. The form is still routable to the embedded IPv4 on most
- // network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
- if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
- // 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
- // ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
- // e.g. 64:ff9b::7f00:1 → 127.0.0.1.
- // The check intentionally covers the full 64:ff9b::/32 block (broader than
- // the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
- // blocking the whole prefix is defensively sound and prevents a narrower
- // CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
- // future translation assignment.
- if (lower.startsWith('64:ff9b:')) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-
- // 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
- // 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
- // protocol was deprecated by RFC 7526 and the public relay anycast
- // (192.88.99.1) has been retired, so broad-blocking the prefix has near-
- // zero false-positive cost while closing the IPv4-embedded bypass.
- // Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
- // impractical and is out of scope here.
- if (lower.startsWith('2002:')) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-}
-
-function assertNotPrivateIPv4(ip: string): void {
- const parts = ip.split('.').map(Number);
- const [a, b] = parts;
- if (
- a === 127 ||
- a === 10 ||
- (a === 172 && b >= 16 && b <= 31) ||
- (a === 192 && b === 168) ||
- (a === 169 && b === 254) ||
- a === 0 ||
- (a === 100 && b >= 64 && b <= 127) ||
- (a === 198 && (b === 18 || b === 19))
- ) {
- throw new Error('Cloning from private/internal addresses is not allowed');
- }
-}
-
export interface CloneProgress {
phase: 'cloning' | 'pulling';
message: string;
diff --git a/gitnexus/src/server/update-controller.ts b/gitnexus/src/server/update-controller.ts
new file mode 100644
index 000000000..81b91f911
--- /dev/null
+++ b/gitnexus/src/server/update-controller.ts
@@ -0,0 +1,109 @@
+import { packageVersion } from '../core/package-version.js';
+import { armUpdateRefreshScheduler, evaluate, type UpdateState } from '../core/update-check.js';
+
+export interface ServerInfoResponse {
+ version: string;
+ launchContext: 'npx' | 'global' | 'local';
+ nodeVersion: string;
+ latestVersion?: string;
+ updateAvailable?: boolean;
+}
+
+interface ServeUpdateControllerDependencies {
+ evaluate: (options?: { refreshIfStale?: boolean }) => Promise;
+ armScheduler: (onState: (state: UpdateState | null) => void) => () => void;
+}
+
+export interface ServeUpdateController {
+ start: () => Promise;
+ stop: () => void;
+ snapshot: () => UpdateState | null;
+}
+
+/**
+ * Own the update state for one `serve` process. The route reads snapshot()
+ * synchronously; all cache and network work stays on the startup/scheduler path.
+ */
+export const createServeUpdateController = (
+ dependencies: ServeUpdateControllerDependencies = {
+ evaluate,
+ armScheduler: armUpdateRefreshScheduler,
+ },
+): ServeUpdateController => {
+ let updateState: UpdateState | null = null;
+ let stopScheduler: (() => void) | undefined;
+ let started = false;
+ let stopped = false;
+
+ return {
+ start: async () => {
+ if (started || stopped) return;
+ started = true;
+ try {
+ // Cache-only: the scheduler's first cycle owns any stale refresh.
+ updateState = await dependencies.evaluate({ refreshIfStale: false });
+ } catch {
+ updateState = null;
+ }
+ if (stopped) return;
+ try {
+ stopScheduler = dependencies.armScheduler((state) => {
+ if (
+ state?.updateAvailable !== updateState?.updateAvailable ||
+ state?.latestVersion !== updateState?.latestVersion
+ ) {
+ updateState = state;
+ }
+ });
+ } catch {
+ // Update checks are best-effort and never affect HTTP availability.
+ }
+ },
+ stop: () => {
+ if (stopped) return;
+ stopped = true;
+ try {
+ stopScheduler?.();
+ } catch {
+ // Shutdown must continue even if notifier cleanup unexpectedly fails.
+ }
+ },
+ snapshot: () => updateState,
+ };
+};
+
+export const buildServerInfo = (updateState: UpdateState | null): ServerInfoResponse => {
+ const execPath = process.env.npm_execpath ?? '';
+ const argv0 = process.argv[1] ?? '';
+ let launchContext: 'npx' | 'global' | 'local';
+ if (
+ execPath.includes('npx') ||
+ argv0.includes('_npx') ||
+ process.env.npm_config_prefix?.includes('_npx')
+ ) {
+ launchContext = 'npx';
+ } else if (argv0.includes('node_modules')) {
+ launchContext = 'local';
+ } else {
+ launchContext = 'global';
+ }
+
+ return {
+ version: packageVersion(),
+ launchContext,
+ nodeVersion: process.version,
+ ...(updateState?.updateAvailable && updateState.latestVersion
+ ? { latestVersion: updateState.latestVersion, updateAvailable: true }
+ : {}),
+ };
+};
+
+export const bindServeUpdateControllerLifecycle = (
+ server: {
+ once(event: 'listening' | 'close', listener: () => void): unknown;
+ },
+ controller: ServeUpdateController,
+): void => {
+ server.once('listening', () => void controller.start());
+ server.once('close', controller.stop);
+};
diff --git a/gitnexus/src/storage/global-dir.ts b/gitnexus/src/storage/global-dir.ts
new file mode 100644
index 000000000..c60e679d3
--- /dev/null
+++ b/gitnexus/src/storage/global-dir.ts
@@ -0,0 +1,7 @@
+import os from 'node:os';
+import path from 'node:path';
+
+/** Get the path to the global GitNexus directory. */
+export const getGlobalDir = (): string => {
+ return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
+};
diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts
index 7b5cdc25c..743b810b4 100644
--- a/gitnexus/src/storage/parse-cache.ts
+++ b/gitnexus/src/storage/parse-cache.ts
@@ -19,9 +19,9 @@
* - Chunk-level invalidation gives a useful speedup floor (98% on a single
* 1-of-50 invalidated chunk) without touching the worker.
*
- * Survives `--force` because it's content-addressed: the same bytes always
- * produce the same key. `--force` only matters for the LadybugDB writeback;
- * the cache itself is always safe to reuse.
+ * `--force` still reuses content-addressed shards (it only rebuilds graph/FTS).
+ * `useParseCache: false` reparses every file, writes a staging generation, and
+ * publishes onto this cache only after a successful analysis.
*/
import { createHash } from 'crypto';
@@ -815,6 +815,27 @@ export const packParseCacheChunks = (
const LEGACY_CACHE_FILENAME = 'parse-cache.json';
const CACHE_DIRNAME = 'parse-cache';
+/**
+ * Per-run staging root for `useParseCache: false`. Parse-cache shards and the
+ * ParsedFile stores write here so a crash cannot mix a new generation into the
+ * live `.gitnexus/parse-cache` / `parsedfile-cache` trees. `saveParseCache`
+ * publishes onto the live `storagePath` only after a successful analysis.
+ */
+export const COLD_PARSE_REBUILD_DIRNAME = 'parse-rebuild';
+
+/** Deterministic staging path — tests only. Production uses {@link createColdParseRebuildDir}. */
+export const getColdParseRebuildDir = (storagePath: string): string =>
+ path.join(storagePath, COLD_PARSE_REBUILD_DIRNAME);
+
+/**
+ * Unique per analyze process so concurrent `--no-parse-cache` runs on
+ * different branch slots (shared `.gitnexus`, separate index locks) do not
+ * delete each other's staging tree.
+ */
+export const createColdParseRebuildDir = async (storagePath: string): Promise => {
+ await fs.mkdir(storagePath, { recursive: true });
+ return fs.mkdtemp(path.join(storagePath, `${COLD_PARSE_REBUILD_DIRNAME}.`));
+};
const CACHE_INDEX_FILENAME = 'index.json';
/** Keys on disk always come from `computeChunkHash` — 64-char lowercase hex. */
@@ -851,6 +872,8 @@ export interface ParseCache {
* When set, chunk payloads are loaded from / flushed to sharded files on
* demand instead of retaining every chunk in `entries` for the whole run
* (#1983 — Linux kernel OOM from duplicate in-memory cache + graph).
+ * May be a per-run staging directory (`getColdParseRebuildDir`) while the
+ * live index root is passed separately to `saveParseCache`.
*/
storagePath?: string;
/** Index of chunk hashes known to exist under `storagePath/parse-cache/`. */
@@ -1034,6 +1057,11 @@ export const loadParseCacheChunk = async (
*/
const createdCacheDirs = new Set();
+/** Drop the mkdir memo after the staging tree is wiped so the next persist recreates it. */
+export const forgetCreatedParseCacheDir = (storagePath: string): void => {
+ createdCacheDirs.delete(getCacheDirPath(storagePath));
+};
+
/**
* Persist one chunk shard and avoid retaining it in RAM for the rest of the
* run. Falls back to `cache.entries` when `storagePath` is unset (unit tests).
@@ -1171,8 +1199,18 @@ export const saveParseCache = async (storagePath: string, cache: ParseCache): Pr
}
continue;
}
- const existingPath = getCacheChunkPath(storagePath, chunkHash);
- if (await copyV8CacheIfPresent(existingPath, chunkPath)) {
+ // Cold rebuilds persist mid-run under `cache.storagePath` (staging). Prefer
+ // that generation over a same-hash shard still sitting in the live dir so
+ // we never publish a mixed old/new pair. Sibling-branch keys (#2106) that
+ // this run did not rewrite still copy from the live path.
+ const stagedPath =
+ cache.storagePath !== undefined && cache.storagePath !== storagePath
+ ? getCacheChunkPath(cache.storagePath, chunkHash)
+ : undefined;
+ const livePath = getCacheChunkPath(storagePath, chunkHash);
+ const fromStaged = Boolean(stagedPath && cache.onDiskKeys?.has(chunkHash));
+ const sourcePath = fromStaged && stagedPath ? stagedPath : livePath;
+ if (await copyV8CacheIfPresent(sourcePath, chunkPath)) {
writtenKeys.push(chunkHash);
}
}
@@ -1216,10 +1254,12 @@ export const pruneCache = (cache: ParseCache, usedHashes: ReadonlySet):
return removed;
};
-const emptyCache = (storagePath?: string): ParseCache => ({
+export const emptyParseCache = (storagePath?: string): ParseCache => ({
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath,
onDiskKeys: storagePath ? new Set() : undefined,
});
+
+const emptyCache = emptyParseCache;
diff --git a/gitnexus/src/storage/parsedfile-store.ts b/gitnexus/src/storage/parsedfile-store.ts
index 51e2a9a54..dce10b03d 100644
--- a/gitnexus/src/storage/parsedfile-store.ts
+++ b/gitnexus/src/storage/parsedfile-store.ts
@@ -722,3 +722,74 @@ export const pruneAndSaveDurableParsedFileStore = async (
await fs.writeFile(tmp, JSON.stringify(idx), 'utf-8');
await fs.rename(tmp, path.join(durableDir, DURABLE_INDEX_FILENAME));
};
+
+/**
+ * Overlay this run's staged durable ParsedFile chunks onto the live store,
+ * then prune the live tree to `keepKeys`. Live chunks this run did not rewrite
+ * (other branches, unused hashes) stay until prune. No-op overlay when the
+ * staged dir is missing.
+ */
+export const mergeStagedDurableParsedFileStore = async (
+ liveStoragePath: string,
+ stagedStoragePath: string,
+ version: string,
+ keepKeys: ReadonlySet,
+): Promise => {
+ const liveDir = getDurableParsedFileDir(liveStoragePath);
+ if (stagedStoragePath === liveStoragePath) {
+ await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys);
+ return;
+ }
+ const stagedDir = getDurableParsedFileDir(stagedStoragePath);
+ await fs.mkdir(liveDir, { recursive: true });
+ let stagedEntries: string[] = [];
+ try {
+ stagedEntries = await fs.readdir(stagedDir);
+ } catch {
+ await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys);
+ return;
+ }
+ for (const name of stagedEntries) {
+ if (name === DURABLE_INDEX_FILENAME) continue;
+ const from = path.join(stagedDir, name);
+ const to = path.join(liveDir, name);
+ await replaceDurableChunkDir(from, to);
+ }
+ await pruneAndSaveDurableParsedFileStore(liveDir, version, keepKeys);
+};
+
+/** Move `from` onto `to` without deleting `to` until the new tree is in place. */
+const replaceDurableChunkDir = async (from: string, to: string): Promise => {
+ try {
+ await fs.rename(from, to);
+ return;
+ } catch {
+ /* dest exists, or the rename is cross-device */
+ }
+ const backup = `${to}.replacing`;
+ await fs.rm(backup, { recursive: true, force: true });
+ let backedUp = false;
+ try {
+ await fs.rename(to, backup);
+ backedUp = true;
+ } catch {
+ /* dest was missing */
+ }
+ try {
+ try {
+ await fs.rename(from, to);
+ } catch {
+ await fs.cp(from, to, { recursive: true });
+ await fs.rm(from, { recursive: true, force: true });
+ }
+ } catch (err) {
+ if (backedUp) {
+ await fs.rm(to, { recursive: true, force: true }).catch(() => {});
+ await fs.rename(backup, to).catch(() => {});
+ }
+ throw err;
+ }
+ if (backedUp) {
+ await fs.rm(backup, { recursive: true, force: true });
+ }
+};
diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts
index 383f25dcc..c8d9ad487 100644
--- a/gitnexus/src/storage/repo-manager.ts
+++ b/gitnexus/src/storage/repo-manager.ts
@@ -17,10 +17,10 @@
import fs from 'fs/promises';
import { realpathSync } from 'fs';
import path from 'path';
-import os from 'os';
import { getInferredRepoName, resolveRepoIdentityRoot, stripUrlCredentials } from './git.js';
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
import { writeFileAtomic } from './fs-atomic.js';
+import { getGlobalDir } from './global-dir.js';
import { logger } from '../core/logger.js';
import { acquireIndexLock, IndexLockTimeoutError, type IndexLockHandle } from './index-lock.js';
import {
@@ -53,6 +53,7 @@ export type { BranchSummary };
// `tryReadMetaFile` stay module-private here, exactly as before.
export { getStoragePath, INDEX_METADATA_FILE, isMissingFilesystemError, loadMeta };
export type { AnalyzerRunnerIdentity, RepoMeta };
+export { getGlobalDir } from './global-dir.js';
/**
* Normalise a repo path for registry comparison across platforms
@@ -517,13 +518,6 @@ const ensureGitInfoExclude = async (repoPath: string): Promise => {
// ─── Global Registry (~/.gitnexus/registry.json) ───────────────────────
-/**
- * Get the path to the global GitNexus directory
- */
-export const getGlobalDir = (): string => {
- return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
-};
-
/**
* Get the path to the global registry file
*/
diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts
index 960517416..d665a7acf 100644
--- a/gitnexus/src/types/pipeline.ts
+++ b/gitnexus/src/types/pipeline.ts
@@ -54,6 +54,8 @@ export interface PipelineResult {
usedWorkerPool: boolean;
/** Files actually dispatched to parser workers after parse-cache lookup. */
reparsedFileCount: number;
+ /** Files restored from parse-cache chunks without parser-worker dispatch. */
+ parseCacheHitFileCount?: number;
/** Files omitted from scope-resolution while the rest of analysis continued. */
scopeExtractionFailures: readonly string[];
/** Files scope resolution could not inspect because their parser was unavailable. */
diff --git a/gitnexus/test/integration/cli/update-notice.test.ts b/gitnexus/test/integration/cli/update-notice.test.ts
new file mode 100644
index 000000000..abebfbd28
--- /dev/null
+++ b/gitnexus/test/integration/cli/update-notice.test.ts
@@ -0,0 +1,228 @@
+import { spawn, spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../../helpers/cli-entry.js';
+import { cleanupTempDirSync } from '../../helpers/test-db.js';
+import { packageVersion } from '../../../src/core/package-version.js';
+
+const repoRoot = path.resolve(import.meta.dirname, '../../..');
+const installedVersion = packageVersion();
+const tempDirs: string[] = [];
+
+function tempHome(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cli-update-notice-'));
+ tempDirs.push(dir);
+ return dir;
+}
+
+function seededCache(home: string): string {
+ const file = path.join(home, 'update-check.json');
+ fs.writeFileSync(
+ file,
+ `${JSON.stringify({
+ lastCheckAt: '2000-01-01T00:00:00.000Z',
+ registry: 'https://registry.npmjs.org',
+ latestVersion: '99.0.0',
+ })}\n`,
+ );
+ return file;
+}
+
+function localeEnv(home: string): NodeJS.ProcessEnv {
+ return {
+ ...process.env,
+ GITNEXUS_HOME: home,
+ CI: '',
+ GITNEXUS_NO_UPDATE_NOTIFIER: '',
+ NO_UPDATE_NOTIFIER: '',
+ GITNEXUS_LANG: 'en',
+ LC_ALL: '',
+ LC_MESSAGES: '',
+ LANG: 'C',
+ };
+}
+
+function cli(args: string[], home: string) {
+ return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: localeEnv(home),
+ });
+}
+
+afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ cleanupTempDirSync(dir);
+ }
+});
+
+describe('CLI update notice subprocess behavior', () => {
+ it('keeps non-TTY stdout byte-clean and neither emits nor spawns a refresh child', () => {
+ const home = tempHome();
+ const cache = seededCache(home);
+ const before = fs.readFileSync(cache, 'utf8');
+
+ // `list` is a normal command (not --version/--help, which skip the notifier).
+ const result = cli(['list'], home);
+
+ expect(result.status).toBe(0);
+ expect(result.stderr).not.toContain('is available');
+ expect(result.stdout).not.toContain('99.0.0 is available');
+ expect(fs.readFileSync(cache, 'utf8')).toBe(before);
+ expect(fs.existsSync(path.join(home, 'update-check.lock'))).toBe(false);
+ });
+
+ it('keeps help output unchanged and hides the internal refresh command', () => {
+ const result = cli(['--help'], tempHome());
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain('Usage: gitnexus [options] [command]');
+ expect(result.stdout).toContain('update');
+ expect(result.stdout).not.toContain('__update-check');
+ expect(result.stderr).toBe('');
+ });
+
+ it('prints a versioned command banner on stderr for a normal command', () => {
+ const result = cli(['list'], tempHome());
+
+ expect(result.stderr).toMatch(/GitNexus List \([^)]+\)/);
+ expect(result.stdout).not.toMatch(/GitNexus List \(/);
+ });
+
+ it('documents that gitnexus update installs via npm i -g', () => {
+ const result = cli(['update', '--help'], tempHome());
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toMatch(/npm i -g gitnexus@/);
+ expect(result.stderr).toBe('');
+ });
+
+ it('runs the hidden refresh command without writing stdout', () => {
+ const result = cli(['__update-check'], tempHome());
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toBe('');
+ });
+
+ it('lets the parent exit without waiting for a detached refresh child', async () => {
+ const home = tempHome();
+ const project = path.join(home, 'project');
+ const installedPackage = path.join(project, 'node_modules', 'gitnexus');
+ fs.mkdirSync(installedPackage, { recursive: true });
+ fs.cpSync(path.join(repoRoot, 'src'), path.join(installedPackage, 'src'), {
+ recursive: true,
+ });
+ fs.copyFileSync(
+ path.join(repoRoot, 'package.json'),
+ path.join(installedPackage, 'package.json'),
+ );
+ fs.symlinkSync(
+ path.join(repoRoot, 'node_modules'),
+ path.join(installedPackage, 'node_modules'),
+ 'dir',
+ );
+
+ const preload = path.join(home, 'mock-refresh.mjs');
+ fs.writeFileSync(
+ preload,
+ `Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
+globalThis.fetch = async () => {
+ await new Promise((resolve) => setTimeout(resolve, 750));
+ return new Response(JSON.stringify({ version: '99.0.0' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+};
+`,
+ );
+
+ const startedAt = Date.now();
+ await new Promise((resolve, reject) => {
+ const parent = spawn(
+ process.execPath,
+ [path.join(installedPackage, 'src', 'cli', 'index.ts'), 'list'],
+ {
+ cwd: project,
+ stdio: 'ignore',
+ env: {
+ ...localeEnv(home),
+ NODE_OPTIONS:
+ `--import ${tsxLoaderUrl()} --import ${pathToFileURL(preload).href}`.trim(),
+ },
+ },
+ );
+ parent.once('error', reject);
+ parent.once('exit', (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`notifier parent exited ${String(code)}`));
+ });
+ });
+ const elapsed = Date.now() - startedAt;
+
+ expect(elapsed).toBeLessThan(1_800);
+ const cache = path.join(home, 'update-check.json');
+ expect(fs.existsSync(cache)).toBe(false);
+ await expect.poll(() => fs.existsSync(cache), { timeout: 15_000, interval: 100 }).toBe(true);
+ expect(JSON.parse(fs.readFileSync(cache, 'utf8'))).toMatchObject({
+ latestVersion: '99.0.0',
+ registry: 'https://registry.npmjs.org',
+ });
+ }, 20_000);
+
+ it('prints the localized notice on a forced-TTY stderr and keeps stdout clean', () => {
+ const home = tempHome();
+ fs.writeFileSync(
+ path.join(home, 'update-check.json'),
+ `${JSON.stringify({
+ lastCheckAt: new Date().toISOString(),
+ registry: 'https://registry.npmjs.org',
+ latestVersion: '99.0.0',
+ })}\n`,
+ );
+ const project = path.join(home, 'project');
+ const installedPackage = path.join(project, 'node_modules', 'gitnexus');
+ fs.mkdirSync(installedPackage, { recursive: true });
+ fs.cpSync(path.join(repoRoot, 'src'), path.join(installedPackage, 'src'), {
+ recursive: true,
+ });
+ fs.copyFileSync(
+ path.join(repoRoot, 'package.json'),
+ path.join(installedPackage, 'package.json'),
+ );
+ fs.symlinkSync(
+ path.join(repoRoot, 'node_modules'),
+ path.join(installedPackage, 'node_modules'),
+ 'dir',
+ );
+
+ const preload = path.join(home, 'force-tty.mjs');
+ fs.writeFileSync(
+ preload,
+ `Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });\n`,
+ );
+
+ const result = spawnSync(
+ process.execPath,
+ [path.join(installedPackage, 'src', 'cli', 'index.ts'), 'list'],
+ {
+ cwd: project,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: {
+ ...localeEnv(home),
+ NODE_OPTIONS: `--import ${tsxLoaderUrl()} --import ${pathToFileURL(preload).href}`.trim(),
+ },
+ },
+ );
+
+ expect(result.stderr).toContain(
+ `GitNexus 99.0.0 is available (you are running ${installedVersion}).`,
+ );
+ expect(result.stdout).not.toContain('99.0.0 is available');
+ });
+});
diff --git a/gitnexus/test/integration/mcp/update-notice.test.ts b/gitnexus/test/integration/mcp/update-notice.test.ts
new file mode 100644
index 000000000..fee79fc1d
--- /dev/null
+++ b/gitnexus/test/integration/mcp/update-notice.test.ts
@@ -0,0 +1,379 @@
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
+import { createMCPServer } from '../../../src/mcp/server.js';
+import type { UpdateState } from '../../../src/core/update-check.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
+
+interface FakeChecker {
+ evaluate: () => Promise;
+ armUpdateRefreshScheduler: (onState: (state: UpdateState | null) => void) => () => void;
+}
+
+interface FakeLogger {
+ info: ReturnType;
+}
+
+function checker(initial: UpdateState | null): {
+ service: FakeChecker;
+ publish: (state: UpdateState | null) => void;
+ stop: ReturnType;
+} {
+ let subscriber: ((state: UpdateState | null) => void) | undefined;
+ const stop = vi.fn();
+ return {
+ service: {
+ evaluate: vi.fn().mockResolvedValue(initial),
+ armUpdateRefreshScheduler: vi.fn((onState) => {
+ subscriber = onState;
+ return stop;
+ }),
+ },
+ publish: (state) => subscriber?.(state),
+ stop,
+ };
+}
+
+function mockBackend() {
+ return {
+ callTool: vi
+ .fn()
+ .mockImplementation(async (name: string) =>
+ name === 'list_repos'
+ ? { repositories: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false } }
+ : { ok: true },
+ ),
+ listRepos: vi.fn().mockResolvedValue([]),
+ resolveRepo: vi
+ .fn()
+ .mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
+ selectToolRepository: vi
+ .fn()
+ .mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
+ getContext: vi.fn().mockReturnValue(null),
+ queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
+ queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
+ queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
+ queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
+ disconnect: vi.fn().mockResolvedValue(undefined),
+ };
+}
+
+async function protocolSnapshot(pendingUpdate: boolean): Promise {
+ const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
+ const backend = mockBackend();
+ const server = createMCPServer(backend as never);
+ const client = new Client({ name: 'update-snapshot', version: '0.0.0' });
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ const log = { info: vi.fn() };
+ const fake = checker(pendingUpdate ? { updateAvailable: true, latestVersion: '99.0.0' } : null);
+
+ try {
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
+ await startMcpUpdateNotifier(log, async () => fake.service);
+
+ const snapshot = {
+ initialize: {
+ serverInfo: client.getServerVersion(),
+ capabilities: client.getServerCapabilities(),
+ },
+ tools: await client.listTools(),
+ resources: await client.listResources(),
+ resource: await client.readResource({ uri: 'gitnexus://repos' }),
+ prompts: await client.listPrompts(),
+ call: await client.callTool({ name: 'list_repos', arguments: { limit: 5 } }),
+ };
+ return JSON.stringify(snapshot);
+ } finally {
+ await client.close();
+ await server.close();
+ }
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ vi.doUnmock('../../../src/mcp/server.js');
+ vi.doUnmock('../../../src/mcp/local/local-backend.js');
+ vi.doUnmock('../../../src/mcp/repository-policy.js');
+ vi.doUnmock('../../../src/mcp/http-transport.js');
+ vi.doUnmock('../../../src/core/logger.js');
+ vi.doUnmock('../../../src/core/update-check.js');
+});
+
+describe('MCP process update notice', () => {
+ it('keeps the full protocol surface byte-identical with and without a cached update', async () => {
+ expect(await protocolSnapshot(true)).toBe(await protocolSnapshot(false));
+ });
+
+ it.each(['CI', 'GITNEXUS_NO_UPDATE_NOTIFIER'])(
+ 'emits no log and performs no fetch when %s is set',
+ async (name) => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-guard-'));
+ fs.writeFileSync(
+ path.join(home, 'update-check.json'),
+ `${JSON.stringify({
+ lastCheckAt: new Date().toISOString(),
+ registry: 'https://registry.npmjs.org',
+ latestVersion: '99.0.0',
+ })}\n`,
+ );
+ const previousHome = process.env.GITNEXUS_HOME;
+ const previousCi = process.env.CI;
+ const previousOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
+ const fetchStub = vi.fn();
+ vi.stubGlobal('fetch', fetchStub);
+ process.env.GITNEXUS_HOME = home;
+ process.env[name] = '1';
+ if (name !== 'CI') delete process.env.CI;
+ const actualChecker = await vi.importActual<
+ typeof import('../../../src/core/update-check.js')
+ >('../../../src/core/update-check.js');
+ const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
+ const log: FakeLogger = { info: vi.fn() };
+
+ try {
+ await startMcpUpdateNotifier(log, async () => actualChecker);
+ expect(log.info).not.toHaveBeenCalled();
+ expect(fetchStub).not.toHaveBeenCalled();
+ } finally {
+ if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
+ else process.env.GITNEXUS_HOME = previousHome;
+ if (previousCi === undefined) delete process.env.CI;
+ else process.env.CI = previousCi;
+ if (previousOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
+ else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousOptOut;
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ },
+ );
+
+ it('emits one structured stderr logger event per process per newer version', async () => {
+ const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
+ const log: FakeLogger = { info: vi.fn() };
+ const first = checker({ updateAvailable: true, latestVersion: '9.0.0' });
+ const second = checker({ updateAvailable: true, latestVersion: '9.0.0' });
+
+ await startMcpUpdateNotifier(log, async () => first.service);
+ first.publish({ updateAvailable: true, latestVersion: '9.0.0' });
+ await startMcpUpdateNotifier(log, async () => second.service);
+ second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
+ second.publish({ updateAvailable: true, latestVersion: '10.0.0' });
+
+ expect(log.info).toHaveBeenCalledTimes(2);
+ expect(log.info).toHaveBeenNthCalledWith(
+ 1,
+ { event: 'gitnexus.update_available', latestVersion: '9.0.0' },
+ 'GitNexus update available',
+ );
+ expect(log.info).toHaveBeenNthCalledWith(
+ 2,
+ { event: 'gitnexus.update_available', latestVersion: '10.0.0' },
+ 'GitNexus update available',
+ );
+ });
+
+ it('uses only the logger channel and never writes directly to stdout', async () => {
+ const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
+ const stdout = vi.spyOn(process.stdout, 'write');
+ const log: FakeLogger = { info: vi.fn() };
+ const fake = checker({ updateAvailable: true, latestVersion: '11.0.0' });
+
+ await startMcpUpdateNotifier(log, async () => fake.service);
+
+ expect(stdout).not.toHaveBeenCalled();
+ expect(log.info).toHaveBeenCalledOnce();
+ });
+
+ it('catch-isolates checker import, evaluation, logger, and scheduler failures', async () => {
+ const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
+
+ await expect(
+ startMcpUpdateNotifier({ info: vi.fn() }, async () => {
+ throw new Error('import failed');
+ }),
+ ).resolves.toBeUndefined();
+
+ await expect(
+ startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
+ evaluate: vi.fn().mockRejectedValue(new Error('evaluation failed')),
+ armUpdateRefreshScheduler: vi.fn(() => () => {}),
+ })),
+ ).resolves.toBeUndefined();
+
+ await expect(
+ startMcpUpdateNotifier(
+ {
+ info: vi.fn(() => {
+ throw new Error('logger failed');
+ }),
+ },
+ async () => ({
+ evaluate: vi.fn().mockResolvedValue({
+ updateAvailable: true,
+ latestVersion: '12.0.0',
+ }),
+ armUpdateRefreshScheduler: vi.fn(() => () => {}),
+ }),
+ ),
+ ).resolves.toBeUndefined();
+
+ await expect(
+ startMcpUpdateNotifier({ info: vi.fn() }, async () => ({
+ evaluate: vi.fn().mockResolvedValue(null),
+ armUpdateRefreshScheduler: vi.fn(() => {
+ throw new Error('scheduler failed');
+ }),
+ })),
+ ).resolves.toBeUndefined();
+ });
+
+ it.each([
+ ['stdio', 'hang'],
+ ['http', 'fail'],
+ ] as const)(
+ 'starts %s notifier work only after its startup boundary and never awaits a registry %s',
+ async (transport, registryBehavior) => {
+ const order: string[] = [];
+ let evaluateStarted!: () => void;
+ const started = new Promise((resolve) => {
+ evaluateStarted = resolve;
+ });
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-update-'));
+ const previousHome = process.env.GITNEXUS_HOME;
+ const previousCi = process.env.CI;
+ const previousGitnexusOptOut = process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
+ const previousNoUpdate = process.env.NO_UPDATE_NOTIFIER;
+ let releaseFetch: ((response: Response) => void) | undefined;
+ const fetchStub = vi.fn(() =>
+ registryBehavior === 'hang'
+ ? new Promise((resolve) => {
+ releaseFetch = resolve;
+ })
+ : Promise.reject(new Error('registry unavailable')),
+ );
+ vi.stubGlobal('fetch', fetchStub);
+ process.env.GITNEXUS_HOME = home;
+ delete process.env.CI;
+ delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
+ delete process.env.NO_UPDATE_NOTIFIER;
+ const actualChecker = await vi.importActual<
+ typeof import('../../../src/core/update-check.js')
+ >('../../../src/core/update-check.js');
+
+ vi.doMock('../../../src/mcp/server.js', () => ({
+ startMCPServer: vi.fn(async () => {
+ order.push('stdio-connected');
+ }),
+ }));
+ vi.doMock('../../../src/mcp/local/local-backend.js', () => ({
+ LocalBackend: class {
+ async init() {}
+ async listRepos() {
+ return [];
+ }
+ },
+ }));
+ vi.doMock('../../../src/mcp/repository-policy.js', () => ({
+ createMcpRepositoryPolicy: vi.fn(async () => ({
+ scopeBackend: (backend: unknown) => backend,
+ })),
+ }));
+ vi.doMock('../../../src/core/logger.js', () => ({
+ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
+ }));
+ vi.doMock('../../../src/mcp/http-transport.js', () => ({
+ resolveAuthToken: vi.fn(),
+ startMcpHttpServer: vi.fn(async () => {
+ order.push('http-listening');
+ }),
+ }));
+ vi.doMock('../../../src/core/update-check.js', () => ({
+ ...actualChecker,
+ evaluate: vi.fn(() => {
+ order.push('evaluate');
+ evaluateStarted();
+ return actualChecker.evaluate({ eligible: true });
+ }),
+ armUpdateRefreshScheduler: vi.fn(() => () => {}),
+ }));
+
+ try {
+ const { mcpCommand } = await import('../../../src/cli/mcp.js');
+ await expect(
+ mcpCommand(transport === 'http' ? { http: true, port: '3000' } : undefined),
+ ).resolves.toBeUndefined();
+ await started;
+ await vi.waitFor(() => expect(fetchStub).toHaveBeenCalledOnce());
+
+ expect(order).toEqual([
+ transport === 'http' ? 'http-listening' : 'stdio-connected',
+ 'evaluate',
+ ]);
+ } finally {
+ if (releaseFetch) {
+ releaseFetch(new Response('', { status: 503 }));
+ await actualChecker.refresh({ eligible: true });
+ }
+ if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
+ else process.env.GITNEXUS_HOME = previousHome;
+ if (previousCi === undefined) delete process.env.CI;
+ else process.env.CI = previousCi;
+ if (previousGitnexusOptOut === undefined) delete process.env.GITNEXUS_NO_UPDATE_NOTIFIER;
+ else process.env.GITNEXUS_NO_UPDATE_NOTIFIER = previousGitnexusOptOut;
+ if (previousNoUpdate === undefined) delete process.env.NO_UPDATE_NOTIFIER;
+ else process.env.NO_UPDATE_NOTIFIER = previousNoUpdate;
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+ },
+ );
+
+ it('wires the scheduler stop function into process exit', async () => {
+ const { startMcpUpdateNotifier } = await import('../../../src/cli/mcp.js');
+ const fake = checker(null);
+ const before = new Set(process.listeners('exit'));
+
+ await startMcpUpdateNotifier({ info: vi.fn() }, async () => fake.service);
+ const added = process.listeners('exit').filter((listener) => !before.has(listener));
+ expect(added).toHaveLength(1);
+
+ added[0](0);
+ expect(fake.stop).toHaveBeenCalledOnce();
+ process.removeListener('exit', added[0]);
+ });
+
+ it('uses an unrefd scheduler timer so an opted-out MCP process can exit', async () => {
+ const script = [
+ "import { startMcpUpdateNotifier } from './dist/cli/mcp.js';",
+ 'await startMcpUpdateNotifier({ info() {} });',
+ ].join('\n');
+ const child = spawn(process.execPath, ['--input-type=module', '--eval', script], {
+ cwd: REPO_ROOT,
+ env: { ...process.env, GITNEXUS_NO_UPDATE_NOTIFIER: '1', NODE_OPTIONS: '' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ const result = await new Promise<{ code: number | null; stderr: string }>((resolve, reject) => {
+ let stderr = '';
+ child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
+ const timeout = setTimeout(() => {
+ child.kill('SIGKILL');
+ reject(new Error('MCP notifier scheduler kept the child process alive'));
+ }, 2_000);
+ child.once('error', reject);
+ child.once('close', (code) => {
+ clearTimeout(timeout);
+ resolve({ code, stderr });
+ });
+ });
+
+ expect(result).toEqual({ code: 0, stderr: '' });
+ });
+});
diff --git a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts
index 93cac4d8a..bab7eb100 100644
--- a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts
+++ b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts
@@ -137,6 +137,25 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)',
expect(opts.repairFts).toBe(true);
});
+ it('maps --no-parse-cache to a cold parser run', async () => {
+ const { analyzeCommand } = await import('../../src/cli/analyze.js');
+
+ await analyzeCommand(undefined, { parseCache: false });
+
+ const opts = runFullAnalysisMock.mock.calls[0][1];
+ expect(opts.useParseCache).toBe(false);
+ expect(opts.force).toBe(true);
+ });
+
+ it('reuses parser output by default', async () => {
+ const { analyzeCommand } = await import('../../src/cli/analyze.js');
+
+ await analyzeCommand(undefined, {});
+
+ const opts = runFullAnalysisMock.mock.calls[0][1];
+ expect(opts.useParseCache).toBe(true);
+ });
+
it('rejects combining --repair-fts with --force', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
@@ -144,11 +163,20 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)',
expect(process.exitCode).toBe(1);
expect(cliErrorMock).toHaveBeenCalledWith(
- expect.stringMatching(/cannot combine `--repair-fts` with `--force`/i),
+ expect.stringMatching(/cannot combine `--repair-fts` with a full rebuild/i),
);
expect(runFullAnalysisMock).not.toHaveBeenCalled();
});
+ it('rejects combining --repair-fts with --no-parse-cache', async () => {
+ const { analyzeCommand } = await import('../../src/cli/analyze.js');
+
+ await analyzeCommand(undefined, { repairFts: true, parseCache: false });
+
+ expect(process.exitCode).toBe(1);
+ expect(runFullAnalysisMock).not.toHaveBeenCalled();
+ });
+
it('passes stats:false as noStats to generateAIContextFiles on the --skills regeneration path (#1477)', async () => {
runFullAnalysisMock.mockResolvedValueOnce({
repoName: 'repo',
diff --git a/gitnexus/test/unit/build-web-optin.test.ts b/gitnexus/test/unit/build-web-optin.test.ts
new file mode 100644
index 000000000..68e8cd464
--- /dev/null
+++ b/gitnexus/test/unit/build-web-optin.test.ts
@@ -0,0 +1,346 @@
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import os from 'node:os';
+import path from 'node:path';
+import { load } from 'js-yaml';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { runWebBuild, shouldBuildWeb, shouldPreserveWebOutput } from '../../scripts/build-web.js';
+
+/** Default prepare/build stay CLI-only; the web UI ships only via prepack --web. */
+const REPO_ROOT = path.resolve(__dirname, '../../..');
+const PACKAGE_JSON = JSON.parse(
+ readFileSync(path.join(REPO_ROOT, 'gitnexus/package.json'), 'utf8'),
+) as { scripts?: Record };
+const tempDirs: string[] = [];
+
+interface WorkflowStep {
+ name?: string;
+ run?: unknown;
+ uses?: string;
+ with?: Record;
+ env?: Record;
+ if?: string;
+ 'working-directory'?: string;
+}
+
+interface WorkflowJob {
+ 'timeout-minutes'?: number;
+ steps?: WorkflowStep[];
+}
+
+function jobs(workflowPath: string): Record {
+ const doc = load(readFileSync(path.join(REPO_ROOT, workflowPath), 'utf8')) as {
+ jobs?: Record;
+ };
+ return doc.jobs ?? {};
+}
+
+function compositeAction(actionPath: string): {
+ inputs?: Record;
+ runs?: { steps?: WorkflowStep[] };
+} {
+ return load(readFileSync(path.join(REPO_ROOT, actionPath), 'utf8')) as {
+ inputs?: Record;
+ runs?: { steps?: WorkflowStep[] };
+ };
+}
+
+const ciJobs = jobs('.github/workflows/ci-tests.yml');
+const publishJobs = jobs('.github/workflows/publish.yml');
+const qualityJobs = jobs('.github/workflows/ci-quality.yml');
+const setupGitnexus = compositeAction('.github/actions/setup-gitnexus/action.yml');
+const setupGitnexusWeb = compositeAction('.github/actions/setup-gitnexus-web/action.yml');
+
+function stepIndex(steps: WorkflowStep[], predicate: (step: WorkflowStep) => boolean): number {
+ return steps.findIndex(predicate);
+}
+
+const installsWeb = (step: WorkflowStep) =>
+ step['working-directory'] === 'gitnexus-web' && String(step.run ?? '').includes('npm ci');
+
+function runWeb(
+ fixture: ReturnType,
+ overrides: {
+ timeoutMs?: number;
+ argv?: string[];
+ env?: NodeJS.Dict;
+ exec?: (...args: unknown[]) => unknown;
+ } = {},
+) {
+ return runWebBuild({
+ root: fixture.root,
+ dist: fixture.dist,
+ timeoutMs: 600_000,
+ argv: ['node', 'build.js'],
+ env: {},
+ exec: vi.fn(),
+ ...overrides,
+ });
+}
+
+function buildFixture({ withWeb = true, withNodeModules = true } = {}) {
+ const workspace = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-build-web-'));
+ tempDirs.push(workspace);
+
+ const root = path.join(workspace, 'gitnexus');
+ const dist = path.join(root, 'dist');
+ const webRoot = path.join(workspace, 'gitnexus-web');
+ mkdirSync(dist, { recursive: true });
+
+ if (withWeb) {
+ mkdirSync(path.join(webRoot, 'dist', 'assets'), { recursive: true });
+ writeFileSync(path.join(webRoot, 'package.json'), '{}');
+ writeFileSync(
+ path.join(webRoot, 'dist', 'index.html'),
+ '',
+ );
+ writeFileSync(path.join(webRoot, 'dist', 'assets', 'app.js'), 'export {};');
+ if (withNodeModules) mkdirSync(path.join(webRoot, 'node_modules'));
+ }
+
+ return { root, dist, webRoot, webDest: path.join(root, 'web') };
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+});
+
+describe('gitnexus build scripts', () => {
+ it('keeps the default build CLI-only', () => {
+ expect(PACKAGE_JSON.scripts?.build).toBe('node scripts/build.js');
+ expect(PACKAGE_JSON.scripts?.prepare).toBe('node scripts/build.js');
+ expect(PACKAGE_JSON.scripts?.prepare).not.toContain('--web');
+ });
+
+ it('compiles gitnexus-shared with gitnexus TypeScript, not a separate TypeScript 7 install', () => {
+ const src = readFileSync(path.join(REPO_ROOT, 'gitnexus/scripts/build.js'), 'utf8');
+ expect(src).toContain("path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js')");
+ expect(src).toContain('execFileSync(process.execPath, [tscJs]');
+ expect(src).not.toMatch(/node_modules['"]?, ['"]\.bin/);
+ expect(src).not.toMatch(/execFileSync\([^)]*tsc\.cmd/);
+ expect(src).not.toContain("typescript', 'bin', 'tsc'");
+ });
+
+ it.skipIf(!existsSync(path.join(REPO_ROOT, 'gitnexus/node_modules/typescript/lib/tsc.js')))(
+ 'can launch TypeScript via node + lib/tsc.js on this OS',
+ () => {
+ const probe = spawnSync(
+ process.execPath,
+ [path.join(REPO_ROOT, 'gitnexus/node_modules/typescript/lib/tsc.js'), '--version'],
+ { encoding: 'utf8' },
+ );
+ expect(probe.status).toBe(0);
+ expect(probe.stdout).toMatch(/Version \d+/);
+ },
+ );
+
+ it('builds the web UI from prepack, which is what ships the tarball', () => {
+ expect(PACKAGE_JSON.scripts?.prepack).toContain('scripts/build.js --web');
+ expect(PACKAGE_JSON.scripts?.prepack).toContain('scripts/assert-web-assets.mjs web');
+ expect(PACKAGE_JSON.scripts?.['build:web']).toBe('node scripts/build.js --web');
+ });
+
+ it('recognizes only explicit CLI or environment opt-ins', () => {
+ expect(shouldBuildWeb(['node', 'build.js'], {})).toBe(false);
+ expect(shouldBuildWeb(['node', 'build.js', '--web'], {})).toBe(true);
+ expect(shouldBuildWeb(['node', 'build.js'], { GITNEXUS_BUILD_WEB: '1' })).toBe(true);
+ expect(shouldBuildWeb(['node', 'build.js'], { GITNEXUS_BUILD_WEB: 'true' })).toBe(false);
+ });
+
+ it('removes stale packaged output from a default build', () => {
+ const fixture = buildFixture();
+ mkdirSync(fixture.webDest, { recursive: true });
+ writeFileSync(path.join(fixture.webDest, 'index.html'), 'stale');
+
+ const exec = vi.fn();
+ const result = runWeb(fixture, { exec });
+
+ expect(result.status).toBe('skipped');
+ expect(exec).not.toHaveBeenCalled();
+ expect(existsSync(fixture.webDest)).toBe(false);
+ });
+
+ it('preserves prepack output during npm prepare for pack and publish', () => {
+ for (const npmCommand of ['pack', 'publish']) {
+ const fixture = buildFixture();
+ mkdirSync(fixture.webDest, { recursive: true });
+ writeFileSync(path.join(fixture.webDest, 'index.html'), npmCommand);
+
+ expect(
+ shouldPreserveWebOutput({
+ npm_lifecycle_event: 'prepare',
+ npm_command: npmCommand,
+ }),
+ ).toBe(true);
+ runWeb(fixture, {
+ env: { npm_lifecycle_event: 'prepare', npm_command: npmCommand },
+ });
+
+ expect(readFileSync(path.join(fixture.webDest, 'index.html'), 'utf8')).toBe(npmCommand);
+ }
+ });
+
+ it('fails closed when an explicit web build has no web package', () => {
+ const fixture = buildFixture({ withWeb: false });
+ expect(() => runWeb(fixture, { argv: ['node', 'build.js', '--web'] })).toThrow(
+ 'web UI requested, but gitnexus-web was not found',
+ );
+ });
+
+ it('builds and copies the web UI with an untimed fallback install', () => {
+ const fixture = buildFixture({ withNodeModules: false });
+ const exec = vi.fn();
+ const result = runWeb(fixture, {
+ timeoutMs: 123_456,
+ argv: ['node', 'build.js', '--web'],
+ exec,
+ });
+
+ expect(exec).toHaveBeenNthCalledWith(1, 'npm ci', {
+ cwd: fixture.webRoot,
+ stdio: 'inherit',
+ });
+ expect(exec).toHaveBeenNthCalledWith(2, 'npm run build', {
+ cwd: fixture.webRoot,
+ stdio: 'inherit',
+ timeout: 123_456,
+ });
+ expect(result.status).toBe('built');
+ expect(readFileSync(path.join(fixture.webDest, 'index.html'), 'utf8')).toContain('app.js');
+ });
+
+ it('rejects a packaged web UI with missing referenced assets', () => {
+ const fixture = buildFixture();
+ const checker = path.join(REPO_ROOT, 'gitnexus/scripts/assert-web-assets.mjs');
+
+ expect(spawnSync(process.execPath, [checker, path.join(fixture.webRoot, 'dist')]).status).toBe(
+ 0,
+ );
+ rmSync(path.join(fixture.webRoot, 'dist', 'assets', 'app.js'));
+
+ const invalid = spawnSync(process.execPath, [checker, path.join(fixture.webRoot, 'dist')], {
+ encoding: 'utf8',
+ });
+ expect(invalid.status).toBe(1);
+ expect(invalid.stderr).toContain('references missing assets');
+
+ const missingIndex = spawnSync(
+ process.execPath,
+ [checker, path.join(fixture.webRoot, 'none')],
+ {
+ encoding: 'utf8',
+ },
+ );
+ expect(missingIndex.status).toBe(1);
+ expect(missingIndex.stderr).toContain('missing');
+ });
+});
+
+describe('workflows that need the web UI install it themselves', () => {
+ it('packaged install smoke installs gitnexus-web before npm pack', () => {
+ const steps = ciJobs['packaged-install-smoke']?.steps ?? [];
+ const webIdx = stepIndex(steps, installsWeb);
+ const packIdx = stepIndex(steps, (step) => String(step.run ?? '').includes('npm pack'));
+ expect(webIdx).toBeGreaterThanOrEqual(0);
+ expect(packIdx).toBeGreaterThan(webIdx);
+ });
+
+ it('packaged install smoke validates web assets in the installed tarball', () => {
+ const steps = ciJobs['packaged-install-smoke']?.steps ?? [];
+ const artifactCheck = steps.find((step) =>
+ String(step.run ?? '').includes('scripts/assert-web-assets.mjs'),
+ );
+ expect(artifactCheck).toBeTruthy();
+ expect(String(artifactCheck?.run)).toContain('$INSTALLED/web');
+ });
+
+ it('publish installs gitnexus-web before it packs the tarball', () => {
+ const steps = publishJobs['publish']?.steps ?? [];
+ const webIdx = stepIndex(steps, installsWeb);
+ const publishIdx = stepIndex(steps, (step) =>
+ String(step.run ?? '').includes('npm publish --dry-run'),
+ );
+ expect(webIdx).toBeGreaterThanOrEqual(0);
+ expect(publishIdx).toBeGreaterThan(webIdx);
+ });
+
+ it('node floor compat stays CLI-only — it never installs the web tree', () => {
+ const steps = ciJobs['node-floor-compat']?.steps ?? [];
+ expect(steps.length).toBeGreaterThan(0);
+ expect(steps.filter(installsWeb)).toHaveLength(0);
+ });
+
+ it('packaged install smoke skips a pre-pack CLI build and keeps a 20-minute budget', () => {
+ const job = ciJobs['packaged-install-smoke'];
+ const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus');
+ expect(job?.['timeout-minutes']).toBe(20);
+ expect(setup?.with?.['lifecycle-scripts']).toBe('false');
+ expect(setup?.with?.build).toBeUndefined();
+ });
+});
+
+describe('setup-gitnexus job budget', () => {
+ it('does not npm-ci gitnexus-shared (TypeScript 7 optional-platform install stalls CI)', () => {
+ const shared = setupGitnexus.runs?.steps?.find((step) => step.name === 'Build gitnexus-shared');
+ expect(String(shared?.run)).toBe('node ../gitnexus/node_modules/typescript/lib/tsc.js');
+ expect(String(shared?.run)).not.toContain('.bin');
+ expect(shared?.if).toContain("lifecycle-scripts == 'false'");
+ expect(
+ setupGitnexus.runs?.steps?.some(
+ (step) =>
+ step['working-directory'] === 'gitnexus-shared' &&
+ String(step.run ?? '').includes('npm ci'),
+ ),
+ ).toBe(false);
+ expect(setupGitnexus.inputs?.['lifecycle-scripts']?.default).toBe('true');
+ expect(
+ setupGitnexus.runs?.steps?.some((step) =>
+ String(step.run ?? '').includes('--ignore-scripts'),
+ ),
+ ).toBe(true);
+ });
+
+ it('setup-gitnexus-web compiles shared with the web TypeScript and skips Playwright browsers', () => {
+ const setupNode = setupGitnexusWeb.runs?.steps?.find((step) =>
+ String(step.uses ?? '').startsWith('actions/setup-node@'),
+ );
+ const shared = setupGitnexusWeb.runs?.steps?.find(
+ (step) => step.name === 'Build gitnexus-shared',
+ );
+ const webInstall = setupGitnexusWeb.runs?.steps?.find(
+ (step) => step.name === 'Install web dependencies',
+ );
+ expect(String(setupNode?.with?.['cache-dependency-path'])).toBe(
+ 'gitnexus-web/package-lock.json',
+ );
+ expect(String(shared?.run)).toBe('node ../gitnexus-web/node_modules/typescript/lib/tsc.js');
+ expect(String(shared?.run)).not.toContain('.bin');
+ expect(String(shared?.run)).not.toContain('npm ci');
+ expect(webInstall?.env?.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD).toBe('1');
+ });
+
+ it('quality typecheck skips prepare/postinstall so tsc --noEmit fits in 10 minutes', () => {
+ const job = qualityJobs.typecheck;
+ const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus');
+ expect(job?.['timeout-minutes']).toBe(10);
+ expect(setup?.with?.['lifecycle-scripts']).toBe('false');
+ });
+
+ it('quality typecheck-web can finish a cold web install instead of canceling before cache save', () => {
+ expect(qualityJobs['typecheck-web']?.['timeout-minutes']).toBe(15);
+ });
+
+ it('quality format matches lint budget and skips husky during npm ci', () => {
+ const formatCi = qualityJobs.format?.steps?.find((step) =>
+ String(step.run ?? '').includes('npm ci'),
+ );
+ const lintCi = qualityJobs.lint?.steps?.find((step) =>
+ String(step.run ?? '').includes('npm ci'),
+ );
+ expect(qualityJobs.format?.['timeout-minutes']).toBe(10);
+ expect(qualityJobs.lint?.['timeout-minutes']).toBe(10);
+ expect(String(formatCi?.run)).toContain('--ignore-scripts');
+ expect(String(lintCi?.run)).toContain('--ignore-scripts');
+ });
+});
diff --git a/gitnexus/test/unit/cli-command-banner.test.ts b/gitnexus/test/unit/cli-command-banner.test.ts
new file mode 100644
index 000000000..65bb5bc97
--- /dev/null
+++ b/gitnexus/test/unit/cli-command-banner.test.ts
@@ -0,0 +1,63 @@
+import { Command } from 'commander';
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ commandBannerTitle,
+ commandDisplayName,
+ formatCommandBanner,
+ writeCommandBanner,
+} from '../../src/cli/command-banner.js';
+
+function commandAt(path: string[]): Command {
+ let current = new Command('gitnexus');
+ for (const name of path) {
+ current = current.command(name);
+ }
+ return current;
+}
+
+describe('commandDisplayName', () => {
+ it('uses Analyzer for analyze and MCP for mcp', () => {
+ expect(commandDisplayName('analyze')).toBe('Analyzer');
+ expect(commandDisplayName('mcp')).toBe('MCP');
+ });
+
+ it('title-cases hyphenated command names', () => {
+ expect(commandDisplayName('detect-changes')).toBe('Detect Changes');
+ expect(commandDisplayName('eval-server')).toBe('Eval Server');
+ expect(commandDisplayName('list')).toBe('List');
+ });
+});
+
+describe('commandBannerTitle', () => {
+ it('joins nested group commands', () => {
+ expect(commandBannerTitle(commandAt(['group', 'list']))).toBe('Group List');
+ expect(commandBannerTitle(commandAt(['embeddings', 'install']))).toBe('Embeddings Install');
+ });
+});
+
+describe('formatCommandBanner', () => {
+ it('puts the version in the title', () => {
+ expect(formatCommandBanner('Analyzer', '1.6.10')).toBe('\n GitNexus Analyzer (1.6.10)\n');
+ expect(formatCommandBanner('Query', '1.6.10')).toBe('\n GitNexus Query (1.6.10)\n');
+ });
+
+ it('keeps the unversioned title when version is missing', () => {
+ expect(formatCommandBanner('Analyzer', '')).toBe('\n GitNexus Analyzer\n');
+ });
+});
+
+describe('writeCommandBanner', () => {
+ it('writes the title for a normal command', () => {
+ const write = vi.fn();
+ writeCommandBanner(commandAt(['status']), { write, version: '1.6.10' });
+ expect(write).toHaveBeenCalledWith('\n GitNexus Status (1.6.10)\n');
+ });
+
+ it('skips hidden refresh and help', () => {
+ const write = vi.fn();
+ writeCommandBanner(commandAt(['__update-check']), { write, version: '1.6.10' });
+ writeCommandBanner(commandAt(['help']), { write, version: '1.6.10' });
+ expect(write).not.toHaveBeenCalled();
+ });
+});
diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts
index e414c6cb6..4911af27f 100644
--- a/gitnexus/test/unit/cli-index-help.test.ts
+++ b/gitnexus/test/unit/cli-index-help.test.ts
@@ -42,6 +42,7 @@ const allHelpCommands = [
['list'],
['status'],
['doctor'],
+ ['update'],
['clean'],
['remove'],
['wiki'],
diff --git a/gitnexus/test/unit/cli-update-notice.test.ts b/gitnexus/test/unit/cli-update-notice.test.ts
new file mode 100644
index 000000000..7602bd79f
--- /dev/null
+++ b/gitnexus/test/unit/cli-update-notice.test.ts
@@ -0,0 +1,333 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ runCliUpdateNotice,
+ type CliUpdateNoticeDependencies,
+} from '../../src/cli/update-notice.js';
+import { cachedUpdateDoctorLine } from '../../src/cli/doctor.js';
+import { setCliLanguage } from '../../src/cli/i18n/index.js';
+import { readProcessStartTime } from '../../src/utils/process-identity.js';
+
+const tempHomes: string[] = [];
+
+function dependencies(
+ overrides: Partial = {},
+): CliUpdateNoticeDependencies {
+ // Isolate the refresh-lock probe from the real GITNEXUS_HOME.
+ const gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'update-notice-test-'));
+ tempHomes.push(gitnexusHome);
+ return {
+ argv: ['/usr/bin/node', '/prefix/lib/node_modules/gitnexus/dist/cli/index.js', 'status'],
+ env: { GITNEXUS_HOME: gitnexusHome },
+ installedVersion: '1.6.10',
+ isTTY: true,
+ eligible: true,
+ now: 2_000,
+ readCache: vi.fn(() => ({
+ lastCheckAt: 1_500,
+ latestVersion: '1.7.0',
+ stale: false,
+ })),
+ writeStderr: vi.fn(),
+ spawn: vi.fn(() => ({ unref: vi.fn() })),
+ ...overrides,
+ };
+}
+
+describe('CLI cached update notice', () => {
+ beforeEach(() => {
+ setCliLanguage('en');
+ });
+
+ afterEach(() => {
+ setCliLanguage(null);
+ for (const dir of tempHomes.splice(0)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('writes exactly one localized line to stderr for a TTY and keeps stdout untouched', () => {
+ const writeStderr = vi.fn();
+ const stdoutWrite = vi.spyOn(process.stdout, 'write');
+ try {
+ const deps = dependencies({ writeStderr });
+
+ runCliUpdateNotice(deps);
+
+ expect(writeStderr).toHaveBeenCalledOnce();
+ expect(writeStderr).toHaveBeenCalledWith(
+ 'GitNexus 1.7.0 is available (you are running 1.6.10).\n',
+ );
+ expect(stdoutWrite).not.toHaveBeenCalled();
+ expect(deps.spawn).not.toHaveBeenCalled();
+ } finally {
+ stdoutWrite.mockRestore();
+ }
+ });
+
+ it('displays stale valid state and starts one detached, ignored, unrefd refresh child', () => {
+ const unref = vi.fn();
+ const deps = dependencies({
+ readCache: vi.fn(() => ({
+ lastCheckAt: 0,
+ latestVersion: '1.7.0',
+ stale: true,
+ })),
+ spawn: vi.fn(() => ({ unref })),
+ });
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.writeStderr).toHaveBeenCalledOnce();
+ expect(deps.spawn).toHaveBeenCalledWith(
+ process.execPath,
+ ['/prefix/lib/node_modules/gitnexus/dist/cli/index.js', '__update-check'],
+ { detached: true, stdio: 'ignore', windowsHide: true },
+ );
+ expect(unref).toHaveBeenCalledOnce();
+ });
+
+ it('refreshes a stale unknown/current cache without printing a notice', () => {
+ for (const latestVersion of [undefined, '1.6.10', '1.5.0']) {
+ const deps = dependencies({
+ readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion, stale: true })),
+ });
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.writeStderr).not.toHaveBeenCalled();
+ expect(deps.spawn).toHaveBeenCalledOnce();
+ }
+ });
+
+ it('spawns one refresh child when the cache is missing entirely', () => {
+ const unref = vi.fn();
+ const deps = dependencies({
+ readCache: vi.fn(() => null),
+ spawn: vi.fn(() => ({ unref })),
+ });
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.writeStderr).not.toHaveBeenCalled();
+ expect(deps.spawn).toHaveBeenCalledOnce();
+ expect(deps.spawn).toHaveBeenCalledWith(
+ process.execPath,
+ ['/prefix/lib/node_modules/gitnexus/dist/cli/index.js', '__update-check'],
+ { detached: true, stdio: 'ignore', windowsHide: true },
+ );
+ expect(unref).toHaveBeenCalledOnce();
+ });
+
+ it('skips the refresh spawn when a live process holds the refresh lock', () => {
+ const deps = dependencies({
+ readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion: '1.7.0', stale: true })),
+ });
+ const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
+ fs.writeFileSync(
+ lockPath,
+ `${JSON.stringify({ pid: process.pid, ownerId: 'test', processStartTime: readProcessStartTime(process.pid), hostname: os.hostname() })}\n`,
+ );
+
+ runCliUpdateNotice(deps);
+
+ // The live holder's refresh covers this invocation.
+ expect(deps.spawn).not.toHaveBeenCalled();
+ // Display from the stale-but-valid cache is unaffected.
+ expect(deps.writeStderr).toHaveBeenCalledOnce();
+ });
+
+ it('spawns when a live PID is reuse with a different process start time', () => {
+ const deps = dependencies({
+ readCache: vi.fn(() => ({ lastCheckAt: 0, latestVersion: '1.7.0', stale: true })),
+ });
+ const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
+ fs.writeFileSync(
+ lockPath,
+ `${JSON.stringify({ pid: process.pid, ownerId: 'reused', processStartTime: 'not-this-process', hostname: os.hostname() })}\n`,
+ );
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.spawn).toHaveBeenCalledOnce();
+ expect(deps.writeStderr).toHaveBeenCalledOnce();
+ });
+
+ it('spawns when the lock owner is dead so the child can reclaim it', () => {
+ const deps = dependencies({
+ readCache: vi.fn(() => null),
+ });
+ const lockPath = path.join(deps.env.GITNEXUS_HOME as string, 'update-check.lock');
+ fs.writeFileSync(
+ lockPath,
+ `${JSON.stringify({ pid: 99999999, ownerId: 'stale', processStartTime: 'x', hostname: os.hostname() })}\n`,
+ );
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.spawn).toHaveBeenCalledOnce();
+ });
+
+ it('does nothing for non-TTY stderr, including no cache read or child spawn', () => {
+ const deps = dependencies({ isTTY: false });
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.readCache).not.toHaveBeenCalled();
+ expect(deps.writeStderr).not.toHaveBeenCalled();
+ expect(deps.spawn).not.toHaveBeenCalled();
+ });
+
+ it.each(['CI', 'GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER'])(
+ 'does nothing when %s is truthy',
+ (name) => {
+ const deps = dependencies({ env: { [name]: '1' } });
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.readCache).not.toHaveBeenCalled();
+ expect(deps.writeStderr).not.toHaveBeenCalled();
+ expect(deps.spawn).not.toHaveBeenCalled();
+ },
+ );
+
+ it('uses truthy-env semantics rather than treating "0" as opted out', () => {
+ const deps = dependencies({
+ env: {
+ CI: '0',
+ GITNEXUS_NO_UPDATE_NOTIFIER: 'false',
+ NO_UPDATE_NOTIFIER: 'off',
+ },
+ });
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.writeStderr).toHaveBeenCalledOnce();
+ });
+
+ it('does nothing for ineligible dev and Docker contexts', () => {
+ for (const env of [{}, { GITNEXUS_NO_UPDATE_NOTIFIER: '1' }]) {
+ const deps = dependencies({ eligible: false, env });
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.readCache).not.toHaveBeenCalled();
+ expect(deps.spawn).not.toHaveBeenCalled();
+ }
+ });
+
+ it.each([
+ ['augment'],
+ ['--help'],
+ ['status', '--help'],
+ ['--version'],
+ ['mcp'],
+ ['serve'],
+ ['eval-server'],
+ ['update'],
+ ['__update-check'],
+ ])('excludes command identity %j from display and refresh', (...args) => {
+ const deps = dependencies({ argv: ['/usr/bin/node', '/entry.js', ...args] });
+
+ runCliUpdateNotice(deps);
+
+ expect(deps.readCache).not.toHaveBeenCalled();
+ expect(deps.writeStderr).not.toHaveBeenCalled();
+ expect(deps.spawn).not.toHaveBeenCalled();
+ });
+
+ it('swallows cache and spawn failures before Commander parsing', () => {
+ expect(() =>
+ runCliUpdateNotice(
+ dependencies({
+ readCache: () => {
+ throw new Error('cache unavailable');
+ },
+ }),
+ ),
+ ).not.toThrow();
+
+ expect(() =>
+ runCliUpdateNotice(
+ dependencies({
+ readCache: () => ({ lastCheckAt: 0, stale: true }),
+ spawn: () => {
+ throw new Error('spawn unavailable');
+ },
+ }),
+ ),
+ ).not.toThrow();
+ });
+});
+
+describe('doctor cached update line', () => {
+ beforeEach(() => {
+ setCliLanguage('en');
+ });
+
+ afterEach(() => {
+ setCliLanguage(null);
+ });
+
+ it('shows installed and latest versions from cache without triggering refresh', () => {
+ const readCache = vi.fn(() => ({
+ lastCheckAt: 0,
+ latestVersion: '1.7.0',
+ stale: true,
+ }));
+
+ expect(
+ cachedUpdateDoctorLine({
+ installedVersion: '1.6.10',
+ eligible: true,
+ env: {},
+ readCache,
+ }),
+ ).toBe('GitNexus 1.7.0 is available (you are running 1.6.10).');
+ expect(readCache).toHaveBeenCalledOnce();
+ });
+
+ it('is silent for current, invalid, opted-out, and ineligible states', () => {
+ expect(
+ cachedUpdateDoctorLine({
+ installedVersion: '1.6.10',
+ eligible: true,
+ env: {},
+ readCache: () => ({ lastCheckAt: 0, latestVersion: '1.6.10', stale: false }),
+ }),
+ ).toBeNull();
+ expect(
+ cachedUpdateDoctorLine({
+ installedVersion: '1.6.10',
+ eligible: false,
+ env: {},
+ readCache: vi.fn(),
+ }),
+ ).toBeNull();
+ expect(
+ cachedUpdateDoctorLine({
+ installedVersion: '1.6.10',
+ eligible: true,
+ env: { CI: '1' },
+ readCache: vi.fn(),
+ }),
+ ).toBeNull();
+ });
+
+ it.each(['v1.7.0', '1.7.0-rc.1'])(
+ 'is silent for non-strict latestVersion %s',
+ (latestVersion) => {
+ expect(
+ cachedUpdateDoctorLine({
+ installedVersion: '1.6.10',
+ eligible: true,
+ env: {},
+ readCache: () => ({ lastCheckAt: 0, latestVersion, stale: false }),
+ }),
+ ).toBeNull();
+ },
+ );
+});
diff --git a/gitnexus/test/unit/cli-update.test.ts b/gitnexus/test/unit/cli-update.test.ts
new file mode 100644
index 000000000..3b3b5e3c1
--- /dev/null
+++ b/gitnexus/test/unit/cli-update.test.ts
@@ -0,0 +1,174 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { updateCommand, updateInstallArgs, updateInstallCommand } from '../../src/cli/update.js';
+import { setCliLanguage } from '../../src/cli/i18n/index.js';
+
+describe('gitnexus update', () => {
+ afterEach(() => {
+ setCliLanguage(null);
+ });
+
+ it('pins npm i -g to a stable x.y.z spec', () => {
+ expect(updateInstallArgs('1.7.0')).toEqual(['i', '-g', 'gitnexus@1.7.0']);
+ expect(updateInstallCommand('1.7.0')).toBe('npm i -g gitnexus@1.7.0');
+ });
+
+ it('installs the discovered version when a newer release exists', async () => {
+ setCliLanguage('en');
+ const refresh = vi.fn().mockResolvedValue({
+ updateAvailable: true,
+ latestVersion: '1.7.0',
+ });
+ const runInstall = vi.fn().mockResolvedValue(0);
+ const writeStdout = vi.fn();
+ const setExitCode = vi.fn();
+
+ await updateCommand({
+ installedVersion: '1.6.10',
+ refresh,
+ runInstall,
+ writeStdout,
+ setExitCode,
+ });
+
+ expect(refresh).toHaveBeenCalledWith({
+ eligible: true,
+ ignoreOptOut: true,
+ installedVersion: '1.6.10',
+ });
+ expect(runInstall).toHaveBeenCalledWith('1.7.0');
+ expect(writeStdout).toHaveBeenCalledWith(
+ 'GitNexus 1.7.0 is available (you are running 1.6.10).',
+ );
+ expect(writeStdout).toHaveBeenCalledWith('Installing with npm i -g gitnexus@1.7.0…');
+ expect(writeStdout).toHaveBeenCalledWith(
+ 'Installed gitnexus@1.7.0. Restart long-running mcp/serve processes.',
+ );
+ expect(setExitCode).not.toHaveBeenCalled();
+ });
+
+ it('does not install when already current', async () => {
+ setCliLanguage('en');
+ const runInstall = vi.fn();
+ const writeStdout = vi.fn();
+
+ await updateCommand({
+ installedVersion: '1.7.0',
+ refresh: vi.fn().mockResolvedValue({
+ updateAvailable: false,
+ latestVersion: '1.7.0',
+ }),
+ runInstall,
+ writeStdout,
+ });
+
+ expect(runInstall).not.toHaveBeenCalled();
+ expect(writeStdout).toHaveBeenCalledWith(
+ 'GitNexus 1.7.0 is current or newer than the latest stable version.',
+ );
+ expect(writeStdout).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not install when newer than the registry latest', async () => {
+ setCliLanguage('en');
+ const runInstall = vi.fn();
+ const writeStdout = vi.fn();
+
+ await updateCommand({
+ installedVersion: '1.7.0',
+ refresh: vi.fn().mockResolvedValue({
+ updateAvailable: false,
+ latestVersion: '1.6.10',
+ }),
+ runInstall,
+ writeStdout,
+ });
+
+ expect(runInstall).not.toHaveBeenCalled();
+ expect(writeStdout).toHaveBeenCalledWith(
+ 'GitNexus 1.7.0 is current or newer than the latest stable version.',
+ );
+ expect(writeStdout).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not install a non x.y.z spec', async () => {
+ setCliLanguage('en');
+ const runInstall = vi.fn();
+ const writeStdout = vi.fn();
+
+ await updateCommand({
+ installedVersion: '1.6.10',
+ refresh: vi.fn().mockResolvedValue({
+ updateAvailable: true,
+ latestVersion: '1.7.0-rc.1',
+ }),
+ runInstall,
+ writeStdout,
+ });
+
+ expect(runInstall).not.toHaveBeenCalled();
+ expect(writeStdout).toHaveBeenCalledWith(
+ 'Could not check for updates (offline, private registry, or the check failed open).',
+ );
+ });
+
+ it('does not install when the check fails open', async () => {
+ setCliLanguage('en');
+ const runInstall = vi.fn();
+ const writeStdout = vi.fn();
+
+ await updateCommand({
+ installedVersion: '1.6.10',
+ refresh: vi.fn().mockResolvedValue(null),
+ runInstall,
+ writeStdout,
+ });
+
+ expect(runInstall).not.toHaveBeenCalled();
+ expect(writeStdout).toHaveBeenCalledWith(
+ 'Could not check for updates (offline, private registry, or the check failed open).',
+ );
+ });
+
+ it('forwards a non-zero npm exit code', async () => {
+ setCliLanguage('en');
+ const writeStdout = vi.fn();
+ const setExitCode = vi.fn();
+
+ await updateCommand({
+ installedVersion: '1.6.10',
+ refresh: vi.fn().mockResolvedValue({
+ updateAvailable: true,
+ latestVersion: '1.7.0',
+ }),
+ runInstall: vi.fn().mockResolvedValue(7),
+ writeStdout,
+ setExitCode,
+ });
+
+ expect(writeStdout).toHaveBeenCalledWith(
+ 'npm install failed. You can retry: npm i -g gitnexus@1.7.0',
+ );
+ expect(setExitCode).toHaveBeenCalledWith(7);
+ });
+
+ it('fails open when npm cannot be spawned', async () => {
+ setCliLanguage('en');
+ const writeStdout = vi.fn();
+ const setExitCode = vi.fn();
+
+ await updateCommand({
+ installedVersion: '1.6.10',
+ refresh: vi.fn().mockResolvedValue({
+ updateAvailable: true,
+ latestVersion: '1.7.0',
+ }),
+ runInstall: vi.fn().mockRejectedValue(new Error('spawn npm ENOENT')),
+ writeStdout,
+ setExitCode,
+ });
+
+ expect(writeStdout).toHaveBeenCalledWith('Could not run npm: spawn npm ENOENT');
+ expect(setExitCode).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts
index 0c365e359..715955d74 100644
--- a/gitnexus/test/unit/git-clone.test.ts
+++ b/gitnexus/test/unit/git-clone.test.ts
@@ -326,6 +326,8 @@ describe('git-clone', () => {
// chosen because their prefixes don't collide with any block above.
expect(() => validateGitUrl('https://[2606:4700:4700::1111]/repo.git')).not.toThrow();
expect(() => validateGitUrl('https://[2001:4860:4860::8888]/repo.git')).not.toThrow();
+ // A public address that merely contains a `ffff` hextet is not IPv4-mapped.
+ expect(() => validateGitUrl('https://[2001:4860:ffff::1]/repo.git')).not.toThrow();
});
it('blocks CGN range (100.64.0.0/10)', () => {
diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts
index 547f6417d..df24e963d 100644
--- a/gitnexus/test/unit/incremental-orchestration.test.ts
+++ b/gitnexus/test/unit/incremental-orchestration.test.ts
@@ -579,6 +579,28 @@ describe('runFullAnalysis — incremental orchestration', () => {
}
}, 300_000);
+ it('useParseCache:false bypasses the alreadyUpToDate fast path without --force', async () => {
+ const repo = await setupMiniRepo();
+ try {
+ const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
+ await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
+
+ const logs: string[] = [];
+ const cold = await runFullAnalysis(
+ repo.dbPath,
+ { skipAgentsMd: true, useParseCache: false },
+ { onProgress: () => {}, onLog: (message) => logs.push(message) },
+ );
+
+ expect(cold.alreadyUpToDate).toBeUndefined();
+ expect(cold.pipelineResult?.parseCacheHitFileCount ?? 0).toBe(0);
+ expect(cold.pipelineResult?.reparsedFileCount).toBe(7);
+ expect(logs.join('\n')).toContain('Parser cache bypass requested');
+ } finally {
+ await repo.cleanup();
+ }
+ }, 300_000);
+
it('rebuilds for Actuator snapshots and once more when runtime enrichment is disabled', async () => {
const repo = await setupMiniRepo();
const runtimeInput = 'runtime-actuator';
@@ -663,12 +685,17 @@ describe('runFullAnalysis — incremental orchestration', () => {
);
expect(steady.alreadyUpToDate).toBe(true);
+ const forceLogs: string[] = [];
const forcedSteady = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, force: true },
- { onProgress: () => {} },
+ { onProgress: () => {}, onLog: (message) => forceLogs.push(message) },
);
expect(forcedSteady.alreadyUpToDate).toBeUndefined();
+ expect(forceLogs.join('\n')).toContain(
+ 'Rebuilt the graph and FTS while reusing cached parser output',
+ );
+ expect(forceLogs.join('\n')).toContain('increment SCHEMA_BUMP');
expect(
await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue),
).toEqual([]);
diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts
index 948dc27e0..f6627e94a 100644
--- a/gitnexus/test/unit/incremental-parse-cache.test.ts
+++ b/gitnexus/test/unit/incremental-parse-cache.test.ts
@@ -15,6 +15,8 @@ import {
saveParseCache,
pruneCache,
slimParseWorkerResultsForCache,
+ getColdParseRebuildDir,
+ createColdParseRebuildDir,
type ParseCache,
} from '../../src/storage/parse-cache.js';
import { writeV8CacheFile } from '../../src/storage/v8-sidecar.js';
@@ -947,4 +949,78 @@ describe('loadParseCache / saveParseCache (round-trip)', () => {
await rm(dir, { recursive: true, force: true });
}
});
+
+ it('persists cold-rebuild shards under staging without touching the live parse-cache dir', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-stage-'));
+ try {
+ const liveKey = 'a'.repeat(64);
+ const stagedKey = 'b'.repeat(64);
+ await saveParseCache(dir, {
+ version: PARSE_CACHE_VERSION,
+ entries: new Map([[liveKey, [minimalResult({ fileCount: 1 })]]]),
+ usedKeys: new Set([liveKey]),
+ });
+ const staging = getColdParseRebuildDir(dir);
+ const cache: ParseCache = {
+ version: PARSE_CACHE_VERSION,
+ entries: new Map(),
+ usedKeys: new Set([liveKey, stagedKey]),
+ storagePath: staging,
+ onDiskKeys: new Set(),
+ };
+ await persistParseCacheChunk(cache, stagedKey, [minimalResult({ fileCount: 99 })]);
+ const liveNames = await readdir(path.join(dir, 'parse-cache'));
+ expect(liveNames).toContain(`${liveKey}.v8`);
+ expect(liveNames).not.toContain(`${stagedKey}.v8`);
+ const stagedNames = await readdir(path.join(staging, 'parse-cache'));
+ expect(stagedNames).toContain(`${stagedKey}.v8`);
+
+ const saved = await saveParseCache(dir, cache);
+ expect(saved.sort()).toEqual([liveKey, stagedKey].sort());
+ const loaded = await loadParseCache(dir);
+ expect((await loadParseCacheChunk(loaded, liveKey))?.[0]?.fileCount).toBe(1);
+ expect((await loadParseCacheChunk(loaded, stagedKey))?.[0]?.fileCount).toBe(99);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('prefers a staged shard over a same-hash live shard when publishing', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-pref-'));
+ try {
+ const key = 'c'.repeat(64);
+ await saveParseCache(dir, {
+ version: PARSE_CACHE_VERSION,
+ entries: new Map([[key, [minimalResult({ fileCount: 1 })]]]),
+ usedKeys: new Set([key]),
+ });
+ const staging = getColdParseRebuildDir(dir);
+ const cache: ParseCache = {
+ version: PARSE_CACHE_VERSION,
+ entries: new Map(),
+ usedKeys: new Set([key]),
+ storagePath: staging,
+ onDiskKeys: new Set(),
+ };
+ await persistParseCacheChunk(cache, key, [minimalResult({ fileCount: 7 })]);
+ await saveParseCache(dir, cache);
+ const loaded = await loadParseCache(dir);
+ expect((await loadParseCacheChunk(loaded, key))?.[0]?.fileCount).toBe(7);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('createColdParseRebuildDir returns distinct directories under the same storage root', async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-uniq-'));
+ try {
+ const a = await createColdParseRebuildDir(dir);
+ const b = await createColdParseRebuildDir(dir);
+ expect(a).not.toBe(b);
+ expect(a.startsWith(path.join(dir, 'parse-rebuild.'))).toBe(true);
+ expect(b.startsWith(path.join(dir, 'parse-rebuild.'))).toBe(true);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ });
});
diff --git a/gitnexus/test/unit/package-version.test.ts b/gitnexus/test/unit/package-version.test.ts
new file mode 100644
index 000000000..a38e5cd32
--- /dev/null
+++ b/gitnexus/test/unit/package-version.test.ts
@@ -0,0 +1,13 @@
+import { createRequire } from 'node:module';
+import { describe, expect, it } from 'vitest';
+import { packageVersion } from '../../src/core/package-version.js';
+
+const fromManifest = (createRequire(import.meta.url)('../../package.json') as { version: string })
+ .version;
+
+describe('packageVersion', () => {
+ it('returns the same string as gitnexus/package.json#version', () => {
+ expect(packageVersion()).toBe(fromManifest);
+ expect(packageVersion()).toMatch(/^\d+\.\d+\.\d+/);
+ });
+});
diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts
index 39fd4c9fe..85a96e3da 100644
--- a/gitnexus/test/unit/parsedfile-store.test.ts
+++ b/gitnexus/test/unit/parsedfile-store.test.ts
@@ -15,6 +15,9 @@ import {
getParsedFileStoreDir,
getDurableParsedFileDir,
parsedFileLoadGc,
+ prepareDurableParsedFileChunk,
+ pruneAndSaveDurableParsedFileStore,
+ mergeStagedDurableParsedFileStore,
} from '../../src/storage/parsedfile-store.js';
/**
@@ -846,4 +849,45 @@ describe('parsedfile-store receiverChain sanitation', () => {
await rm(dir, { recursive: true, force: true });
}
});
+
+ it('overlays staged durable chunks onto the live store without dropping live-only keys', async () => {
+ const live = await mkdtemp(path.join(tmpdir(), 'pf-live-'));
+ const staged = await mkdtemp(path.join(tmpdir(), 'pf-stg-'));
+ try {
+ const liveOnly = '1'.repeat(64);
+ const rewritten = '2'.repeat(64);
+ await prepareDurableParsedFileChunk(getDurableParsedFileDir(live), liveOnly);
+ persistDurableParsedFileShardSync(getDurableParsedFileDir(live), liveOnly, 1, 0, [
+ makeParsedFile('keep.c'),
+ ]);
+ await prepareDurableParsedFileChunk(getDurableParsedFileDir(live), rewritten);
+ persistDurableParsedFileShardSync(getDurableParsedFileDir(live), rewritten, 1, 0, [
+ makeParsedFile('old.c'),
+ ]);
+ await pruneAndSaveDurableParsedFileStore(
+ getDurableParsedFileDir(live),
+ 'v-test',
+ new Set([liveOnly, rewritten]),
+ );
+
+ await prepareDurableParsedFileChunk(getDurableParsedFileDir(staged), rewritten);
+ persistDurableParsedFileShardSync(getDurableParsedFileDir(staged), rewritten, 1, 0, [
+ makeParsedFile('new.c'),
+ ]);
+
+ await mergeStagedDurableParsedFileStore(
+ live,
+ staged,
+ 'v-test',
+ new Set([liveOnly, rewritten]),
+ );
+
+ expect(await durableChunkHasShards(live, liveOnly, new Set(['keep.c']))).toBe(true);
+ expect(await durableChunkHasShards(live, rewritten, new Set(['new.c']))).toBe(true);
+ expect(await durableChunkHasShards(live, rewritten, new Set(['old.c']))).toBe(false);
+ } finally {
+ await rm(live, { recursive: true, force: true });
+ await rm(staged, { recursive: true, force: true });
+ }
+ });
});
diff --git a/gitnexus/test/unit/server-info.test.ts b/gitnexus/test/unit/server-info.test.ts
new file mode 100644
index 000000000..ea395c089
--- /dev/null
+++ b/gitnexus/test/unit/server-info.test.ts
@@ -0,0 +1,212 @@
+import { EventEmitter } from 'node:events';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ bindServeUpdateControllerLifecycle,
+ buildServerInfo,
+ createServeUpdateController,
+} from '../../src/server/update-controller.js';
+import { packageVersion } from '../../src/core/package-version.js';
+import { evaluate } from '../../src/core/update-check.js';
+import type { UpdateState } from '../../src/core/update-check.js';
+
+const PKG_VERSION = packageVersion();
+const baseKeys = ['version', 'launchContext', 'nodeVersion'];
+const tempDirs: string[] = [];
+
+afterEach(async () => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
+});
+
+describe('GET /api/info update state', () => {
+ it('keeps the existing three fields unchanged when no update is available', () => {
+ const response = buildServerInfo(null);
+
+ expect(Object.keys(response)).toEqual(baseKeys);
+ expect(response).toEqual({
+ version: PKG_VERSION,
+ launchContext: expect.stringMatching(/^(npx|global|local)$/),
+ nodeVersion: process.version,
+ });
+ });
+
+ it('adds optional update fields only for an available version', () => {
+ expect(buildServerInfo({ updateAvailable: true, latestVersion: '9.8.7' })).toEqual({
+ version: PKG_VERSION,
+ launchContext: expect.stringMatching(/^(npx|global|local)$/),
+ nodeVersion: process.version,
+ latestVersion: '9.8.7',
+ updateAvailable: true,
+ });
+
+ expect(
+ Object.keys(buildServerInfo({ updateAvailable: false, latestVersion: PKG_VERSION })),
+ ).toEqual(baseKeys);
+ });
+
+ it.each(['GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER', 'CI'])(
+ 'omits update fields after start when %s is set',
+ async (name) => {
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-serve-update-'));
+ tempDirs.push(home);
+ vi.stubEnv('GITNEXUS_HOME', home);
+ vi.stubEnv('npm_config_registry', 'https://registry.npmjs.org');
+ vi.stubEnv(name, '1');
+ await fs.writeFile(
+ path.join(home, 'update-check.json'),
+ JSON.stringify({
+ lastCheckAt: new Date().toISOString(),
+ registry: 'https://registry.npmjs.org',
+ latestVersion: '9.9.9',
+ }),
+ );
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ const evaluateSpy = vi.fn((options?: { refreshIfStale?: boolean }) =>
+ evaluate({ ...options, eligible: true }),
+ );
+ const controller = createServeUpdateController({
+ evaluate: evaluateSpy,
+ armScheduler: vi.fn(() => vi.fn()),
+ });
+
+ await controller.start();
+
+ expect(evaluateSpy).toHaveBeenCalledWith({ refreshIfStale: false });
+ expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
+ expect(fetchMock).not.toHaveBeenCalled();
+ },
+ );
+
+ it('omits update fields after start for an ineligible install', async () => {
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-serve-update-'));
+ tempDirs.push(home);
+ vi.stubEnv('GITNEXUS_HOME', home);
+ vi.stubEnv('npm_config_registry', 'https://registry.npmjs.org');
+ vi.stubEnv('CI', '');
+ await fs.writeFile(
+ path.join(home, 'update-check.json'),
+ JSON.stringify({
+ lastCheckAt: new Date().toISOString(),
+ registry: 'https://registry.npmjs.org',
+ latestVersion: '9.9.9',
+ }),
+ );
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ const evaluateSpy = vi.fn((options?: { refreshIfStale?: boolean }) =>
+ evaluate({ ...options, eligible: false }),
+ );
+ const controller = createServeUpdateController({
+ evaluate: evaluateSpy,
+ armScheduler: vi.fn(() => vi.fn()),
+ });
+
+ await controller.start();
+
+ expect(evaluateSpy).toHaveBeenCalledWith({ refreshIfStale: false });
+ expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('stays assignable to the web client ServerInfo contract', () => {
+ // Mirrors gitnexus-web/src/services/backend-client.ts without creating a
+ // cross-package import that would couple either package's build graph.
+ interface WebClientServerInfo {
+ version: string;
+ launchContext: 'npx' | 'global' | 'local';
+ nodeVersion: string;
+ latestVersion?: string;
+ updateAvailable?: boolean;
+ }
+
+ const response: WebClientServerInfo = buildServerInfo({
+ updateAvailable: true,
+ latestVersion: '9.8.7',
+ });
+ expect(response.updateAvailable).toBe(true);
+ });
+});
+
+describe('serve update controller lifecycle', () => {
+ it('starts only after successful listen and stops whenever the server closes', async () => {
+ const server = new EventEmitter();
+ const controller = {
+ start: vi.fn().mockResolvedValue(undefined),
+ stop: vi.fn(),
+ snapshot: vi.fn().mockReturnValue(null),
+ };
+ bindServeUpdateControllerLifecycle(server, controller);
+
+ expect(controller.start).not.toHaveBeenCalled();
+
+ server.emit('listening');
+ expect(controller.start).toHaveBeenCalledOnce();
+
+ server.emit('close');
+ expect(controller.stop).toHaveBeenCalledOnce();
+ });
+
+ it('evaluates once before arming the scheduler and serves memory-only snapshots', async () => {
+ const calls: string[] = [];
+ let publish: ((state: UpdateState | null) => void) | undefined;
+ const evaluate = vi.fn(async () => {
+ calls.push('evaluate');
+ return { updateAvailable: true, latestVersion: '2.0.0' };
+ });
+ const armScheduler = vi.fn((onState) => {
+ calls.push('arm');
+ publish = onState;
+ return vi.fn();
+ });
+ const controller = createServeUpdateController({ evaluate, armScheduler });
+
+ expect(controller.snapshot()).toBeNull();
+ await controller.start();
+ expect(calls).toEqual(['evaluate', 'arm']);
+ expect(controller.snapshot()).toEqual({
+ updateAvailable: true,
+ latestVersion: '2.0.0',
+ });
+
+ publish?.({ updateAvailable: true, latestVersion: '2.1.0' });
+ expect(controller.snapshot()).toEqual({
+ updateAvailable: true,
+ latestVersion: '2.1.0',
+ });
+ expect(evaluate).toHaveBeenCalledTimes(1);
+ expect(evaluate).toHaveBeenCalledWith({ refreshIfStale: false });
+ });
+
+ it('fails open and still arms the long-lived refresh scheduler', async () => {
+ const armScheduler = vi.fn(() => vi.fn());
+ const controller = createServeUpdateController({
+ evaluate: vi.fn().mockRejectedValue(new Error('checker failed')),
+ armScheduler,
+ });
+
+ await expect(controller.start()).resolves.toBeUndefined();
+ expect(controller.snapshot()).toBeNull();
+ expect(armScheduler).toHaveBeenCalledOnce();
+ expect(Object.keys(buildServerInfo(controller.snapshot()))).toEqual(baseKeys);
+ });
+
+ it('stops the scheduler on shutdown and is idempotent', async () => {
+ const stop = vi.fn();
+ const controller = createServeUpdateController({
+ evaluate: vi.fn().mockResolvedValue(null),
+ armScheduler: vi.fn(() => stop),
+ });
+
+ await controller.start();
+ controller.stop();
+ controller.stop();
+
+ expect(stop).toHaveBeenCalledOnce();
+ });
+});
diff --git a/gitnexus/test/unit/setup-antigravity.test.ts b/gitnexus/test/unit/setup-antigravity.test.ts
index 338b75806..4f817c89e 100644
--- a/gitnexus/test/unit/setup-antigravity.test.ts
+++ b/gitnexus/test/unit/setup-antigravity.test.ts
@@ -20,11 +20,10 @@ import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
-import { createRequire } from 'module';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
+import { packageVersion } from '../../src/core/package-version.js';
-const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
- .version;
+const PKG_VERSION = packageVersion();
const NPX_REF = `gitnexus@${PKG_VERSION}`;
// vi.hoisted lets the mock factory below (which is hoisted by Vitest) see
diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts
index 9ede4a67b..10a19e38c 100644
--- a/gitnexus/test/unit/setup-codex.test.ts
+++ b/gitnexus/test/unit/setup-codex.test.ts
@@ -2,10 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
-import { createRequire } from 'module';
+import { packageVersion } from '../../src/core/package-version.js';
-const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
- .version;
+const PKG_VERSION = packageVersion();
const NPX_REF = `gitnexus@${PKG_VERSION}`;
const execFileMock = vi.fn((...args: any[]) => {
diff --git a/gitnexus/test/unit/setup-jsonc.test.ts b/gitnexus/test/unit/setup-jsonc.test.ts
index 1bd8dd267..d84b9a41b 100644
--- a/gitnexus/test/unit/setup-jsonc.test.ts
+++ b/gitnexus/test/unit/setup-jsonc.test.ts
@@ -3,10 +3,9 @@ import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { parse as parseJsonc } from 'jsonc-parser';
-import { createRequire } from 'module';
+import { packageVersion } from '../../src/core/package-version.js';
-const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
- .version;
+const PKG_VERSION = packageVersion();
const NPX_REF = `gitnexus@${PKG_VERSION}`;
const execFileMock = vi.fn((...args: any[]) => {
diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts
index 448074669..97744511d 100644
--- a/gitnexus/test/unit/setup.test.ts
+++ b/gitnexus/test/unit/setup.test.ts
@@ -2,12 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
-import { createRequire } from 'module';
+import { packageVersion } from '../../src/core/package-version.js';
-// Match what setup.ts emits — read the version from the same package.json
-// so the test never goes stale on a release bump.
-const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
- .version;
+const PKG_VERSION = packageVersion();
const MCP_PINNED_REF = `gitnexus@${PKG_VERSION}`;
/** Flatten the spied console.log calls into one searchable string. */
diff --git a/gitnexus/test/unit/update-check.test.ts b/gitnexus/test/unit/update-check.test.ts
new file mode 100644
index 000000000..673653e21
--- /dev/null
+++ b/gitnexus/test/unit/update-check.test.ts
@@ -0,0 +1,539 @@
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { updateEligibleInstall } from '../../src/core/install-context.js';
+import { isNewerVersion } from '../../src/core/update-cache.js';
+import { armUpdateRefreshScheduler, evaluate, refresh } from '../../src/core/update-check.js';
+import { acquireFileLock } from '../../src/storage/file-lock.js';
+
+const DAY_MS = 24 * 60 * 60 * 1_000;
+const NOW = Date.parse('2026-09-04T05:00:00.000Z');
+const REGISTRY = 'https://registry.npmjs.org';
+const tempDirs: string[] = [];
+
+async function tempHome(): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-check-'));
+ tempDirs.push(dir);
+ vi.stubEnv('GITNEXUS_HOME', dir);
+ return dir;
+}
+
+function cachePath(home: string): string {
+ return path.join(home, 'update-check.json');
+}
+
+async function writeCache(
+ home: string,
+ body: { lastCheckAt: string; registry: string; latestVersion?: string },
+): Promise {
+ await fs.mkdir(home, { recursive: true });
+ await fs.writeFile(cachePath(home), JSON.stringify(body));
+}
+
+async function readCache(home: string): Promise> {
+ return JSON.parse(await fs.readFile(cachePath(home), 'utf8')) as Record;
+}
+
+function registryResponse(version = '1.7.0'): Response {
+ return new Response(JSON.stringify({ version }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+beforeEach(() => {
+ vi.stubEnv('npm_config_registry', REGISTRY);
+ // GitHub Actions sets CI=true; the checker treats that as a hard opt-out.
+ vi.stubEnv('CI', '');
+ vi.stubEnv('GITNEXUS_NO_UPDATE_NOTIFIER', '');
+ vi.stubEnv('NO_UPDATE_NOTIFIER', '');
+});
+
+afterEach(async () => {
+ vi.useRealTimers();
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
+});
+
+describe('update check cache and versions', () => {
+ it('returns a newer version from a fresh cache', async () => {
+ const home = await tempHome();
+ await writeCache(home, {
+ lastCheckAt: new Date(NOW).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.7.0',
+ });
+
+ await expect(
+ evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
+ ).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
+ });
+
+ it('returns stale valid state and starts one stale-while-revalidate refresh', async () => {
+ const home = await tempHome();
+ await writeCache(home, {
+ lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.7.0',
+ });
+ const fetchMock = vi.fn().mockResolvedValue(registryResponse('1.8.0'));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(
+ evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
+ ).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.8.0'));
+ });
+
+ it.each(['1.6.10', '1.6.9'])('is silent for fresh equal/lower latest %s', async (latest) => {
+ const home = await tempHome();
+ await writeCache(home, {
+ lastCheckAt: new Date(NOW).toISOString(),
+ registry: REGISTRY,
+ latestVersion: latest,
+ });
+
+ await expect(
+ evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
+ ).resolves.toEqual({ updateAvailable: false, latestVersion: latest });
+ });
+
+ it('treats corrupt JSON as a miss and refresh overwrites it', async () => {
+ const home = await tempHome();
+ await fs.writeFile(cachePath(home), '{broken');
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse()));
+
+ await expect(
+ evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
+ ).resolves.toBeNull();
+ await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBe('1.7.0'));
+ });
+
+ it('never propagates a latestVersion with a non-strict format', async () => {
+ const home = await tempHome();
+ await writeCache(home, {
+ lastCheckAt: new Date(NOW).toISOString(),
+ registry: REGISTRY,
+ latestVersion: 'v1.7.0',
+ });
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
+
+ await expect(
+ evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW }),
+ ).resolves.toBeNull();
+ await vi.waitFor(async () => expect((await readCache(home)).latestVersion).toBeUndefined());
+ });
+
+ it('treats a future timestamp as stale', async () => {
+ const home = await tempHome();
+ // Wall-clock-future lastCheckAt is monotonic poison; NOW+1ms is still in
+ // the past on a later wall clock and would not be overwritten.
+ await writeCache(home, {
+ lastCheckAt: new Date(Date.now() + 60_000).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.6.0',
+ });
+ const fetchMock = vi.fn().mockResolvedValue(registryResponse());
+ vi.stubGlobal('fetch', fetchMock);
+
+ await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW });
+ await vi.waitFor(async () =>
+ expect(await readCache(home)).toEqual({
+ lastCheckAt: new Date(NOW).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.7.0',
+ }),
+ );
+ });
+
+ it('writes a negative entry after failure and suppresses retries inside the TTL', async () => {
+ const home = await tempHome();
+ const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
+ expect(await readCache(home)).toEqual({
+ lastCheckAt: new Date(NOW).toISOString(),
+ registry: REGISTRY,
+ });
+ await evaluate({ eligible: true, installedVersion: '1.6.10', now: NOW + 1 });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves a known latestVersion when a later refresh fails', async () => {
+ const home = await tempHome();
+ await writeCache(home, {
+ lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.7.0',
+ });
+ const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(
+ refresh({ eligible: true, installedVersion: '1.6.10', now: NOW }),
+ ).resolves.toBeNull();
+ expect(await readCache(home)).toEqual({
+ lastCheckAt: new Date(NOW).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.7.0',
+ });
+ await expect(
+ evaluate({
+ eligible: true,
+ installedVersion: '1.6.10',
+ now: NOW + 1,
+ refreshIfStale: false,
+ }),
+ ).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips fetch when another process holds the refresh lock', async () => {
+ const home = await tempHome();
+ const release = await acquireFileLock(path.join(home, 'update-check.lock'));
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ try {
+ await refresh({ eligible: true, now: NOW });
+ expect(fetchMock).not.toHaveBeenCalled();
+ } finally {
+ await release();
+ }
+ });
+
+ it('returns stale cache state without fetching when refreshIfStale is false', async () => {
+ const home = await tempHome();
+ await writeCache(home, {
+ lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.7.0',
+ });
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(
+ evaluate({
+ eligible: true,
+ installedVersion: '1.6.10',
+ now: NOW,
+ refreshIfStale: false,
+ }),
+ ).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('replaces a future-dated cache timestamp instead of preserving it', async () => {
+ const home = await tempHome();
+ await writeCache(home, {
+ lastCheckAt: '2099-01-01T00:00:00.000Z',
+ registry: REGISTRY,
+ latestVersion: '9.9.9',
+ });
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(registryResponse('1.8.0')));
+
+ await refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
+
+ expect(await readCache(home)).toEqual({
+ lastCheckAt: new Date(NOW).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.8.0',
+ });
+ });
+
+ it('dedupes concurrent refresh() callers onto one fetch', async () => {
+ await tempHome();
+ let resolveFetch!: (response: Response) => void;
+ const fetchMock = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ const first = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
+ const second = refresh({ eligible: true, installedVersion: '1.6.10', now: NOW });
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ resolveFetch(registryResponse('1.8.0'));
+
+ await expect(Promise.all([first, second])).resolves.toEqual([
+ { updateAvailable: true, latestVersion: '1.8.0' },
+ { updateAvailable: true, latestVersion: '1.8.0' },
+ ]);
+ expect(first).toBe(second);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not let a late failed attempt overwrite a newer success', async () => {
+ const home = await tempHome();
+ let rejectFetch!: (error: Error) => void;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectFetch = reject;
+ }),
+ ),
+ );
+
+ // Relative to the real clock: publishMonotonically only preserves a newer
+ // on-disk timestamp when it is not in the future (`currentAt <= Date.now()`).
+ const wall = Date.now();
+ const olderAttempt = wall - 10_000;
+ const newerSuccess = wall - 1;
+ const pending = refresh({ eligible: true, now: olderAttempt });
+ await vi.waitFor(() => expect(rejectFetch).toBeTypeOf('function'));
+ await writeCache(home, {
+ lastCheckAt: new Date(newerSuccess).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.8.0',
+ });
+ rejectFetch(new Error('late failure'));
+ await pending;
+
+ expect(await readCache(home)).toEqual({
+ lastCheckAt: new Date(newerSuccess).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.8.0',
+ });
+ });
+
+ it('treats a cache from another registry as a miss', async () => {
+ const home = await tempHome();
+ await writeCache(home, {
+ lastCheckAt: new Date(NOW).toISOString(),
+ registry: 'https://registry.example.test/custom',
+ latestVersion: '9.0.0',
+ });
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
+
+ await expect(
+ evaluate({
+ eligible: true,
+ installedVersion: '1.6.10',
+ now: NOW,
+ refreshIfStale: false,
+ }),
+ ).resolves.toBeNull();
+ });
+
+ it('handles prerelease and lower versions with the strict comparator', () => {
+ expect(isNewerVersion('1.7.0-rc.1', '1.6.10')).toBe(false);
+ expect(isNewerVersion('1.7.0', '1.6.10')).toBe(false);
+ expect(isNewerVersion('1.6.10', '1.6.9')).toBe(false);
+ expect(isNewerVersion('1.6.10', '1.7.0')).toBe(true);
+ expect(isNewerVersion('1.0.9007199254740992', '1.0.9007199254740993')).toBe(true);
+ });
+});
+
+describe('hardened registry request', () => {
+ it('strips registry userinfo and sends no credentials', async () => {
+ await tempHome();
+ vi.stubEnv('npm_config_registry', 'https://user:secret@registry.example.test/custom/');
+ const fetchMock = vi.fn().mockResolvedValue(registryResponse());
+ vi.stubGlobal('fetch', fetchMock);
+
+ await refresh({ eligible: true, now: NOW });
+
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe('https://registry.example.test/custom/gitnexus/latest');
+ expect(url).not.toContain('user');
+ expect(new Headers(init.headers).has('authorization')).toBe(false);
+ });
+
+ it('refuses a redirect to loopback/private addresses', async () => {
+ const home = await tempHome();
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/latest' } }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await refresh({ eligible: true, now: NOW });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(await readCache(home)).not.toHaveProperty('latestVersion');
+ });
+
+ it('caps an oversized response body', async () => {
+ const home = await tempHome();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(new Response('x'.repeat(70_000), { status: 200 })),
+ );
+
+ await refresh({ eligible: true, now: NOW });
+
+ expect(await readCache(home)).not.toHaveProperty('latestVersion');
+ });
+});
+
+describe('guards and scheduler', () => {
+ it.each(['GITNEXUS_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER', 'CI'])(
+ '%s skips cache refresh and network with truthy-env semantics',
+ async (name) => {
+ await tempHome();
+ vi.stubEnv(name, 'yes');
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
+ await refresh({ eligible: true, now: NOW });
+ expect(fetchMock).not.toHaveBeenCalled();
+ },
+ );
+
+ it('ignoreOptOut still fetches when CI/opt-out env is set', async () => {
+ await tempHome();
+ vi.stubEnv('CI', 'true');
+ const fetchMock = vi.fn().mockResolvedValue(registryResponse());
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(
+ refresh({
+ eligible: true,
+ ignoreOptOut: true,
+ installedVersion: '1.6.10',
+ now: NOW,
+ }),
+ ).resolves.toEqual({ updateAvailable: true, latestVersion: '1.7.0' });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('an ineligible install skips network', async () => {
+ await tempHome();
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(evaluate({ eligible: false, now: NOW })).resolves.toBeNull();
+ await refresh({ eligible: false, now: NOW });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('an unwritable cache path fails open', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-update-unwritable-'));
+ tempDirs.push(root);
+ const blocker = path.join(root, 'not-a-directory');
+ await fs.writeFile(blocker, 'x');
+ vi.stubEnv('GITNEXUS_HOME', path.join(blocker, 'child'));
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
+
+ await expect(evaluate({ eligible: true, now: NOW })).resolves.toBeNull();
+ await expect(refresh({ eligible: true, now: NOW })).resolves.toBeNull();
+ });
+
+ it('arms an unrefd, clearable, single-flight scheduler', async () => {
+ await tempHome();
+ let resolveFetch!: (response: Response) => void;
+ const fetchMock = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ const onState = vi.fn();
+ const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
+
+ const clearA = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
+ const clearB = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ const immediateHandles = timeoutSpy.mock.results
+ .map((result) => result.value as NodeJS.Timeout)
+ .filter((handle) => typeof handle?.hasRef === 'function');
+ expect(immediateHandles.some((handle) => !handle.hasRef())).toBe(true);
+ resolveFetch(registryResponse());
+ await vi.waitFor(() => expect(onState).toHaveBeenCalled());
+ clearA();
+ clearB();
+ });
+
+ it('backs off when refresh is lock-busy instead of spinning at 1ms', async () => {
+ const home = await tempHome();
+ await writeCache(home, {
+ lastCheckAt: new Date(NOW - DAY_MS - 1).toISOString(),
+ registry: REGISTRY,
+ latestVersion: '1.7.0',
+ });
+ const release = await acquireFileLock(path.join(home, 'update-check.lock'));
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
+ const onState = vi.fn();
+ try {
+ const clear = armUpdateRefreshScheduler(onState, { eligible: true, now: () => NOW });
+ await vi.waitFor(() => expect(onState).toHaveBeenCalled());
+ const retryDelay = timeoutSpy.mock.calls
+ .map(([, delay]) => delay)
+ .find(
+ (delay): delay is number =>
+ typeof delay === 'number' && delay >= 30_000 && delay <= 60_000,
+ );
+ expect(retryDelay).toBeDefined();
+ expect(fetchMock).not.toHaveBeenCalled();
+ clear();
+ } finally {
+ await release();
+ }
+ });
+});
+
+describe('updateEligibleInstall', () => {
+ async function entry(relative: string): Promise<{ root: string; entry: string }> {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-install-context-'));
+ tempDirs.push(root);
+ const entryPath = path.join(root, relative);
+ await fs.mkdir(path.dirname(entryPath), { recursive: true });
+ await fs.writeFile(entryPath, '');
+ return { root, entry: entryPath };
+ }
+
+ it('classifies global, local, ephemeral, dev, and Docker layouts', async () => {
+ const global = await entry('prefix/lib/node_modules/gitnexus/dist/cli/index.js');
+ expect(
+ await updateEligibleInstall(global.entry, {
+ npm_config_prefix: path.join(global.root, 'prefix'),
+ }),
+ ).toBe(true);
+
+ const local = await entry('project/node_modules/gitnexus/dist/cli/index.js');
+ expect(await updateEligibleInstall(local.entry, {})).toBe(true);
+
+ const namedDlx = await entry('dlx/project/node_modules/gitnexus/dist/cli/index.js');
+ expect(await updateEligibleInstall(namedDlx.entry, {})).toBe(true);
+
+ for (const relative of [
+ 'cache/_npx/123/node_modules/gitnexus/dist/cli/index.js',
+ 'cache/_cacache/tmp/node_modules/gitnexus/dist/cli/index.js',
+ 'pnpm/dlx/123/node_modules/gitnexus/dist/cli/index.js',
+ '.bun/install/cache/gitnexus@1.0.0/node_modules/gitnexus/dist/cli/index.js',
+ ]) {
+ const ephemeral = await entry(relative);
+ expect(
+ await updateEligibleInstall(ephemeral.entry, {
+ npm_config_cache: path.join(ephemeral.root, 'cache'),
+ npm_execpath: path.join(ephemeral.root, relative),
+ }),
+ ).toBe(false);
+ }
+
+ const dev = await entry('checkout/gitnexus/src/cli/index.ts');
+ await fs.mkdir(path.join(dev.root, 'checkout', '.git'));
+ expect(await updateEligibleInstall(dev.entry, {})).toBe(false);
+
+ const docker = await entry('usr/local/lib/node_modules/gitnexus/dist/cli/index.js');
+ expect(
+ await updateEligibleInstall(docker.entry, {
+ npm_config_prefix: path.join(docker.root, 'usr/local'),
+ }),
+ ).toBe(true);
+ });
+});