From 79543c8f833eb571bfa853650da569019ecaf0d5 Mon Sep 17 00:00:00 2001 From: mengkaka Date: Sun, 13 Sep 2026 04:31:55 +0800 Subject: [PATCH] feat(storage): add configurable index storage and content retention tiers (#3060) * feat(storage): add configurable index storage and content retention tiers Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH, GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in main's FTS skip, embed-session, and help-text updates. Co-authored-by: Cursor * Address PR review feedback (#3060) Keep legacy registry rows on the local storage fallback, resolve symlinks before the destructive-path guard, and align hook lookup with CLI branch slugs, branch-slot metadata, and longest-path match. Co-authored-by: Cursor * Address PR review feedback (#3060) Only list swept upload directories after a successful removal so callers cannot treat a permission or transient rm failure as gone. Co-authored-by: Cursor * Address PR review feedback (#3060) Document that getStoragePath may consult registered storage while this module still does not mutate the global registry. Co-authored-by: Cursor * fix(storage): close review findings for external indexes and retention Re-inspect ownership under the analyze lock, fail-closed when the registry file is missing, and keep skip-git hook discovery plus retention fields on HTTP/MCP list surfaces. /api/file stays 410 unless contentRetention is full. Co-authored-by: Cursor * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3060) Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment. Co-authored-by: Cursor * Address PR review feedback (#3060) Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation. Co-authored-by: Cursor * Fix macOS hook test expecting realpath'd registry paths. resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that. * Address gitnexus-check warnings on hook install docs and slot tests. The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory. * Align the HTTP catalog source-scan with skippable resolveRepo validation. resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true. * Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear. Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time. * Settle bridge stamps before writing so CI size/mtime matches stay stable. LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches. * Type the settled bridge stat as fs.Stats so tsc does not see bigint. Awaited> collapsed the bigint overload and broke prepare/typecheck on CI. * Keep the bridge mtime stamp exact so same-size swaps still fail the pair check. Co-authored-by: Cursor * Wrap the bridge stamp predicate so prettier --check stays green. Co-authored-by: Cursor * Require a quiet interval before stamping a settled bridge file. Co-authored-by: Cursor * Reuse shared storage and settle helpers instead of local copies. Co-authored-by: Cursor --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .claude/skills/gitnexus-cli/SKILL.md | 12 + AGENTS.md | 1 + README.md | 7 +- gitnexus-claude-plugin/hooks/gitnexus-hook.js | 129 +-- .../hooks/registry-query.cjs | 410 ++++++++ .../skills/gitnexus-cli/SKILL.md | 12 + gitnexus-cursor-integration/README.md | 26 +- .../hooks/gitnexus-hook.cjs | 68 +- .../hooks/registry-query.cjs | 410 ++++++++ .../src/components/CodeReferencesPanel.tsx | 20 +- gitnexus-web/src/locales/en/graph.json | 3 +- gitnexus-web/src/locales/zh-CN/graph.json | 3 +- gitnexus-web/src/services/backend-client.ts | 33 +- .../test/unit/code-references-panel.test.tsx | 25 +- gitnexus/README.md | 7 +- gitnexus/bench/mcp-tools-list/measure.mjs | 6 +- .../antigravity/gitnexus-antigravity-hook.cjs | 146 +-- gitnexus/hooks/claude/gitnexus-hook.cjs | 129 +-- gitnexus/hooks/claude/registry-query.cjs | 410 ++++++++ gitnexus/skills/gitnexus-cli.md | 12 + gitnexus/src/cli/analyze-watch.ts | 8 + gitnexus/src/cli/analyze.ts | 8 +- gitnexus/src/cli/clean.ts | 106 +- gitnexus/src/cli/i18n/en.ts | 2 +- gitnexus/src/cli/i18n/zh-CN.ts | 2 +- gitnexus/src/cli/index-repo.ts | 92 +- gitnexus/src/cli/index.ts | 13 +- gitnexus/src/cli/publish.ts | 20 +- gitnexus/src/cli/remove.ts | 20 +- gitnexus/src/cli/setup.ts | 1 + gitnexus/src/cli/status.ts | 196 +++- gitnexus/src/cli/wiki.ts | 29 +- gitnexus/src/core/augmentation/engine.ts | 29 +- gitnexus/src/core/content-retention.ts | 92 ++ gitnexus/src/core/group/bridge-db.ts | 47 +- gitnexus/src/core/group/sync.ts | 42 +- gitnexus/src/core/lbug/csv-generator.ts | 23 +- gitnexus/src/core/lbug/lbug-adapter.ts | 7 +- gitnexus/src/core/run-analyze.ts | 178 +++- gitnexus/src/core/search/fts-indexes.ts | 25 +- gitnexus/src/core/search/fts-schema.ts | 15 + gitnexus/src/mcp/local/local-backend.ts | 96 +- gitnexus/src/mcp/resources.ts | 14 + gitnexus/src/mcp/tools.ts | 6 +- gitnexus/src/server/analyze-launch.ts | 197 ++-- gitnexus/src/server/analyze-upload.ts | 8 +- gitnexus/src/server/analyze-worker-core.ts | 2 +- gitnexus/src/server/api.ts | 207 +++- gitnexus/src/server/repo-projection.ts | 21 +- gitnexus/src/server/upload-sweep.ts | 66 +- gitnexus/src/storage/branch-index.ts | 8 +- gitnexus/src/storage/parse-cache.ts | 2 +- gitnexus/src/storage/repo-manager.ts | 554 +++++++---- gitnexus/src/storage/repo-meta.ts | 32 +- gitnexus/src/storage/storage-constants.ts | 6 + gitnexus/src/storage/storage-resolver.ts | 736 ++++++++++++++ .../integration/antigravity-hook-e2e.test.ts | 29 +- .../test/integration/augmentation.test.ts | 30 +- .../context-resource-staleness.test.ts | 6 +- ...external-storage-content-retention.test.ts | 228 +++++ gitnexus/test/integration/hooks-e2e.test.ts | 28 +- .../local-backend-calltool.test.ts | 94 +- .../resolvers/java-javac-local-types.test.ts | 100 +- .../run-analyze-adopt-failure.test.ts | 2 +- .../integration/server-repo-freshness.test.ts | 3 +- gitnexus/test/unit/analyze-api.test.ts | 21 +- .../unit/analyze-launch-branch-settle.test.ts | 85 +- .../test/unit/analyze-launch-collapse.test.ts | 160 ++- .../test/unit/analyze-worker-core.test.ts | 2 + gitnexus/test/unit/analyze-worker-ipc.test.ts | 2 + ...nalyzer-identity-in-process-guards.test.ts | 8 +- gitnexus/test/unit/analyzer-identity.test.ts | 31 +- gitnexus/test/unit/api-file-route.test.ts | 18 +- gitnexus/test/unit/api-fts-mode.test.ts | 25 +- gitnexus/test/unit/calltool-dispatch.test.ts | 31 +- ...canonicalize-path-long-path-prefix.test.ts | 13 +- .../test/unit/clean-command-ownership.test.ts | 119 +++ gitnexus/test/unit/cli-index-help.test.ts | 19 + gitnexus/test/unit/content-retention.test.ts | 150 +++ gitnexus/test/unit/cursor-hook.test.ts | 54 +- gitnexus/test/unit/fts-indexes.test.ts | 12 +- .../group/bridge-meta-swap-window.test.ts | 9 + .../unit/group/sync-registry-identity.test.ts | 36 +- .../unit/group/sync-unreadable-repos.test.ts | 14 + gitnexus/test/unit/hooks.test.ts | 941 ++++++++++++++++-- .../test/unit/incremental-parse-cache.test.ts | 4 + gitnexus/test/unit/index-repo-command.test.ts | 120 ++- gitnexus/test/unit/list-status-branch.test.ts | 215 +++- gitnexus/test/unit/publish.test.ts | 4 +- gitnexus/test/unit/rate-limit.test.ts | 9 + gitnexus/test/unit/remove-command.test.ts | 11 +- .../repo-manager-finalize-invariant.test.ts | 45 + .../test/unit/repo-manager-reconcile.test.ts | 94 +- .../repo-manager-registry-strict-read.test.ts | 22 +- ...o-manager-registry-validation-race.test.ts | 96 ++ .../unit/repo-manager-transient-error.test.ts | 64 +- gitnexus/test/unit/repo-manager.test.ts | 220 +++- gitnexus/test/unit/repo-projection.test.ts | 51 +- gitnexus/test/unit/resources.test.ts | 3 + .../unit/run-analyze-adopt-failure.test.ts | 32 + .../test/unit/run-analyze-fts-repair.test.ts | 13 +- .../unit/server-api-repo-resolution.test.ts | 48 +- gitnexus/test/unit/setup-antigravity.test.ts | 18 + gitnexus/test/unit/setup.test.ts | 18 +- gitnexus/test/unit/skip-git-cli.test.ts | 6 + .../test/unit/status-content-drift.test.ts | 19 +- gitnexus/test/unit/storage-resolver.test.ts | 367 +++++++ gitnexus/test/unit/upload-sweep.test.ts | 90 +- gitnexus/test/unit/wiki-flags.test.ts | 17 + gitnexus/test/utils/hook-test-helpers.ts | 31 +- gitnexus/vitest.config.ts | 4 +- 111 files changed, 7313 insertions(+), 1357 deletions(-) create mode 100644 gitnexus-claude-plugin/hooks/registry-query.cjs create mode 100644 gitnexus-cursor-integration/hooks/registry-query.cjs create mode 100644 gitnexus/hooks/claude/registry-query.cjs create mode 100644 gitnexus/src/core/content-retention.ts create mode 100644 gitnexus/src/storage/storage-constants.ts create mode 100644 gitnexus/src/storage/storage-resolver.ts create mode 100644 gitnexus/test/integration/external-storage-content-retention.test.ts create mode 100644 gitnexus/test/unit/clean-command-ownership.test.ts create mode 100644 gitnexus/test/unit/content-retention.test.ts create mode 100644 gitnexus/test/unit/repo-manager-registry-validation-race.test.ts create mode 100644 gitnexus/test/unit/storage-resolver.test.ts diff --git a/.claude/skills/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus-cli/SKILL.md index 09c7af0d2..3a00ab546 100644 --- a/.claude/skills/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus-cli/SKILL.md @@ -34,6 +34,18 @@ Run from the project root. This parses all source files, builds the knowledge gr For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. +## Index storage and retention + +Default location is `/.gitnexus/`. Override with environment variables (also documented in README): + +| Env | Effect | +| --- | ------ | +| `GITNEXUS_STORAGE_PATH` | One complete external index directory. Wins if both storage vars are set. | +| `GITNEXUS_STORAGE_ROOT` | Absolute root; GitNexus creates an isolated `-<12-hex>/` slot per repository. | +| `GITNEXUS_CONTENT_RETENTION` | `full` (default) keeps file text; `symbol` keeps snippets; `none` keeps the graph only. | + +`list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`. MCP `include_content` may still return symbol spans when retention is `symbol`. + Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. ### status — Check index freshness diff --git a/AGENTS.md b/AGENTS.md index 9def73122..d15b02274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,3 +197,4 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind - `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (`build-tree-sitter-grammars.cjs` activates committed prebuilds in place under `vendor/`, and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback. - The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin,zig}` are handled uniformly: c is required; dart/proto/swift/kotlin/zig are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable. - ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`. +- Index storage defaults to `/.gitnexus/`. `GITNEXUS_STORAGE_PATH` selects one complete external index directory and wins over `GITNEXUS_STORAGE_ROOT`, which creates an isolated `-<12-hex>/` slot per repository. `GITNEXUS_CONTENT_RETENTION` is `full` (default), `symbol`, or `none`. MCP `list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`; MCP `include_content` may still return symbol spans at `symbol`. diff --git a/README.md b/README.md index 80f93f9d9..0d8ae457c 100644 --- a/README.md +++ b/README.md @@ -598,6 +598,9 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout ` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | | `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". | | `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. | +| `GITNEXUS_STORAGE_PATH` | unset (`/.gitnexus/`) | Complete external index directory. This preserves the existing configuration semantics and takes precedence over `GITNEXUS_STORAGE_ROOT` when both are set. | You already keep one repository index outside its checkout or need one explicit index location. | +| `GITNEXUS_STORAGE_ROOT` | unset | Absolute root directory for external indexes. GitNexus creates an isolated `-/` slot beneath it for each repository, then registers the resolved slot so `status`, MCP, and `serve` can reopen it later. | You want to manage multiple repository indexes centrally or keep generated data outside source checkouts. | +| `GITNEXUS_CONTENT_RETENTION` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. | You need to reduce persisted source text while preserving graph structure. | | `GITNEXUS_SKIP_FTS` | unset | When exactly `1`, skips FTS extension loading and keyword index creation during analyze. Equivalent to `--skip-fts`; a later analyze without either option restores FTS. | Graph-only consumers with their own retrieval, or short-lived indexes that do not need keyword search. | | `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold `. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. | | `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. | @@ -676,7 +679,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere. -Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly. +Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo by default (portable, gitignored). `GITNEXUS_STORAGE_PATH` selects one complete external index directory and preserves the established configuration behavior. To manage multiple repositories under one external directory, set `GITNEXUS_STORAGE_ROOT`; GitNexus derives an isolated `-/` slot beneath it for each repository. If both variables are set, `GITNEXUS_STORAGE_PATH` takes precedence. GitNexus registers the resolved slot in `~/.gitnexus/registry.json`, allowing later `status`, MCP, and `serve` commands to reopen the index without repeating the environment variable. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
Architecture diagram @@ -1095,7 +1098,7 @@ Built by the community — not officially maintained, but worth checking out. ## Security & Privacy -- **CLI**: everything runs locally on your machine. No network calls. Index stored in `.gitnexus/` (gitignored). Global registry at `~/.gitnexus/` stores only paths and metadata. +- **CLI**: everything runs locally on your machine. No network calls. Indexes are stored in `.gitnexus/` by default (gitignored), in the complete external directory selected by `GITNEXUS_STORAGE_PATH`, or in repository-specific slots beneath `GITNEXUS_STORAGE_ROOT`. Global registry at `~/.gitnexus/` stores only paths and metadata. - **Web**: everything runs in your browser. No code uploaded to any server. API keys stored in localStorage only. - Open source — audit the code yourself. diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 238438455..b550f5e08 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -20,6 +20,7 @@ const { resolveUnixGuardTimeout, } = require('./hook-db-lock-probe.cjs'); const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs'); +const { resolveHookRepo } = require('./registry-query.cjs'); /** * Read JSON input from stdin synchronously. @@ -33,106 +34,10 @@ function readInput() { } } -/** - * Find the .gitnexus directory by walking up from startDir. - * Returns the path to .gitnexus/ or null if not found. - */ -function isGlobalRegistryDir(candidate) { - if ( - fs.existsSync(path.join(candidate, 'gitnexus.json')) || - fs.existsSync(path.join(candidate, 'meta.json')) - ) { - return false; - } - return ( - fs.existsSync(path.join(candidate, 'registry.json')) || - fs.existsSync(path.join(candidate, 'repos')) - ); -} +/* Registry-backed hooks resolve the source repo and storage path together. */ -/** - * Read the index metadata file, preferring `gitnexus.json` (current format) - * and falling back to the legacy `meta.json` mirror. Returns `null` if - * neither exists or parses. - */ -function readIndexMeta(gitNexusDir) { - try { - return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'gitnexus.json'), 'utf-8')); - } catch { - try { - return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); - } catch { - return null; - } - } -} - -/** - * Walk up from `startDir` looking for a non-registry `.gitnexus/` folder. - * Returns the path to `.gitnexus/` or null if not found within 5 levels. - */ -function walkForGitNexusDir(startDir) { - let dir = startDir; - for (let i = 0; i < 5; i++) { - const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) { - if (!isGlobalRegistryDir(candidate)) return candidate; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return null; -} - -/** - * Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside - * any git working tree — including a *linked* worktree created via - * `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the - * upward walk from cwd alone misses the index. Returns null when `cwd` is - * not inside a git repo or `git` is not available. - * - * Implementation: `git rev-parse --git-common-dir` resolves to the canonical - * `.git/` directory (or `.git/worktrees/...` parent) that is shared across - * all linked worktrees. The canonical repo root is its parent directory. - */ -function findCanonicalRepoRoot(cwd) { - try { - const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { - encoding: 'utf-8', - timeout: 2000, - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.error || result.status !== 0) return null; - const commonDir = (result.stdout || '').trim(); - if (!commonDir || !path.isAbsolute(commonDir)) return null; - return path.dirname(commonDir); - } catch { - return null; - } -} - -function findGitNexusDir(startDir) { - const cwd = startDir || process.cwd(); - - // Fast path: the cwd is inside the canonical repo (most common case). - const fromCwd = walkForGitNexusDir(cwd); - if (fromCwd) return fromCwd; - - // Fallback: cwd may be inside a linked git worktree whose `.gitnexus/` - // only lives in the canonical repo root. Resolve the shared git dir - // and retry from there. - const canonicalRoot = findCanonicalRepoRoot(cwd); - if (canonicalRoot && canonicalRoot !== cwd) { - return walkForGitNexusDir(canonicalRoot); - } - return null; -} - -function hasGitNexusServerOwner(gitNexusDir) { - return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid); +function hasGitNexusServerOwner(lbugPath) { + return hasGitNexusDbLockedByGitNexusServer(lbugPath, process.pid); } /** @@ -406,11 +311,11 @@ function buildMcpQueryHint(pattern) { * ponytail: per-repo mtime marker, shared across concurrent sessions on the same * repo; add per-session dedup only if that sharing becomes a problem. */ -function shouldEmitMcpHint(gitNexusDir) { +function shouldEmitMcpHint(storagePath) { const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS; const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw); if (!Number.isFinite(windowMs) || windowMs <= 0) return true; - const marker = path.join(gitNexusDir, '.mcp-hint-shown'); + const marker = path.join(storagePath, '.mcp-hint-shown'); try { if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false; } catch { @@ -430,8 +335,6 @@ function shouldEmitMcpHint(gitNexusDir) { function handlePreToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - const gitNexusDir = findGitNexusDir(cwd); - if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; @@ -441,12 +344,18 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + // Registry row first (persisted external storagePath wins). Local owned + // `.gitnexus` is only the fallback when no matching registry row exists. + const repo = resolveHookRepo(cwd); + if (!repo) return; + const storagePath = repo.storagePath; + // Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe // itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap // as the augment, or concurrent sessions fan out unbounded probe // subprocesses. Keep the acquire right after the cheap guards above — // moving it earlier would churn slot files on tool calls that never probe. - const release = acquireHookSlot(gitNexusDir); + const release = acquireHookSlot(storagePath); if (!release) { // Normal skip path: all per-repo hook slots are held by concurrent // sessions. Stay silent for strict hook runners (issue #1913); surface @@ -459,7 +368,7 @@ function handlePreToolUse(input) { let result = ''; try { - if (hasGitNexusServerOwner(gitNexusDir)) { + if (hasGitNexusServerOwner(repo.lbugPath)) { // #2396: the MCP server holds the DB write lock, so a competing CLI // `augment` would only contend on it (LadybugDB is single-writer). But the // session that triggered this hook has the GitNexus MCP tools live — route @@ -470,7 +379,7 @@ function handlePreToolUse(input) { if (isDebugEnabled()) { process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n'); } - if (shouldEmitMcpHint(gitNexusDir)) { + if (shouldEmitMcpHint(storagePath)) { result = buildMcpQueryHint(pattern); } } else { @@ -496,7 +405,7 @@ function handlePreToolUse(input) { * Instead of spawning a full `gitnexus analyze` synchronously (which blocks * the agent for up to 120s and risks LadybugDB corruption on timeout), we do a * lightweight staleness check: compare `git rev-parse HEAD` against the - * lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the + * lastCommit stored in the registered index metadata. If they differ, notify the * agent so it can decide when to reindex. */ function handlePostToolUse(input) { @@ -512,8 +421,8 @@ function handlePostToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - const gitNexusDir = findGitNexusDir(cwd); - if (!gitNexusDir) return; + const repo = resolveHookRepo(cwd); + if (!repo) return; // Compare HEAD against last indexed commit — skip if unchanged let currentHead = ''; @@ -534,7 +443,7 @@ function handlePostToolUse(input) { let lastCommit = ''; let hadEmbeddings = false; - const meta = readIndexMeta(gitNexusDir); + const meta = repo.metadata; if (meta) { lastCommit = meta.lastCommit || ''; hadEmbeddings = meta.stats && meta.stats.embeddings > 0; diff --git a/gitnexus-claude-plugin/hooks/registry-query.cjs b/gitnexus-claude-plugin/hooks/registry-query.cjs new file mode 100644 index 000000000..649b363fe --- /dev/null +++ b/gitnexus-claude-plugin/hooks/registry-query.cjs @@ -0,0 +1,410 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { createHash } = require('crypto'); +const { spawnSync } = require('child_process'); + +// Hooks are copied into editor-specific directories and run without the +// package's TypeScript modules. Keep their on-disk names centralized here. +const GITNEXUS_DIR = '.gitnexus'; +const INDEX_METADATA_FILE = 'gitnexus.json'; +const LEGACY_METADATA_FILE = 'meta.json'; +const LBUG_DIRECTORY = 'lbug'; +const BRANCHES_DIRECTORY = 'branches'; +const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH'; +const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT'; +const STORAGE_SLOT_HASH_LENGTH = 12; +const LOCAL_OWNED_PARENT_HOPS = 5; + +function stripWindowsLongPathPrefix(p) { + if (process.platform !== 'win32') return p; + if (/^\\\\\?\\UNC\\(?=[^\\])/i.test(p)) return `\\\\${p.slice(8)}`; + if (/^\\\\\?\\[A-Za-z]:\\/.test(p)) return p.slice(4); + return p; +} + +function canonicalize(value) { + if (typeof value !== 'string' || !value || value.includes('\0') || !path.isAbsolute(value)) + return null; + const resolved = path.resolve(value); + try { + return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved)); + } catch { + return stripWindowsLongPathPrefix(resolved); + } +} + +function samePath(left, right) { + if (left == null || right == null) return false; + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function isMissingFile(error) { + return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR'); +} + +function readMetadataFile(storagePath, filename) { + try { + const value = JSON.parse(fs.readFileSync(path.join(storagePath, filename), 'utf-8')); + return value && typeof value === 'object' && !Array.isArray(value) + ? { state: 'valid', value } + : { state: 'invalid' }; + } catch (error) { + return isMissingFile(error) ? { state: 'absent' } : { state: 'invalid' }; + } +} + +function readIndexMetadata(storagePath) { + const primary = readMetadataFile(storagePath, INDEX_METADATA_FILE); + if (primary.state === 'valid') return primary.value; + if (primary.state !== 'absent') return null; + + const legacy = readMetadataFile(storagePath, LEGACY_METADATA_FILE); + return legacy.state === 'valid' ? legacy.value : null; +} + +function isOwnedStorage(repoPath, storagePath, repositoryLocal, metadata) { + // Repository-local storage remains usable for metadata written before + // repoPath was recorded, but an explicit repoPath must never name another + // checkout. External storage always requires the complete ownership binding. + if (repositoryLocal && (!metadata || typeof metadata.repoPath !== 'string')) { + return true; + } + if (!metadata || typeof metadata.repoPath !== 'string') return false; + + const metadataRepoPath = canonicalize(metadata.repoPath); + const expectedRepoPath = canonicalize(repoPath); + if ( + metadataRepoPath == null || + expectedRepoPath == null || + !samePath(metadataRepoPath, expectedRepoPath) + ) { + return false; + } + if (repositoryLocal) return true; + if (typeof metadata.storagePath !== 'string') return false; + + const metadataStoragePath = canonicalize(metadata.storagePath); + const expectedStoragePath = canonicalize(storagePath); + return ( + metadataStoragePath != null && + expectedStoragePath != null && + samePath(metadataStoragePath, expectedStoragePath) + ); +} + +function ancestorPaths(cwd) { + const paths = []; + let current = canonicalize(cwd); + while (current) { + paths.push(current); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return paths; +} + +function isInsideOrEqual(child, ancestor) { + if (child == null || ancestor == null) return false; + if (samePath(child, ancestor)) return true; + const relative = path.relative(ancestor, child); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function ancestorPathsThrough(cwd, stopAt) { + const paths = []; + let current = canonicalize(cwd); + const stop = canonicalize(stopAt); + while (current) { + if (stop && !isInsideOrEqual(current, stop)) break; + paths.push(current); + if (stop && samePath(current, stop)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return paths; +} + +function currentGitBranch(cwd) { + try { + const result = spawnSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.error || result.status !== 0) return null; + const branch = String(result.stdout || '').trim(); + return branch || null; + } catch { + return null; + } +} + +function registryPathsForCwd(cwd) { + const fallbackPaths = ancestorPaths(cwd); + if (fallbackPaths.length === 0) return { repoPaths: [], branch: null }; + try { + const result = spawnSync( + 'git', + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + if (result.error || result.status !== 0) return { repoPaths: fallbackPaths, branch: null }; + + const [worktreeRoot, commonDir] = String(result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (!worktreeRoot || !path.isAbsolute(worktreeRoot)) { + return { repoPaths: fallbackPaths, branch: null }; + } + + // Keep ancestor paths of cwd that stay inside this worktree (cwd up to + // and including show-toplevel) so a --skip-git subdirectory index can + // win via longest-match. Do not walk ancestors outside the worktree — + // that would re-attribute a parent index to a nested git checkout. + const repoPaths = ancestorPathsThrough(cwd, worktreeRoot); + const worktreeCanon = canonicalize(worktreeRoot); + if (worktreeCanon && !repoPaths.some((repoPath) => samePath(repoPath, worktreeCanon))) { + repoPaths.push(worktreeCanon); + } + + // Linked worktrees share the canonical repo's git dir. Include that + // parent so the registered main checkout is still discoverable, but do + // not walk any further outside this worktree. + if (commonDir) { + const commonParent = canonicalize(path.dirname(commonDir)); + if ( + commonParent && + worktreeCanon && + !samePath(commonParent, worktreeCanon) && + !repoPaths.some((repoPath) => samePath(repoPath, commonParent)) + ) { + repoPaths.push(commonParent); + } + } + return { + repoPaths, + branch: currentGitBranch(cwd), + }; + } catch { + return { repoPaths: fallbackPaths, branch: null }; + } +} + +function branchSlug(rawRef) { + const sanitized = rawRef.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_'); + const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i; + const safe = + !sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized) + ? 'unknown' + : sanitized; + const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8); + return `${safe}-${hash}`; +} + +// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly +// (sanitize + sha256 of the canonical repo path, 12-hex suffix). +function sanitizeSlotBasename(value) { + // Cap first, then walk the tail once — same order as + // gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS). + const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); + let end = sanitized.length; + while (end > 0) { + const code = sanitized.charCodeAt(end - 1); + if (code !== 0x20 && code !== 0x2e) break; + end--; + } + const candidate = sanitized.slice(0, end) || 'repository'; + return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) + ? `repository-${candidate}` + : candidate; +} + +function storageSlotName(repoPath) { + const canonical = canonicalize(repoPath); + if (!canonical) return null; + const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical; + const basename = sanitizeSlotBasename(path.basename(canonical)); + const digest = createHash('sha256') + .update(identity) + .digest('hex') + .slice(0, STORAGE_SLOT_HASH_LENGTH); + return `${basename}-${digest}`; +} + +function envOverridesStorage() { + const envPath = process.env[STORAGE_PATH_ENV]; + const envRoot = process.env[STORAGE_ROOT_ENV]; + return ( + (typeof envPath === 'string' && envPath.length > 0) || + (typeof envRoot === 'string' && envRoot.length > 0) + ); +} + +function resolveEntryStoragePath(entry) { + const envPath = process.env[STORAGE_PATH_ENV]; + if ( + typeof envPath === 'string' && + envPath.length > 0 && + !envPath.includes('\0') && + path.isAbsolute(envPath) + ) { + const resolved = path.resolve(envPath); + if (path.isAbsolute(resolved)) return resolved; + } + + const envRoot = process.env[STORAGE_ROOT_ENV]; + if ( + typeof envRoot === 'string' && + envRoot.length > 0 && + !envRoot.includes('\0') && + path.isAbsolute(envRoot) + ) { + const root = path.resolve(envRoot); + const slot = storageSlotName(entry.path); + if (slot) { + const storagePath = path.join(root, slot); + if (samePath(path.dirname(storagePath), root)) return storagePath; + } + } + + if (entry.storagePath !== undefined) { + if ( + typeof entry.storagePath !== 'string' || + !entry.storagePath || + entry.storagePath.includes('\0') || + !path.isAbsolute(entry.storagePath) + ) { + return null; + } + return path.resolve(entry.storagePath); + } + return path.resolve(path.join(entry.path, GITNEXUS_DIR)); +} + +function hasLocalIndexSignal(storagePath) { + try { + return ( + fs.existsSync(path.join(storagePath, INDEX_METADATA_FILE)) || + fs.existsSync(path.join(storagePath, LBUG_DIRECTORY)) + ); + } catch { + return false; + } +} + +function findLocalOwnedRepo(cwd) { + // Environment storage overrides win; a leftover repo-local .gitnexus must + // not skip the registry scan that applies STORAGE_PATH / STORAGE_ROOT. + if (envOverridesStorage()) return null; + const { repoPaths, branch } = registryPathsForCwd(cwd); + let current = canonicalize(cwd); + for (let hops = 0; hops <= LOCAL_OWNED_PARENT_HOPS && current; hops++) { + const storagePath = path.join(current, GITNEXUS_DIR); + if (hasLocalIndexSignal(storagePath)) { + const metadata = readIndexMetadata(storagePath); + if (isOwnedStorage(current, storagePath, true, metadata)) { + const branchDir = + branch != null ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) : null; + const indexDir = branchDir && hasLocalIndexSignal(branchDir) ? branchDir : storagePath; + return { + path: current, + storagePath, + lbugPath: path.join(indexDir, LBUG_DIRECTORY), + metadata: indexDir === storagePath ? metadata : readIndexMetadata(indexDir), + }; + } + } + const parent = path.dirname(current); + if (parent === current) break; + // Stay inside this checkout. Registered lookup already stops at + // `--show-toplevel`; walking raw parents would adopt `/outer/.gitnexus` + // from `/outer/nested-repo`. + if (repoPaths.length > 0 && !repoPaths.some((repoPath) => samePath(repoPath, parent))) { + break; + } + current = parent; + } + return null; +} + +function findRegisteredRepo(cwd) { + const { repoPaths, branch } = registryPathsForCwd(cwd); + if (repoPaths.length === 0) return null; + + const home = process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'); + let entries; + try { + entries = JSON.parse(fs.readFileSync(path.join(home, 'registry.json'), 'utf-8')); + } catch { + return null; + } + if (!Array.isArray(entries)) return null; + + let best = null; + let bestLen = -1; + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + if (typeof entry.path !== 'string') continue; + if (entry.path.includes('\0') || !path.isAbsolute(entry.path)) continue; + const registeredPath = canonicalize(entry.path); + if (!registeredPath || !repoPaths.some((repoPath) => samePath(repoPath, registeredPath))) { + continue; + } + const storagePath = resolveEntryStoragePath(entry); + if (!storagePath) continue; + const repositoryLocal = samePath( + canonicalize(path.join(entry.path, GITNEXUS_DIR)), + canonicalize(storagePath), + ); + const ownershipMetadata = readIndexMetadata(storagePath); + if (!isOwnedStorage(entry.path, storagePath, repositoryLocal, ownershipMetadata)) continue; + const branchIsIndexed = + branch && + Array.isArray(entry.branches) && + entry.branches.some((summary) => summary && summary.branch === branch); + const indexDir = branchIsIndexed + ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) + : storagePath; + if (registeredPath.length > bestLen) { + bestLen = registeredPath.length; + best = { + path: entry.path, + storagePath, + lbugPath: path.join(indexDir, LBUG_DIRECTORY), + metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata, + }; + } + } + return best; +} + +/** Registry row wins (including persisted external storagePath); local owned is fallback. */ +function resolveHookRepo(cwd) { + return findRegisteredRepo(cwd) || findLocalOwnedRepo(cwd); +} + +module.exports = { + findRegisteredRepo, + findLocalOwnedRepo, + resolveHookRepo, + INDEX_METADATA_FILE, + LEGACY_METADATA_FILE, + LBUG_DIRECTORY, +}; diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 09c7af0d2..3a00ab546 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -34,6 +34,18 @@ Run from the project root. This parses all source files, builds the knowledge gr For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. +## Index storage and retention + +Default location is `/.gitnexus/`. Override with environment variables (also documented in README): + +| Env | Effect | +| --- | ------ | +| `GITNEXUS_STORAGE_PATH` | One complete external index directory. Wins if both storage vars are set. | +| `GITNEXUS_STORAGE_ROOT` | Absolute root; GitNexus creates an isolated `-<12-hex>/` slot per repository. | +| `GITNEXUS_CONTENT_RETENTION` | `full` (default) keeps file text; `symbol` keeps snippets; `none` keeps the graph only. | + +`list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`. MCP `include_content` may still return symbol spans when retention is `symbol`. + Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. ### status — Check index freshness diff --git a/gitnexus-cursor-integration/README.md b/gitnexus-cursor-integration/README.md index 67bed4583..0d044aaa9 100644 --- a/gitnexus-cursor-integration/README.md +++ b/gitnexus-cursor-integration/README.md @@ -6,11 +6,11 @@ Static config that adds GitNexus knowledge-graph augmentation and skill files to ## What you get -| Layer | What it does | How it's installed | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| **MCP** | `gitnexus` MCP server with 17 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. | -| **Skills** | All bundled markdown skills (`/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-guide`, `/gitnexus-cli`, `/gitnexus-review`, `/gitnexus-plan`, `/gitnexus-work`, `/gitnexus-lfg`, `/gitnexus-pdg-query`, `/gitnexus-taint-analysis`) | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. | -| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. | +| Layer | What it does | How it's installed | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | +| **MCP** | `gitnexus` MCP server with 17 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. | +| **Skills** | All bundled markdown skills (`/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-guide`, `/gitnexus-cli`, `/gitnexus-review`, `/gitnexus-plan`, `/gitnexus-work`, `/gitnexus-lfg`, `/gitnexus-pdg-query`, `/gitnexus-taint-analysis`) | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. | +| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. | ## Hook install @@ -24,7 +24,8 @@ From this repo's `gitnexus-cursor-integration/hooks/`, copy the files below into │ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json └── hooks/ ├── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs - └── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs + ├── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs + └── registry-query.cjs ← from gitnexus-cursor-integration/hooks/registry-query.cjs ``` Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo): @@ -34,6 +35,7 @@ mkdir -p .cursor hooks cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hook-lock.cjs" hooks/hook-lock.cjs +cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/registry-query.cjs" hooks/registry-query.cjs ``` If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting. @@ -47,11 +49,11 @@ If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array ### What's installed manually vs. automated -| Step | Automated by `gitnexus setup`? | -| -------------------------------------------------------------------- | ------------------------------ | -| `~/.cursor/mcp.json` | ✅ | -| `~/.cursor/skills/gitnexus/*` | ✅ | -| `/.cursor/hooks.json` + `/hooks/gitnexus-hook.cjs` + `/hooks/hook-lock.cjs` | ❌ — copy manually (see above) | +| Step | Automated by `gitnexus setup`? | +| --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| `~/.cursor/mcp.json` | ✅ | +| `~/.cursor/skills/gitnexus/*` | ✅ | +| `/.cursor/hooks.json` + `/hooks/gitnexus-hook.cjs` + `/hooks/hook-lock.cjs` + `/hooks/registry-query.cjs` | ❌ — copy manually (see above) | Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global. @@ -86,6 +88,6 @@ Empty stdout means "no augmentation, continue normally" — the hook never block ## Troubleshooting -- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus both hook files at `hooks/gitnexus-hook.cjs` and `hooks/hook-lock.cjs`. Then `npx gitnexus list` to confirm the project is indexed. +- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus the hook files at `hooks/gitnexus-hook.cjs`, `hooks/hook-lock.cjs`, and `hooks/registry-query.cjs`. Then `npx gitnexus list` to confirm the project is indexed. - **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency. - **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload. diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs index 564384f83..ffddd163e 100644 --- a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -19,6 +19,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { acquireHookSlot } = require('./hook-lock.cjs'); +const { resolveHookRepo } = require('./registry-query.cjs'); function readInput() { try { @@ -29,62 +30,6 @@ function readInput() { } } -function isGlobalRegistryDir(candidate) { - if ( - fs.existsSync(path.join(candidate, 'gitnexus.json')) || - fs.existsSync(path.join(candidate, 'meta.json')) - ) { - return false; - } - return ( - fs.existsSync(path.join(candidate, 'registry.json')) || - fs.existsSync(path.join(candidate, 'repos')) - ); -} - -function walkForGitNexusDir(startDir) { - let dir = startDir; - for (let i = 0; i < 5; i++) { - const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) { - if (!isGlobalRegistryDir(candidate)) return candidate; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return null; -} - -function findCanonicalRepoRoot(cwd) { - try { - const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { - encoding: 'utf-8', - timeout: 2000, - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.error || result.status !== 0) return null; - const commonDir = (result.stdout || '').trim(); - if (!commonDir || !path.isAbsolute(commonDir)) return null; - return path.dirname(commonDir); - } catch { - return null; - } -} - -function findGitNexusDir(startDir) { - const cwd = startDir || process.cwd(); - const fromCwd = walkForGitNexusDir(cwd); - if (fromCwd) return fromCwd; - const canonicalRoot = findCanonicalRepoRoot(cwd); - if (canonicalRoot && canonicalRoot !== cwd) { - return walkForGitNexusDir(canonicalRoot); - } - return null; -} - function tokenizeShellWords(command) { const tokens = []; let current = ''; @@ -431,16 +376,19 @@ function main() { } const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - const gitNexusDir = findGitNexusDir(cwd); - if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; - const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; - const release = acquireHookSlot(gitNexusDir); + // Registry row first (persisted external storagePath wins). Local owned + // `.gitnexus` is only the fallback when no matching registry row exists. + const repo = resolveHookRepo(cwd); + if (!repo) return; + const storagePath = repo.storagePath; + + const release = acquireHookSlot(storagePath); if (!release) { // Normal skip path: all per-repo hook slots are held by concurrent // sessions. Stays silent by default; surfaced only under the cursor diff --git a/gitnexus-cursor-integration/hooks/registry-query.cjs b/gitnexus-cursor-integration/hooks/registry-query.cjs new file mode 100644 index 000000000..649b363fe --- /dev/null +++ b/gitnexus-cursor-integration/hooks/registry-query.cjs @@ -0,0 +1,410 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { createHash } = require('crypto'); +const { spawnSync } = require('child_process'); + +// Hooks are copied into editor-specific directories and run without the +// package's TypeScript modules. Keep their on-disk names centralized here. +const GITNEXUS_DIR = '.gitnexus'; +const INDEX_METADATA_FILE = 'gitnexus.json'; +const LEGACY_METADATA_FILE = 'meta.json'; +const LBUG_DIRECTORY = 'lbug'; +const BRANCHES_DIRECTORY = 'branches'; +const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH'; +const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT'; +const STORAGE_SLOT_HASH_LENGTH = 12; +const LOCAL_OWNED_PARENT_HOPS = 5; + +function stripWindowsLongPathPrefix(p) { + if (process.platform !== 'win32') return p; + if (/^\\\\\?\\UNC\\(?=[^\\])/i.test(p)) return `\\\\${p.slice(8)}`; + if (/^\\\\\?\\[A-Za-z]:\\/.test(p)) return p.slice(4); + return p; +} + +function canonicalize(value) { + if (typeof value !== 'string' || !value || value.includes('\0') || !path.isAbsolute(value)) + return null; + const resolved = path.resolve(value); + try { + return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved)); + } catch { + return stripWindowsLongPathPrefix(resolved); + } +} + +function samePath(left, right) { + if (left == null || right == null) return false; + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function isMissingFile(error) { + return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR'); +} + +function readMetadataFile(storagePath, filename) { + try { + const value = JSON.parse(fs.readFileSync(path.join(storagePath, filename), 'utf-8')); + return value && typeof value === 'object' && !Array.isArray(value) + ? { state: 'valid', value } + : { state: 'invalid' }; + } catch (error) { + return isMissingFile(error) ? { state: 'absent' } : { state: 'invalid' }; + } +} + +function readIndexMetadata(storagePath) { + const primary = readMetadataFile(storagePath, INDEX_METADATA_FILE); + if (primary.state === 'valid') return primary.value; + if (primary.state !== 'absent') return null; + + const legacy = readMetadataFile(storagePath, LEGACY_METADATA_FILE); + return legacy.state === 'valid' ? legacy.value : null; +} + +function isOwnedStorage(repoPath, storagePath, repositoryLocal, metadata) { + // Repository-local storage remains usable for metadata written before + // repoPath was recorded, but an explicit repoPath must never name another + // checkout. External storage always requires the complete ownership binding. + if (repositoryLocal && (!metadata || typeof metadata.repoPath !== 'string')) { + return true; + } + if (!metadata || typeof metadata.repoPath !== 'string') return false; + + const metadataRepoPath = canonicalize(metadata.repoPath); + const expectedRepoPath = canonicalize(repoPath); + if ( + metadataRepoPath == null || + expectedRepoPath == null || + !samePath(metadataRepoPath, expectedRepoPath) + ) { + return false; + } + if (repositoryLocal) return true; + if (typeof metadata.storagePath !== 'string') return false; + + const metadataStoragePath = canonicalize(metadata.storagePath); + const expectedStoragePath = canonicalize(storagePath); + return ( + metadataStoragePath != null && + expectedStoragePath != null && + samePath(metadataStoragePath, expectedStoragePath) + ); +} + +function ancestorPaths(cwd) { + const paths = []; + let current = canonicalize(cwd); + while (current) { + paths.push(current); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return paths; +} + +function isInsideOrEqual(child, ancestor) { + if (child == null || ancestor == null) return false; + if (samePath(child, ancestor)) return true; + const relative = path.relative(ancestor, child); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function ancestorPathsThrough(cwd, stopAt) { + const paths = []; + let current = canonicalize(cwd); + const stop = canonicalize(stopAt); + while (current) { + if (stop && !isInsideOrEqual(current, stop)) break; + paths.push(current); + if (stop && samePath(current, stop)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return paths; +} + +function currentGitBranch(cwd) { + try { + const result = spawnSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.error || result.status !== 0) return null; + const branch = String(result.stdout || '').trim(); + return branch || null; + } catch { + return null; + } +} + +function registryPathsForCwd(cwd) { + const fallbackPaths = ancestorPaths(cwd); + if (fallbackPaths.length === 0) return { repoPaths: [], branch: null }; + try { + const result = spawnSync( + 'git', + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + if (result.error || result.status !== 0) return { repoPaths: fallbackPaths, branch: null }; + + const [worktreeRoot, commonDir] = String(result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (!worktreeRoot || !path.isAbsolute(worktreeRoot)) { + return { repoPaths: fallbackPaths, branch: null }; + } + + // Keep ancestor paths of cwd that stay inside this worktree (cwd up to + // and including show-toplevel) so a --skip-git subdirectory index can + // win via longest-match. Do not walk ancestors outside the worktree — + // that would re-attribute a parent index to a nested git checkout. + const repoPaths = ancestorPathsThrough(cwd, worktreeRoot); + const worktreeCanon = canonicalize(worktreeRoot); + if (worktreeCanon && !repoPaths.some((repoPath) => samePath(repoPath, worktreeCanon))) { + repoPaths.push(worktreeCanon); + } + + // Linked worktrees share the canonical repo's git dir. Include that + // parent so the registered main checkout is still discoverable, but do + // not walk any further outside this worktree. + if (commonDir) { + const commonParent = canonicalize(path.dirname(commonDir)); + if ( + commonParent && + worktreeCanon && + !samePath(commonParent, worktreeCanon) && + !repoPaths.some((repoPath) => samePath(repoPath, commonParent)) + ) { + repoPaths.push(commonParent); + } + } + return { + repoPaths, + branch: currentGitBranch(cwd), + }; + } catch { + return { repoPaths: fallbackPaths, branch: null }; + } +} + +function branchSlug(rawRef) { + const sanitized = rawRef.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_'); + const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i; + const safe = + !sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized) + ? 'unknown' + : sanitized; + const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8); + return `${safe}-${hash}`; +} + +// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly +// (sanitize + sha256 of the canonical repo path, 12-hex suffix). +function sanitizeSlotBasename(value) { + // Cap first, then walk the tail once — same order as + // gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS). + const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); + let end = sanitized.length; + while (end > 0) { + const code = sanitized.charCodeAt(end - 1); + if (code !== 0x20 && code !== 0x2e) break; + end--; + } + const candidate = sanitized.slice(0, end) || 'repository'; + return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) + ? `repository-${candidate}` + : candidate; +} + +function storageSlotName(repoPath) { + const canonical = canonicalize(repoPath); + if (!canonical) return null; + const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical; + const basename = sanitizeSlotBasename(path.basename(canonical)); + const digest = createHash('sha256') + .update(identity) + .digest('hex') + .slice(0, STORAGE_SLOT_HASH_LENGTH); + return `${basename}-${digest}`; +} + +function envOverridesStorage() { + const envPath = process.env[STORAGE_PATH_ENV]; + const envRoot = process.env[STORAGE_ROOT_ENV]; + return ( + (typeof envPath === 'string' && envPath.length > 0) || + (typeof envRoot === 'string' && envRoot.length > 0) + ); +} + +function resolveEntryStoragePath(entry) { + const envPath = process.env[STORAGE_PATH_ENV]; + if ( + typeof envPath === 'string' && + envPath.length > 0 && + !envPath.includes('\0') && + path.isAbsolute(envPath) + ) { + const resolved = path.resolve(envPath); + if (path.isAbsolute(resolved)) return resolved; + } + + const envRoot = process.env[STORAGE_ROOT_ENV]; + if ( + typeof envRoot === 'string' && + envRoot.length > 0 && + !envRoot.includes('\0') && + path.isAbsolute(envRoot) + ) { + const root = path.resolve(envRoot); + const slot = storageSlotName(entry.path); + if (slot) { + const storagePath = path.join(root, slot); + if (samePath(path.dirname(storagePath), root)) return storagePath; + } + } + + if (entry.storagePath !== undefined) { + if ( + typeof entry.storagePath !== 'string' || + !entry.storagePath || + entry.storagePath.includes('\0') || + !path.isAbsolute(entry.storagePath) + ) { + return null; + } + return path.resolve(entry.storagePath); + } + return path.resolve(path.join(entry.path, GITNEXUS_DIR)); +} + +function hasLocalIndexSignal(storagePath) { + try { + return ( + fs.existsSync(path.join(storagePath, INDEX_METADATA_FILE)) || + fs.existsSync(path.join(storagePath, LBUG_DIRECTORY)) + ); + } catch { + return false; + } +} + +function findLocalOwnedRepo(cwd) { + // Environment storage overrides win; a leftover repo-local .gitnexus must + // not skip the registry scan that applies STORAGE_PATH / STORAGE_ROOT. + if (envOverridesStorage()) return null; + const { repoPaths, branch } = registryPathsForCwd(cwd); + let current = canonicalize(cwd); + for (let hops = 0; hops <= LOCAL_OWNED_PARENT_HOPS && current; hops++) { + const storagePath = path.join(current, GITNEXUS_DIR); + if (hasLocalIndexSignal(storagePath)) { + const metadata = readIndexMetadata(storagePath); + if (isOwnedStorage(current, storagePath, true, metadata)) { + const branchDir = + branch != null ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) : null; + const indexDir = branchDir && hasLocalIndexSignal(branchDir) ? branchDir : storagePath; + return { + path: current, + storagePath, + lbugPath: path.join(indexDir, LBUG_DIRECTORY), + metadata: indexDir === storagePath ? metadata : readIndexMetadata(indexDir), + }; + } + } + const parent = path.dirname(current); + if (parent === current) break; + // Stay inside this checkout. Registered lookup already stops at + // `--show-toplevel`; walking raw parents would adopt `/outer/.gitnexus` + // from `/outer/nested-repo`. + if (repoPaths.length > 0 && !repoPaths.some((repoPath) => samePath(repoPath, parent))) { + break; + } + current = parent; + } + return null; +} + +function findRegisteredRepo(cwd) { + const { repoPaths, branch } = registryPathsForCwd(cwd); + if (repoPaths.length === 0) return null; + + const home = process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'); + let entries; + try { + entries = JSON.parse(fs.readFileSync(path.join(home, 'registry.json'), 'utf-8')); + } catch { + return null; + } + if (!Array.isArray(entries)) return null; + + let best = null; + let bestLen = -1; + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + if (typeof entry.path !== 'string') continue; + if (entry.path.includes('\0') || !path.isAbsolute(entry.path)) continue; + const registeredPath = canonicalize(entry.path); + if (!registeredPath || !repoPaths.some((repoPath) => samePath(repoPath, registeredPath))) { + continue; + } + const storagePath = resolveEntryStoragePath(entry); + if (!storagePath) continue; + const repositoryLocal = samePath( + canonicalize(path.join(entry.path, GITNEXUS_DIR)), + canonicalize(storagePath), + ); + const ownershipMetadata = readIndexMetadata(storagePath); + if (!isOwnedStorage(entry.path, storagePath, repositoryLocal, ownershipMetadata)) continue; + const branchIsIndexed = + branch && + Array.isArray(entry.branches) && + entry.branches.some((summary) => summary && summary.branch === branch); + const indexDir = branchIsIndexed + ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) + : storagePath; + if (registeredPath.length > bestLen) { + bestLen = registeredPath.length; + best = { + path: entry.path, + storagePath, + lbugPath: path.join(indexDir, LBUG_DIRECTORY), + metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata, + }; + } + } + return best; +} + +/** Registry row wins (including persisted external storagePath); local owned is fallback. */ +function resolveHookRepo(cwd) { + return findRegisteredRepo(cwd) || findLocalOwnedRepo(cwd); +} + +module.exports = { + findRegisteredRepo, + findLocalOwnedRepo, + resolveHookRepo, + INDEX_METADATA_FILE, + LEGACY_METADATA_FILE, + LBUG_DIRECTORY, +}; diff --git a/gitnexus-web/src/components/CodeReferencesPanel.tsx b/gitnexus-web/src/components/CodeReferencesPanel.tsx index 5818614f9..3fb6e6461 100644 --- a/gitnexus-web/src/components/CodeReferencesPanel.tsx +++ b/gitnexus-web/src/components/CodeReferencesPanel.tsx @@ -16,7 +16,7 @@ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { useAppState } from '../hooks/useAppState'; import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared'; import { NODE_COLORS } from '../lib/constants'; -import { readFile, type ReadFileResult } from '../services/backend-client'; +import { BackendError, readFile, type ReadFileResult } from '../services/backend-client'; import { useTranslation } from 'react-i18next'; const getSyntaxLanguage = (filePath: string | undefined): string => { @@ -205,6 +205,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = const CONTEXT_LINES = 50; // lines of context above and below the symbol const [fileResult, setFileResult] = useState(null); + const [sourceUnavailable, setSourceUnavailable] = useState(false); const [isLoadingFile, setIsLoadingFile] = useState(false); const selectedViewerRef = useRef(null); @@ -214,12 +215,14 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = useEffect(() => { if (!selectedFilePath) { setFileResult(null); + setSourceUnavailable(false); return; } let cancelled = false; setIsLoadingFile(true); setFileResult(null); + setSourceUnavailable(false); // Determine read range: full file for File nodes, buffered for symbols const startLine = selectedNode?.properties?.startLine as number | undefined; @@ -242,9 +245,12 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = setIsLoadingFile(false); } }) - .catch(() => { + .catch((error) => { if (!cancelled) { setFileResult(null); + setSourceUnavailable( + error instanceof BackendError && error.code === 'source_unavailable', + ); setIsLoadingFile(false); } }); @@ -426,11 +432,11 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = ) : (
- {selectedIsFile ? ( - <>{t('graph:codePanel.codeNotAvailable', { path: selectedFilePath })} - ) : ( - <>{t('graph:codePanel.selectFile')} - )} + {sourceUnavailable + ? t('graph:codePanel.sourceUnavailable') + : selectedIsFile + ? t('graph:codePanel.codeNotAvailable', { path: selectedFilePath }) + : t('graph:codePanel.selectFile')}
)} diff --git a/gitnexus-web/src/locales/en/graph.json b/gitnexus-web/src/locales/en/graph.json index 72c883456..cdb84aedd 100644 --- a/gitnexus-web/src/locales/en/graph.json +++ b/gitnexus-web/src/locales/en/graph.json @@ -110,7 +110,8 @@ "references_other": "{{count}} references", "lines_one": "{{count}} line", "lines_other": "{{count}} lines", - "codeNotAvailable": "Code not available in memory for {{path}}" + "codeNotAvailable": "Code not available in memory for {{path}}", + "sourceUnavailable": "Full source is unavailable for this index." }, "canvas": { "viewModes": { diff --git a/gitnexus-web/src/locales/zh-CN/graph.json b/gitnexus-web/src/locales/zh-CN/graph.json index 6dba980b7..f1e9e2afc 100644 --- a/gitnexus-web/src/locales/zh-CN/graph.json +++ b/gitnexus-web/src/locales/zh-CN/graph.json @@ -110,7 +110,8 @@ "references_other": "{{count}} 条引用", "lines_one": "{{count}} 行", "lines_other": "{{count}} 行", - "codeNotAvailable": "内存中没有 {{path}} 的代码内容" + "codeNotAvailable": "内存中没有 {{path}} 的代码内容", + "sourceUnavailable": "此索引无法提供完整源码。" }, "canvas": { "viewModes": { diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index 592e6b16c..c96a0f596 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -119,6 +119,7 @@ export class BackendError extends Error { | 'server' | 'client' | 'not_found' + | 'source_unavailable' | 'timeout' | 'rate_limited' // The write-route same-host Origin guard rejected this request (HTTP 403 @@ -549,22 +550,22 @@ const assertOk = async (response: Response): Promise => { // Response body was not JSON } - const code = - response.status === 404 - ? 'not_found' - : response.status === 429 - ? 'rate_limited' - : // The public edge's token gate returns 401 with this discriminator; - // surface it as a distinct code so the UI can prompt for the token. - bodyCode === 'unauthorized' - ? 'unauthorized' - : // The write-route Origin guard returns 403 with this discriminator; - // surface it as a distinct code so the UI can give actionable guidance. - bodyCode === 'origin_not_allowed' - ? 'origin_blocked' - : response.status >= 400 && response.status < 500 - ? 'client' - : 'server'; + let code: ConstructorParameters[2] = 'server'; + if (bodyCode === 'source-unavailable') { + code = 'source_unavailable'; + } else if (response.status === 404) { + code = 'not_found'; + } else if (response.status === 429) { + code = 'rate_limited'; + } else if (bodyCode === 'unauthorized') { + // Public-edge token gate: HTTP 401 with this discriminator. + code = 'unauthorized'; + } else if (bodyCode === 'origin_not_allowed') { + // Write-route Origin guard: HTTP 403 with this discriminator. + code = 'origin_blocked'; + } else if (response.status >= 400 && response.status < 500) { + code = 'client'; + } // Retry-After is the standard HTTP signal for when the client may try again. // express-rate-limit emits it on 429 with seconds (integer) or HTTP-date. diff --git a/gitnexus-web/test/unit/code-references-panel.test.tsx b/gitnexus-web/test/unit/code-references-panel.test.tsx index e5c6b3e88..ada2a8e9d 100644 --- a/gitnexus-web/test/unit/code-references-panel.test.tsx +++ b/gitnexus-web/test/unit/code-references-panel.test.tsx @@ -1,9 +1,9 @@ -import { render } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReactNode } from 'react'; import type { GraphNode } from 'gitnexus-shared'; import { CodeReferencesPanel } from '../../src/components/CodeReferencesPanel'; -import { readFile } from '../../src/services/backend-client'; +import { BackendError, readFile } from '../../src/services/backend-client'; const fileNode: GraphNode = { id: 'File:src/foo.ts', @@ -31,6 +31,15 @@ vi.mock('../../src/hooks/useAppState', () => ({ vi.mock('../../src/services/backend-client', () => ({ readFile: vi.fn(), + BackendError: class BackendError extends Error { + constructor( + message: string, + _status: number, + public readonly code: string, + ) { + super(message); + } + }, })); vi.mock('react-syntax-highlighter', () => ({ @@ -70,4 +79,16 @@ describe('CodeReferencesPanel repo identity (#2420)', () => { expect(readFile).toHaveBeenCalledWith('src/foo.ts', { repo: 'reels' }); }); + + it('renders the dedicated source-unavailable state for retained indexes without a checkout', async () => { + vi.mocked(readFile).mockRejectedValue( + new BackendError('source unavailable', 410, 'source_unavailable'), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('graph:codePanel.sourceUnavailable')).toBeInTheDocument(); + }); + }); }); diff --git a/gitnexus/README.md b/gitnexus/README.md index f3f870641..95b53cdcf 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -161,7 +161,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas 5. **Processes** — Traces execution flows from entry points through call chains 6. **Search** — Builds hybrid search indexes for fast retrieval -The result is a **LadybugDB graph database** stored locally in `.gitnexus/` with full-text search and semantic embeddings. +The result is a **LadybugDB graph database** stored locally in `.gitnexus/` by default, with full-text search and semantic embeddings. ### Experimental community detection engine @@ -677,6 +677,9 @@ Configure the behavior with these environment variables: | `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. | | `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. | | `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. | +| `GITNEXUS_STORAGE_PATH` | absolute, non-empty directory | unset (repo-local) | Complete external index directory. This preserves the existing configuration semantics and takes precedence over `GITNEXUS_STORAGE_ROOT` when both are set. | +| `GITNEXUS_STORAGE_ROOT` | absolute, non-empty directory | unset (repo-local) | Absolute root directory for external indexes. GitNexus creates an isolated `-/` slot beneath it for each repository, then registers the resolved slot so `status`, MCP, and `serve` can reopen it later. | +| `GITNEXUS_CONTENT_RETENTION` | `full`, `symbol`, `none` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. | | `GITNEXUS_STREAM_GRAPH_EMIT` | `0`, `1` | `1` (on) | **On by default** on a full rebuild (`--force`); incremental runs ignore it. Holds structural relationships (CALLS, IMPORTS, ACCESSES, CONTAINS, ...) as CSV-on-disk plus compact in-memory columns instead of as objects in three overlapping indexes, cutting peak in-memory graph heap by ~1.4x at no measurable CPU cost (measured A/B on a synthetic 400k-node / 1.08M-edge graph: 819 MB -> 584 MB, iteration at parity, scaling verified linear from 100k to 800k nodes, with every edge still visible through the graph interface; no end-to-end measurement on a real repository yet). Nothing is traded away — community detection, process extraction, PDG taint summaries and the local-symbol pruner all read a complete relationship set and behave identically. Set to `0` only to bisect a suspected streaming-related fault. | | `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` is the supported default. `icebug` and `auto` are **experimental** and currently behave identically: both try the optional `@ladybugmem/icebug` native Leiden over a CSR export and fall back to Graphology if it is not installed, cannot load, or lacks the deterministic thread/seed controls. Experimental engines partition differently, so community IDs are not comparable across engines. | | `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | @@ -882,7 +885,7 @@ only — the hook's structured stdout (the JSON the agent consumes) is unaffecte - All processing happens locally on your machine - No code is sent to any server -- Index stored in `.gitnexus/` inside your repo (gitignored) +- Index stored in `.gitnexus/` inside your repo by default (gitignored), in the complete external directory selected by `GITNEXUS_STORAGE_PATH`, or in a repository-specific slot beneath `GITNEXUS_STORAGE_ROOT` - Global registry at `~/.gitnexus/` stores only paths and metadata ## Web UI diff --git a/gitnexus/bench/mcp-tools-list/measure.mjs b/gitnexus/bench/mcp-tools-list/measure.mjs index 39f2c872d..e4f359b0a 100644 --- a/gitnexus/bench/mcp-tools-list/measure.mjs +++ b/gitnexus/bench/mcp-tools-list/measure.mjs @@ -131,7 +131,11 @@ function setupFixture() { lastCommit: git(repoPath, ['rev-parse', 'HEAD']), stats: { files: 1, nodes: 1, edges: 0, communities: 0, processes: 0 }, }); - writeFileSync(path.join(storagePath, 'gitnexus.json'), '{}\n'); + mkdirSync(path.join(storagePath, 'lbug'), { recursive: true }); + writeFileSync( + path.join(storagePath, 'gitnexus.json'), + `${JSON.stringify({ repoPath, storagePath })}\n`, + ); } writeFileSync(path.join(HOME, 'registry.json'), `${JSON.stringify(entries, null, 2)}\n`); writeFileSync(marker, `${N}\n`); diff --git a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs index 630195087..7dff39d7d 100755 --- a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs +++ b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs @@ -29,6 +29,12 @@ const { resolveUnixGuardTimeout, } = require('./hook-db-lock-probe.cjs'); const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs'); +const { resolveHookRepo } = (() => { + // Installed copies get helpers next to this adapter. The in-tree source + // tree only ships the adapter, so fall back to the Claude helper copies. + const local = path.join(__dirname, 'registry-query.cjs'); + return fs.existsSync(local) ? require(local) : require('../claude/registry-query.cjs'); +})(); function readInput() { try { @@ -39,81 +45,8 @@ function readInput() { } } -function isGlobalRegistryDir(candidate) { - if ( - fs.existsSync(path.join(candidate, 'gitnexus.json')) || - fs.existsSync(path.join(candidate, 'meta.json')) - ) { - return false; - } - return ( - fs.existsSync(path.join(candidate, 'registry.json')) || - fs.existsSync(path.join(candidate, 'repos')) - ); -} - -/** - * Read the index metadata file, preferring `gitnexus.json` (current format) - * and falling back to the legacy `meta.json` mirror. Returns `null` if - * neither exists or parses. - */ -function readIndexMeta(gitNexusDir) { - try { - return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'gitnexus.json'), 'utf-8')); - } catch { - try { - return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); - } catch { - return null; - } - } -} - -function walkForGitNexusDir(startDir) { - let dir = startDir; - for (let i = 0; i < 5; i++) { - const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) { - if (!isGlobalRegistryDir(candidate)) return candidate; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return null; -} - -function findCanonicalRepoRoot(cwd) { - try { - const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { - encoding: 'utf-8', - timeout: 2000, - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.error || result.status !== 0) return null; - const commonDir = (result.stdout || '').trim(); - if (!commonDir || !path.isAbsolute(commonDir)) return null; - return path.dirname(commonDir); - } catch { - return null; - } -} - -function findGitNexusDir(startDir) { - const cwd = startDir || process.cwd(); - const fromCwd = walkForGitNexusDir(cwd); - if (fromCwd) return fromCwd; - const canonicalRoot = findCanonicalRepoRoot(cwd); - if (canonicalRoot && canonicalRoot !== cwd) { - return walkForGitNexusDir(canonicalRoot); - } - return null; -} - -function hasGitNexusServerOwner(gitNexusDir) { - return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid); +function hasGitNexusServerOwner(lbugPath) { + return hasGitNexusDbLockedByGitNexusServer(lbugPath, process.pid); } /** @@ -355,35 +288,39 @@ function toolSucceeded(toolResponse) { function buildAfterToolContext(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return null; - const gitNexusDir = findGitNexusDir(cwd); - if (!gitNexusDir) return null; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; const toolResponse = input.tool_response || {}; + const succeeded = toolSucceeded(toolResponse); + const pattern = succeeded ? extractPattern(toolName, toolInput) : null; + const command = toolName === 'run_shell_command' ? toolInput.command || '' : ''; + const gitMutation = + succeeded && /\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command); + // Cheap tool-result guards first. Registry I/O is only for search augment + // or a git mutation that might need a stale-index hint. + if (!pattern && !gitMutation) return null; + + const repo = resolveHookRepo(cwd); + if (!repo) return null; + const storagePath = repo.storagePath; const parts = []; - if (toolSucceeded(toolResponse)) { - const pattern = extractPattern(toolName, toolInput); - if (pattern) { - const augmentText = runAugment(gitNexusDir, cwd, pattern); - if (augmentText) parts.push(augmentText); - } + if (pattern) { + const augmentText = runAugment(storagePath, repo.lbugPath, cwd, pattern); + if (augmentText) parts.push(augmentText); } - if (toolName === 'run_shell_command' && toolSucceeded(toolResponse)) { - const command = toolInput.command || ''; - if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) { - const hint = buildStaleIndexHint(gitNexusDir, cwd); - if (hint) { - // The hint always reaches the agent via additionalContext (parts). Mirror - // it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict - // hook runners see no unexpected output on this normal path (#1913). The - // claude hook never mirrored this to stderr — this aligns the two adapters. - parts.push(hint); - if (isDebugEnabled()) { - process.stderr.write(`${hint}\n`); - } + if (gitMutation) { + const hint = buildStaleIndexHint(repo.metadata, cwd); + if (hint) { + // The hint always reaches the agent via additionalContext (parts). Mirror + // it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict + // hook runners see no unexpected output on this normal path (#1913). The + // claude hook never mirrored this to stderr — this aligns the two adapters. + parts.push(hint); + if (isDebugEnabled()) { + process.stderr.write(`${hint}\n`); } } } @@ -416,11 +353,11 @@ function buildMcpQueryHint(pattern) { * ponytail: per-repo mtime marker, shared across concurrent sessions on the same * repo; add per-session dedup only if that sharing becomes a problem. */ -function shouldEmitMcpHint(gitNexusDir) { +function shouldEmitMcpHint(storagePath) { const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS; const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw); if (!Number.isFinite(windowMs) || windowMs <= 0) return true; - const marker = path.join(gitNexusDir, '.mcp-hint-shown'); + const marker = path.join(storagePath, '.mcp-hint-shown'); try { if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false; } catch { @@ -434,14 +371,14 @@ function shouldEmitMcpHint(gitNexusDir) { return true; } -function runAugment(gitNexusDir, cwd, pattern) { +function runAugment(storagePath, lbugPath, cwd, pattern) { // Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe // itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap // as the augment, or concurrent sessions fan out unbounded probe - // subprocesses. The cheap guards (extractPattern, gitNexusDir lookup) run in + // subprocesses. The cheap guards (extractPattern, registry lookup) run in // buildAfterToolContext before this — moving the acquire any earlier would // churn slot files on tool calls that never probe. - const release = acquireHookSlot(gitNexusDir); + const release = acquireHookSlot(storagePath); if (!release) { // Normal skip path: all per-repo hook slots are held by concurrent // sessions. Stay silent for strict hook runners (issue #1913); surface @@ -452,7 +389,7 @@ function runAugment(gitNexusDir, cwd, pattern) { return ''; } try { - if (hasGitNexusServerOwner(gitNexusDir)) { + if (hasGitNexusServerOwner(lbugPath)) { // #2396: the MCP server holds the DB write lock, so a competing CLI // `augment` would only contend on it (LadybugDB is single-writer). The // session has the GitNexus MCP tools live — route the augmentation to the @@ -462,7 +399,7 @@ function runAugment(gitNexusDir, cwd, pattern) { if (isDebugEnabled()) { process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n'); } - return shouldEmitMcpHint(gitNexusDir) ? buildMcpQueryHint(pattern) : ''; + return shouldEmitMcpHint(storagePath) ? buildMcpQueryHint(pattern) : ''; } const cliPath = resolveCliPath(); const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000); @@ -477,7 +414,7 @@ function runAugment(gitNexusDir, cwd, pattern) { return ''; } -function buildStaleIndexHint(gitNexusDir, cwd) { +function buildStaleIndexHint(meta, cwd) { let currentHead = ''; try { const headResult = spawnSync('git', ['rev-parse', 'HEAD'], { @@ -495,7 +432,6 @@ function buildStaleIndexHint(gitNexusDir, cwd) { let lastCommit = ''; let hadEmbeddings = false; - const meta = readIndexMeta(gitNexusDir); if (meta) { lastCommit = meta.lastCommit || ''; hadEmbeddings = meta.stats && meta.stats.embeddings > 0; diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 1b75ed17d..e9bf44421 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -20,6 +20,7 @@ const { resolveUnixGuardTimeout, } = require('./hook-db-lock-probe.cjs'); const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs'); +const { resolveHookRepo } = require('./registry-query.cjs'); /** * Read JSON input from stdin synchronously. @@ -33,106 +34,8 @@ function readInput() { } } -/** - * Find the .gitnexus directory by walking up from startDir. - * Returns the path to .gitnexus/ or null if not found. - */ -function isGlobalRegistryDir(candidate) { - if ( - fs.existsSync(path.join(candidate, 'gitnexus.json')) || - fs.existsSync(path.join(candidate, 'meta.json')) - ) { - return false; - } - return ( - fs.existsSync(path.join(candidate, 'registry.json')) || - fs.existsSync(path.join(candidate, 'repos')) - ); -} - -/** - * Read the index metadata file, preferring `gitnexus.json` (current format) - * and falling back to the legacy `meta.json` mirror. Returns `null` if - * neither exists or parses. - */ -function readIndexMeta(gitNexusDir) { - try { - return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'gitnexus.json'), 'utf-8')); - } catch { - try { - return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); - } catch { - return null; - } - } -} - -/** - * Walk up from `startDir` looking for a non-registry `.gitnexus/` folder. - * Returns the path to `.gitnexus/` or null if not found within 5 levels. - */ -function walkForGitNexusDir(startDir) { - let dir = startDir; - for (let i = 0; i < 5; i++) { - const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) { - if (!isGlobalRegistryDir(candidate)) return candidate; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return null; -} - -/** - * Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside - * any git working tree — including a *linked* worktree created via - * `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the - * upward walk from cwd alone misses the index. Returns null when `cwd` is - * not inside a git repo or `git` is not available. - * - * Implementation: `git rev-parse --git-common-dir` resolves to the canonical - * `.git/` directory (or `.git/worktrees/...` parent) that is shared across - * all linked worktrees. The canonical repo root is its parent directory. - */ -function findCanonicalRepoRoot(cwd) { - try { - const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { - encoding: 'utf-8', - timeout: 2000, - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - if (result.error || result.status !== 0) return null; - const commonDir = (result.stdout || '').trim(); - if (!commonDir || !path.isAbsolute(commonDir)) return null; - return path.dirname(commonDir); - } catch { - return null; - } -} - -function findGitNexusDir(startDir) { - const cwd = startDir || process.cwd(); - - // Fast path: the cwd is inside the canonical repo (most common case). - const fromCwd = walkForGitNexusDir(cwd); - if (fromCwd) return fromCwd; - - // Fallback: cwd may be inside a linked git worktree whose `.gitnexus/` - // only lives in the canonical repo root. Resolve the shared git dir - // and retry from there. - const canonicalRoot = findCanonicalRepoRoot(cwd); - if (canonicalRoot && canonicalRoot !== cwd) { - return walkForGitNexusDir(canonicalRoot); - } - return null; -} - -function hasGitNexusServerOwner(gitNexusDir) { - return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid); +function hasGitNexusServerOwner(lbugPath) { + return hasGitNexusDbLockedByGitNexusServer(lbugPath, process.pid); } /** @@ -374,11 +277,11 @@ function buildMcpQueryHint(pattern) { * ponytail: per-repo mtime marker, shared across concurrent sessions on the same * repo; add per-session dedup only if that sharing becomes a problem. */ -function shouldEmitMcpHint(gitNexusDir) { +function shouldEmitMcpHint(storagePath) { const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS; const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw); if (!Number.isFinite(windowMs) || windowMs <= 0) return true; - const marker = path.join(gitNexusDir, '.mcp-hint-shown'); + const marker = path.join(storagePath, '.mcp-hint-shown'); try { if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false; } catch { @@ -398,8 +301,6 @@ function shouldEmitMcpHint(gitNexusDir) { function handlePreToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - const gitNexusDir = findGitNexusDir(cwd); - if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; @@ -409,12 +310,18 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + // Registry row first (persisted external storagePath wins). Local owned + // `.gitnexus` is only the fallback when no matching registry row exists. + const repo = resolveHookRepo(cwd); + if (!repo) return; + const storagePath = repo.storagePath; + // Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe // itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap // as the augment, or concurrent sessions fan out unbounded probe // subprocesses. Keep the acquire right after the cheap guards above — // moving it earlier would churn slot files on tool calls that never probe. - const release = acquireHookSlot(gitNexusDir); + const release = acquireHookSlot(storagePath); if (!release) { // Normal skip path: all per-repo hook slots are held by concurrent // sessions. Stay silent for strict hook runners (issue #1913); surface @@ -427,7 +334,7 @@ function handlePreToolUse(input) { let result = ''; try { - if (hasGitNexusServerOwner(gitNexusDir)) { + if (hasGitNexusServerOwner(repo.lbugPath)) { // #2396: the MCP server holds the DB write lock, so a competing CLI // `augment` would only contend on it (LadybugDB is single-writer). But the // session that triggered this hook has the GitNexus MCP tools live — route @@ -438,7 +345,7 @@ function handlePreToolUse(input) { if (isDebugEnabled()) { process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n'); } - if (shouldEmitMcpHint(gitNexusDir)) { + if (shouldEmitMcpHint(storagePath)) { result = buildMcpQueryHint(pattern); } } else { @@ -476,7 +383,7 @@ function sendHookResponse(hookEventName, message) { * Instead of spawning a full `gitnexus analyze` synchronously (which blocks * the agent for up to 120s and risks KuzuDB corruption on timeout), we do a * lightweight staleness check: compare `git rev-parse HEAD` against the - * lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the + * lastCommit stored in the registered index metadata. If they differ, notify the * agent so it can decide when to reindex. */ function handlePostToolUse(input) { @@ -492,8 +399,8 @@ function handlePostToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - const gitNexusDir = findGitNexusDir(cwd); - if (!gitNexusDir) return; + const repo = resolveHookRepo(cwd); + if (!repo) return; // Compare HEAD against last indexed commit — skip if unchanged let currentHead = ''; @@ -514,7 +421,7 @@ function handlePostToolUse(input) { let lastCommit = ''; let hadEmbeddings = false; - const meta = readIndexMeta(gitNexusDir); + const meta = repo.metadata; if (meta) { lastCommit = meta.lastCommit || ''; hadEmbeddings = meta.stats && meta.stats.embeddings > 0; diff --git a/gitnexus/hooks/claude/registry-query.cjs b/gitnexus/hooks/claude/registry-query.cjs new file mode 100644 index 000000000..649b363fe --- /dev/null +++ b/gitnexus/hooks/claude/registry-query.cjs @@ -0,0 +1,410 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { createHash } = require('crypto'); +const { spawnSync } = require('child_process'); + +// Hooks are copied into editor-specific directories and run without the +// package's TypeScript modules. Keep their on-disk names centralized here. +const GITNEXUS_DIR = '.gitnexus'; +const INDEX_METADATA_FILE = 'gitnexus.json'; +const LEGACY_METADATA_FILE = 'meta.json'; +const LBUG_DIRECTORY = 'lbug'; +const BRANCHES_DIRECTORY = 'branches'; +const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH'; +const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT'; +const STORAGE_SLOT_HASH_LENGTH = 12; +const LOCAL_OWNED_PARENT_HOPS = 5; + +function stripWindowsLongPathPrefix(p) { + if (process.platform !== 'win32') return p; + if (/^\\\\\?\\UNC\\(?=[^\\])/i.test(p)) return `\\\\${p.slice(8)}`; + if (/^\\\\\?\\[A-Za-z]:\\/.test(p)) return p.slice(4); + return p; +} + +function canonicalize(value) { + if (typeof value !== 'string' || !value || value.includes('\0') || !path.isAbsolute(value)) + return null; + const resolved = path.resolve(value); + try { + return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved)); + } catch { + return stripWindowsLongPathPrefix(resolved); + } +} + +function samePath(left, right) { + if (left == null || right == null) return false; + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function isMissingFile(error) { + return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR'); +} + +function readMetadataFile(storagePath, filename) { + try { + const value = JSON.parse(fs.readFileSync(path.join(storagePath, filename), 'utf-8')); + return value && typeof value === 'object' && !Array.isArray(value) + ? { state: 'valid', value } + : { state: 'invalid' }; + } catch (error) { + return isMissingFile(error) ? { state: 'absent' } : { state: 'invalid' }; + } +} + +function readIndexMetadata(storagePath) { + const primary = readMetadataFile(storagePath, INDEX_METADATA_FILE); + if (primary.state === 'valid') return primary.value; + if (primary.state !== 'absent') return null; + + const legacy = readMetadataFile(storagePath, LEGACY_METADATA_FILE); + return legacy.state === 'valid' ? legacy.value : null; +} + +function isOwnedStorage(repoPath, storagePath, repositoryLocal, metadata) { + // Repository-local storage remains usable for metadata written before + // repoPath was recorded, but an explicit repoPath must never name another + // checkout. External storage always requires the complete ownership binding. + if (repositoryLocal && (!metadata || typeof metadata.repoPath !== 'string')) { + return true; + } + if (!metadata || typeof metadata.repoPath !== 'string') return false; + + const metadataRepoPath = canonicalize(metadata.repoPath); + const expectedRepoPath = canonicalize(repoPath); + if ( + metadataRepoPath == null || + expectedRepoPath == null || + !samePath(metadataRepoPath, expectedRepoPath) + ) { + return false; + } + if (repositoryLocal) return true; + if (typeof metadata.storagePath !== 'string') return false; + + const metadataStoragePath = canonicalize(metadata.storagePath); + const expectedStoragePath = canonicalize(storagePath); + return ( + metadataStoragePath != null && + expectedStoragePath != null && + samePath(metadataStoragePath, expectedStoragePath) + ); +} + +function ancestorPaths(cwd) { + const paths = []; + let current = canonicalize(cwd); + while (current) { + paths.push(current); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return paths; +} + +function isInsideOrEqual(child, ancestor) { + if (child == null || ancestor == null) return false; + if (samePath(child, ancestor)) return true; + const relative = path.relative(ancestor, child); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function ancestorPathsThrough(cwd, stopAt) { + const paths = []; + let current = canonicalize(cwd); + const stop = canonicalize(stopAt); + while (current) { + if (stop && !isInsideOrEqual(current, stop)) break; + paths.push(current); + if (stop && samePath(current, stop)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return paths; +} + +function currentGitBranch(cwd) { + try { + const result = spawnSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.error || result.status !== 0) return null; + const branch = String(result.stdout || '').trim(); + return branch || null; + } catch { + return null; + } +} + +function registryPathsForCwd(cwd) { + const fallbackPaths = ancestorPaths(cwd); + if (fallbackPaths.length === 0) return { repoPaths: [], branch: null }; + try { + const result = spawnSync( + 'git', + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + if (result.error || result.status !== 0) return { repoPaths: fallbackPaths, branch: null }; + + const [worktreeRoot, commonDir] = String(result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (!worktreeRoot || !path.isAbsolute(worktreeRoot)) { + return { repoPaths: fallbackPaths, branch: null }; + } + + // Keep ancestor paths of cwd that stay inside this worktree (cwd up to + // and including show-toplevel) so a --skip-git subdirectory index can + // win via longest-match. Do not walk ancestors outside the worktree — + // that would re-attribute a parent index to a nested git checkout. + const repoPaths = ancestorPathsThrough(cwd, worktreeRoot); + const worktreeCanon = canonicalize(worktreeRoot); + if (worktreeCanon && !repoPaths.some((repoPath) => samePath(repoPath, worktreeCanon))) { + repoPaths.push(worktreeCanon); + } + + // Linked worktrees share the canonical repo's git dir. Include that + // parent so the registered main checkout is still discoverable, but do + // not walk any further outside this worktree. + if (commonDir) { + const commonParent = canonicalize(path.dirname(commonDir)); + if ( + commonParent && + worktreeCanon && + !samePath(commonParent, worktreeCanon) && + !repoPaths.some((repoPath) => samePath(repoPath, commonParent)) + ) { + repoPaths.push(commonParent); + } + } + return { + repoPaths, + branch: currentGitBranch(cwd), + }; + } catch { + return { repoPaths: fallbackPaths, branch: null }; + } +} + +function branchSlug(rawRef) { + const sanitized = rawRef.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_'); + const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i; + const safe = + !sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized) + ? 'unknown' + : sanitized; + const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8); + return `${safe}-${hash}`; +} + +// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly +// (sanitize + sha256 of the canonical repo path, 12-hex suffix). +function sanitizeSlotBasename(value) { + // Cap first, then walk the tail once — same order as + // gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS). + const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); + let end = sanitized.length; + while (end > 0) { + const code = sanitized.charCodeAt(end - 1); + if (code !== 0x20 && code !== 0x2e) break; + end--; + } + const candidate = sanitized.slice(0, end) || 'repository'; + return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) + ? `repository-${candidate}` + : candidate; +} + +function storageSlotName(repoPath) { + const canonical = canonicalize(repoPath); + if (!canonical) return null; + const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical; + const basename = sanitizeSlotBasename(path.basename(canonical)); + const digest = createHash('sha256') + .update(identity) + .digest('hex') + .slice(0, STORAGE_SLOT_HASH_LENGTH); + return `${basename}-${digest}`; +} + +function envOverridesStorage() { + const envPath = process.env[STORAGE_PATH_ENV]; + const envRoot = process.env[STORAGE_ROOT_ENV]; + return ( + (typeof envPath === 'string' && envPath.length > 0) || + (typeof envRoot === 'string' && envRoot.length > 0) + ); +} + +function resolveEntryStoragePath(entry) { + const envPath = process.env[STORAGE_PATH_ENV]; + if ( + typeof envPath === 'string' && + envPath.length > 0 && + !envPath.includes('\0') && + path.isAbsolute(envPath) + ) { + const resolved = path.resolve(envPath); + if (path.isAbsolute(resolved)) return resolved; + } + + const envRoot = process.env[STORAGE_ROOT_ENV]; + if ( + typeof envRoot === 'string' && + envRoot.length > 0 && + !envRoot.includes('\0') && + path.isAbsolute(envRoot) + ) { + const root = path.resolve(envRoot); + const slot = storageSlotName(entry.path); + if (slot) { + const storagePath = path.join(root, slot); + if (samePath(path.dirname(storagePath), root)) return storagePath; + } + } + + if (entry.storagePath !== undefined) { + if ( + typeof entry.storagePath !== 'string' || + !entry.storagePath || + entry.storagePath.includes('\0') || + !path.isAbsolute(entry.storagePath) + ) { + return null; + } + return path.resolve(entry.storagePath); + } + return path.resolve(path.join(entry.path, GITNEXUS_DIR)); +} + +function hasLocalIndexSignal(storagePath) { + try { + return ( + fs.existsSync(path.join(storagePath, INDEX_METADATA_FILE)) || + fs.existsSync(path.join(storagePath, LBUG_DIRECTORY)) + ); + } catch { + return false; + } +} + +function findLocalOwnedRepo(cwd) { + // Environment storage overrides win; a leftover repo-local .gitnexus must + // not skip the registry scan that applies STORAGE_PATH / STORAGE_ROOT. + if (envOverridesStorage()) return null; + const { repoPaths, branch } = registryPathsForCwd(cwd); + let current = canonicalize(cwd); + for (let hops = 0; hops <= LOCAL_OWNED_PARENT_HOPS && current; hops++) { + const storagePath = path.join(current, GITNEXUS_DIR); + if (hasLocalIndexSignal(storagePath)) { + const metadata = readIndexMetadata(storagePath); + if (isOwnedStorage(current, storagePath, true, metadata)) { + const branchDir = + branch != null ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) : null; + const indexDir = branchDir && hasLocalIndexSignal(branchDir) ? branchDir : storagePath; + return { + path: current, + storagePath, + lbugPath: path.join(indexDir, LBUG_DIRECTORY), + metadata: indexDir === storagePath ? metadata : readIndexMetadata(indexDir), + }; + } + } + const parent = path.dirname(current); + if (parent === current) break; + // Stay inside this checkout. Registered lookup already stops at + // `--show-toplevel`; walking raw parents would adopt `/outer/.gitnexus` + // from `/outer/nested-repo`. + if (repoPaths.length > 0 && !repoPaths.some((repoPath) => samePath(repoPath, parent))) { + break; + } + current = parent; + } + return null; +} + +function findRegisteredRepo(cwd) { + const { repoPaths, branch } = registryPathsForCwd(cwd); + if (repoPaths.length === 0) return null; + + const home = process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'); + let entries; + try { + entries = JSON.parse(fs.readFileSync(path.join(home, 'registry.json'), 'utf-8')); + } catch { + return null; + } + if (!Array.isArray(entries)) return null; + + let best = null; + let bestLen = -1; + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + if (typeof entry.path !== 'string') continue; + if (entry.path.includes('\0') || !path.isAbsolute(entry.path)) continue; + const registeredPath = canonicalize(entry.path); + if (!registeredPath || !repoPaths.some((repoPath) => samePath(repoPath, registeredPath))) { + continue; + } + const storagePath = resolveEntryStoragePath(entry); + if (!storagePath) continue; + const repositoryLocal = samePath( + canonicalize(path.join(entry.path, GITNEXUS_DIR)), + canonicalize(storagePath), + ); + const ownershipMetadata = readIndexMetadata(storagePath); + if (!isOwnedStorage(entry.path, storagePath, repositoryLocal, ownershipMetadata)) continue; + const branchIsIndexed = + branch && + Array.isArray(entry.branches) && + entry.branches.some((summary) => summary && summary.branch === branch); + const indexDir = branchIsIndexed + ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) + : storagePath; + if (registeredPath.length > bestLen) { + bestLen = registeredPath.length; + best = { + path: entry.path, + storagePath, + lbugPath: path.join(indexDir, LBUG_DIRECTORY), + metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata, + }; + } + } + return best; +} + +/** Registry row wins (including persisted external storagePath); local owned is fallback. */ +function resolveHookRepo(cwd) { + return findRegisteredRepo(cwd) || findLocalOwnedRepo(cwd); +} + +module.exports = { + findRegisteredRepo, + findLocalOwnedRepo, + resolveHookRepo, + INDEX_METADATA_FILE, + LEGACY_METADATA_FILE, + LBUG_DIRECTORY, +}; diff --git a/gitnexus/skills/gitnexus-cli.md b/gitnexus/skills/gitnexus-cli.md index 09c7af0d2..3a00ab546 100644 --- a/gitnexus/skills/gitnexus-cli.md +++ b/gitnexus/skills/gitnexus-cli.md @@ -34,6 +34,18 @@ Run from the project root. This parses all source files, builds the knowledge gr For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted. +## Index storage and retention + +Default location is `/.gitnexus/`. Override with environment variables (also documented in README): + +| Env | Effect | +| --- | ------ | +| `GITNEXUS_STORAGE_PATH` | One complete external index directory. Wins if both storage vars are set. | +| `GITNEXUS_STORAGE_ROOT` | Absolute root; GitNexus creates an isolated `-<12-hex>/` slot per repository. | +| `GITNEXUS_CONTENT_RETENTION` | `full` (default) keeps file text; `symbol` keeps snippets; `none` keeps the graph only. | + +`list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`. MCP `include_content` may still return symbol spans when retention is `symbol`. + Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index. ### status — Check index freshness diff --git a/gitnexus/src/cli/analyze-watch.ts b/gitnexus/src/cli/analyze-watch.ts index af5405242..32a4de2f1 100644 --- a/gitnexus/src/cli/analyze-watch.ts +++ b/gitnexus/src/cli/analyze-watch.ts @@ -12,6 +12,7 @@ import { import { isIndexLockGuardTimeout } from '../storage/index-lock.js'; import { getGitRoot, hasGitDir } from '../storage/git.js'; import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js'; +import { ANALYZE_STORAGE_REQUIREMENTS, requireStoragePath } from '../storage/storage-resolver.js'; import { GITNEXUS_DIR } from '../storage/repo-meta.js'; import { loadAnalyzeConfigStrict, @@ -414,6 +415,13 @@ export async function watchCommandWithRunnerIdentity( return; } const repoPath = await fs.realpath(requestedRepoPath); + try { + await requireStoragePath(repoPath, ANALYZE_STORAGE_REQUIREMENTS); + } catch (error) { + cliError(` ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } const baselineEnvironment: WatchEnvironmentBaseline = { maxFileSize: process.env.GITNEXUS_MAX_FILE_SIZE, workerTimeout: process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS, diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index ce577cd45..2849a2b96 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -29,7 +29,6 @@ import { WAL_RECOVERY_SUGGESTION, } from '../core/lbug/lbug-config.js'; import { - getStoragePaths, getGlobalRegistryPath, RegistryNameCollisionError, AnalysisNotFinalizedError, @@ -1420,7 +1419,7 @@ const analyzeCommandImpl = async ( // run can write meta.json and then fail before registerRepo(); in // that half-finalized state, runFullAnalysis returns alreadyUpToDate // on the next invocation unless we check the registry here too. - await assertAnalysisFinalized(repoPath); + await assertAnalysisFinalized(repoPath, result.storagePath); // The fast path skips context regeneration, but a changed `.gitnexusrc` // defaultBranch / `--default-branch` must still take effect. Surgically // refresh just the `base_ref` line in AGENTS.md/CLAUDE.md in place, @@ -1492,7 +1491,7 @@ const analyzeCommandImpl = async ( // success so the silent-finalize state surfaces with a non-zero // exit code and an actionable error instead of being mistaken for // a healthy index. - await assertAnalysisFinalized(repoPath); + await assertAnalysisFinalized(repoPath, result.storagePath); // Skill generation (CLI-only, uses pipeline result from analysis). // Gated so `--index-only --skills` skips community skill writes too @@ -1523,10 +1522,9 @@ const analyzeCommandImpl = async ( (count: number) => count >= 5, ).length; } - const { storagePath: sp } = getStoragePaths(repoPath); await generateAIContextFiles( repoPath, - sp, + result.storagePath, result.repoName, { files: s.files ?? 0, diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index 3f1144d73..f545f8211 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -12,11 +12,10 @@ import { findRepo, unregisterRepo, listRegisteredRepos, - assertSafeStoragePath, getStoragePaths, removeBranchIndex, - UnsafeStoragePathError, } from '../storage/repo-manager.js'; +import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js'; import { cleanParkedLbugSidecars, inspectLbugSidecars, @@ -47,14 +46,28 @@ export const cleanCommand = async (options?: { console.log(t('clean.branchNotIndexed', { branch: options.branch })); return; } - const { storagePath, lbugPath } = getStoragePaths(repo.repoPath, summary.branch); + let storagePath: string; + try { + storagePath = await requireDeletableStoragePath({ + path: repo.repoPath, + storagePath: repo.storagePath, + }); + } catch (err) { + if (err instanceof StorageDeletionError) { + logger.error(`Refusing to clean branch index: ${err.message}`); + return; + } + throw err; + } + const { lbugPath } = getStoragePaths(repo.repoPath, summary.branch, storagePath); const branchDir = path.dirname(lbugPath); - // Safety guard: the target MUST live under /.gitnexus/branches/. - // assertSafeStoragePath only validates the flat `/.gitnexus`, so this - // is a dedicated branches-sub-dir check before any destructive fs.rm. + // Safety guard: the target MUST live under the validated + // storage slot's `branches/` directory before any destructive fs.rm. const branchesRoot = path.join(storagePath, 'branches') + path.sep; if (!branchDir.startsWith(branchesRoot)) { - logger.error(`Refusing to clean branch index outside .gitnexus/branches: ${branchDir}`); + logger.error( + `Refusing to clean branch index outside the validated storage slot: ${branchDir}`, + ); return; } if (!options.force) { @@ -81,7 +94,20 @@ export const cleanCommand = async (options?: { return; } - const lbugPath = path.join(repo.storagePath, 'lbug'); + let storagePath: string; + try { + storagePath = await requireDeletableStoragePath({ + path: repo.repoPath, + storagePath: repo.storagePath, + }); + } catch (err) { + if (err instanceof StorageDeletionError) { + logger.error(`Refusing to clean sidecars: ${err.message}`); + return; + } + throw err; + } + const { lbugPath } = getStoragePaths(repo.repoPath, undefined, storagePath); const state = await inspectLbugSidecars(lbugPath); // Single roster authority (this shipping review, FIX 5): the aggregate // covers both parked-sidecar families — the timestamped missing-shadow @@ -121,45 +147,44 @@ export const cleanCommand = async (options?: { // --all flag: clean all indexed repos if (options?.all) { + const entries = await listRegisteredRepos(); if (!options?.force) { - const entries = await listRegisteredRepos(); - if (entries.length === 0) { + const deletableEntries = []; + for (const entry of entries) { + try { + await requireDeletableStoragePath(entry); + deletableEntries.push(entry); + } catch (err) { + if (err instanceof StorageDeletionError) { + logger.error(`Refusing to preview ${entry.name}: ${err.message}`); + continue; + } + throw err; + } + } + if (deletableEntries.length === 0) { console.log(t('common.notIndexed')); return; } - console.log(t('clean.deleteAll', { count: entries.length })); - for (const entry of entries) { + console.log(t('clean.deleteAll', { count: deletableEntries.length })); + for (const entry of deletableEntries) { console.log(` - ${entry.name} (${entry.path})`); } console.log(`\n${t('common.runForceConfirm')}`); return; } - const entries = await listRegisteredRepos(); for (const entry of entries) { - // Safety guard (#1003 review — @magyargergo): same rationale as - // remove.ts. `~/.gitnexus/registry.json` is user-writable, so a - // corrupted or hand-edited entry could point storagePath at the - // repo root, an empty string, or anywhere else — and - // fs.rm(recursive: true) on any of those would be catastrophic. - // Skip poisoned entries without touching disk, but keep going - // through the rest of the registry (preserves the existing - // per-repo error-tolerance semantics of `clean --all`). try { - assertSafeStoragePath(entry); + const storagePath = await requireDeletableStoragePath(entry); + await fs.rm(storagePath, { recursive: true, force: true }); + await unregisterRepo(entry.path); + console.log(t('clean.deletedRepo', { name: entry.name, storagePath })); } catch (err) { - if (err instanceof UnsafeStoragePathError) { + if (err instanceof StorageDeletionError) { logger.error(`Refusing to clean ${entry.name}: ${err.message}`); continue; } - throw err; - } - - try { - await fs.rm(entry.storagePath, { recursive: true, force: true }); - await unregisterRepo(entry.path); - console.log(t('clean.deletedRepo', { name: entry.name, storagePath: entry.storagePath })); - } catch (err) { logger.error({ err }, `Failed to delete ${entry.name}:`); } } @@ -176,18 +201,31 @@ export const cleanCommand = async (options?: { } const repoName = repo.repoPath.split(/[/\\]/).pop() || repo.repoPath; + let storagePath: string; + try { + storagePath = await requireDeletableStoragePath({ + path: repo.repoPath, + storagePath: repo.storagePath, + }); + } catch (err) { + if (err instanceof StorageDeletionError) { + logger.error(`Refusing to clean ${repoName}: ${err.message}`); + return; + } + throw err; + } if (!options?.force) { console.log(t('clean.deleteCurrent', { repoName })); - console.log(` ${t('common.path')}: ${repo.storagePath}`); + console.log(` ${t('common.path')}: ${storagePath}`); console.log(`\n${t('common.runForceConfirm')}`); return; } try { - await fs.rm(repo.storagePath, { recursive: true, force: true }); + await fs.rm(storagePath, { recursive: true, force: true }); await unregisterRepo(repo.repoPath); - console.log(t('common.deleted', { target: repo.storagePath })); + console.log(t('common.deleted', { target: storagePath })); } catch (err) { logger.error({ err }, 'Failed to delete:'); } diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 8fe3025fd..d280b6a2d 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -358,5 +358,5 @@ export const en = { 'help.identityCache.environment': '\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.', 'help.analyze.environment': - '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).', + '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated -/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).', } as const; diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 695acd72a..97a6c2eff 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -331,5 +331,5 @@ export const zhCN = { 'help.identityCache.environment': '\n分析器身份缓存:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n 由操作员明确信任的持久缓存,用于跨进程快速查询状态。目录必须预先存在、位于 GitNexus 包/构建根目录之外,且路径中不得包含符号链接或 junction。缺少 POSIX 所有权 API 的平台默认保持故障关闭。', 'help.analyze.environment': - '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。', + '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。', } satisfies EnglishMessages; diff --git a/gitnexus/src/cli/index-repo.ts b/gitnexus/src/cli/index-repo.ts index 09888e901..08597fdfd 100644 --- a/gitnexus/src/cli/index-repo.ts +++ b/gitnexus/src/cli/index-repo.ts @@ -16,13 +16,17 @@ import path from 'path'; import fs from 'fs/promises'; import { - getStoragePaths, - INDEX_METADATA_FILE, loadMeta, + saveMeta, ensureGitNexusIgnored, registerRepo, } from '../storage/repo-manager.js'; import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js'; +import { + getIndexStorageRequirements, + requireStoragePath, + StorageRequirementError, +} from '../storage/storage-resolver.js'; export interface IndexOptions { force?: boolean; @@ -69,46 +73,39 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt return; } - const { storagePath, lbugPath } = getStoragePaths(repoPath); - - // ── Verify index exists (metadata file, legacy metadata, or restorable DB) ─ - let hasMetadataIndex = false; - let hasLegacyIndex = false; - let hasLbugIndex = false; - + let storagePath: string; try { - await fs.access(path.join(storagePath, INDEX_METADATA_FILE)); - hasMetadataIndex = true; - } catch {} - - try { - await fs.access(path.join(storagePath, 'meta.json')); - hasLegacyIndex = true; - } catch {} - - try { - await fs.access(lbugPath); - hasLbugIndex = true; - } catch {} - - if (!hasMetadataIndex && !hasLegacyIndex && !hasLbugIndex) { - console.log(` No GitNexus index found.`); - console.log(` Expected gitnexus.json, .gitnexus/meta.json, or LadybugDB at: ${storagePath}`); - console.log(' Run `gitnexus analyze` to build the index first.\n'); - process.exitCode = 1; - return; - } - - // ── Verify lbug database exists ─────────────────────────────────── - if (!hasLbugIndex) { - console.log(` Index exists but contains no LadybugDB database.`); - console.log(' Run `gitnexus analyze` to build the index.\n'); + storagePath = await requireStoragePath(repoPath, getIndexStorageRequirements(!!options?.force)); + } catch (error) { + if (!(error instanceof StorageRequirementError)) { + console.log(` ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + return; + } + const inspection = error.inspection; + if (inspection.state === 'missing' || inspection.state === 'empty') { + console.log(` No GitNexus index found.`); + console.log( + ` Expected gitnexus.json, .gitnexus/meta.json, or LadybugDB at: ${inspection.storagePath}`, + ); + console.log(' Run `gitnexus analyze` to build the index first.\n'); + } else if (inspection.state === 'unowned' && !options?.force) { + console.log(` gitnexus.json or .gitnexus/meta.json is missing.`); + console.log(' Use --force to register anyway (stats will be empty),'); + console.log(' or run `gitnexus analyze` to rebuild properly.\n'); + } else if (!inspection.hasCodeIndexDB) { + console.log(` Index exists but contains no LadybugDB database.`); + console.log(' Run `gitnexus analyze` to build the index.\n'); + } else { + console.log(` ${error.message}\n`); + } process.exitCode = 1; return; } // ── Load or reconstruct meta ────────────────────────────────────── let meta = await loadMeta(storagePath); + let reconstructedMeta = false; if (!meta) { if (!options?.force) { @@ -122,9 +119,27 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt // --force: build a minimal meta so the repo can be registered meta = { repoPath, + storagePath, lastCommit: '', indexedAt: new Date().toISOString(), }; + reconstructedMeta = true; + } + + // `index --force` is the explicit adoption path for an existing external + // database whose legacy metadata predates storagePath binding, and for a + // repository-local slot whose metadata still names another checkout. + if (options?.force) { + const adoptedRepoPath = path.resolve(repoPath); + const adoptedStoragePath = path.resolve(storagePath); + const ownershipChanged = + path.resolve(meta.repoPath) !== adoptedRepoPath || + meta.storagePath === undefined || + path.resolve(meta.storagePath) !== adoptedStoragePath; + if (ownershipChanged) { + meta = { ...meta, repoPath: adoptedRepoPath, storagePath: adoptedStoragePath }; + reconstructedMeta = true; + } } // ── Register in global registry ─────────────────────────────────── @@ -135,8 +150,11 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt if (!meta.remoteUrl && isGitRepo(repoPath)) { meta.remoteUrl = getRemoteUrl(repoPath); } - await registerRepo(repoPath, meta); - await ensureGitNexusIgnored(repoPath); + if (reconstructedMeta) { + await saveMeta(storagePath, meta); + } + await registerRepo(repoPath, meta, { storagePath }); + await ensureGitNexusIgnored(repoPath, storagePath); const projectName = path.basename(repoPath); const { stats } = meta; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 1121d0205..22bb3c0dd 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -290,7 +290,8 @@ program program .command('status') - .description('Show index status for current repo') + .description('Show index status for the current repo or a registered index') + .option('-r, --repo ', 'Registered repository alias or path (works after checkout removal)') .option('--json', 'Emit machine-readable index and analyzer provenance') .addHelpText('after', () => t('help.identityCache.environment')) .action(createLazyAction(() => import('./status.js'), 'statusCommand')); @@ -412,7 +413,10 @@ program .option('-c, --context ', 'Task context to improve ranking') .option('-g, --goal ', 'What you want to find') .option('-l, --limit ', 'Max processes to return (default: 5)') - .option('--content', 'Include full symbol source code') + .option( + '--content', + 'Include retained symbol source text (reports availability when disabled by retention)', + ) .action(createLbugLazyAction(() => import('./tool.js'), 'queryCommand')); program @@ -423,7 +427,10 @@ program .option('-u, --uid ', 'Direct symbol UID (zero-ambiguity lookup)') .option('-f, --file ', 'File path to disambiguate common names') .option('-l, --limit ', 'Max callers/callees/processes to return') - .option('--content', 'Include full symbol source code') + .option( + '--content', + 'Include retained symbol source text (reports availability when disabled by retention)', + ) .action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand')); program diff --git a/gitnexus/src/cli/publish.ts b/gitnexus/src/cli/publish.ts index 8aedc9c35..730670d4c 100644 --- a/gitnexus/src/cli/publish.ts +++ b/gitnexus/src/cli/publish.ts @@ -30,7 +30,12 @@ import { parseOwnerRepoFromRemote, } from 'gitnexus-shared'; import { getGitRoot, getRemoteOriginUrl, getCurrentCommit } from '../storage/git.js'; -import { hasIndex } from '../storage/repo-manager.js'; +import { + requireStoragePath, + STATUS_STORAGE_REQUIREMENTS, + StorageRequirementError, + isUnusableIndexInspection, +} from '../storage/storage-resolver.js'; import { cliInfo, cliError } from './cli-message.js'; export interface PublishOptions { @@ -95,9 +100,18 @@ export const publishCommand = async ( // Publishing without an index is almost always a mistake — the // registry's nightly sync would fetch a stale or missing graph file // and mark the entry `missing`. Refuse loudly with a fix-it hint. - if (!(await hasIndex(repoPath))) { + try { + await requireStoragePath(repoPath, STATUS_STORAGE_REQUIREMENTS); + } catch (error) { + if (!(error instanceof StorageRequirementError)) throw error; + const inspection = error.inspection; + if (!isUnusableIndexInspection(inspection)) { + cliError(`[understand-quickly] ${error.message}`); + process.exitCode = 1; + return; + } cliError( - `[understand-quickly] no GitNexus index found at ${repoPath}/.gitnexus.\n` + + `[understand-quickly] no usable GitNexus index found for ${repoPath}.\n` + 'Run `gitnexus analyze` first, then re-run `gitnexus publish`.', ); process.exitCode = 1; diff --git a/gitnexus/src/cli/remove.ts b/gitnexus/src/cli/remove.ts index 260c474e9..72bb92364 100644 --- a/gitnexus/src/cli/remove.ts +++ b/gitnexus/src/cli/remove.ts @@ -36,12 +36,11 @@ import { t } from './i18n/index.js'; import { readRegistry, resolveRegistryEntry, - assertSafeStoragePath, unregisterRepo, RegistryNotFoundError, RegistryAmbiguousTargetError, - UnsafeStoragePathError, } from '../storage/repo-manager.js'; +import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js'; export const removeCommand = async (target: string, options?: { force?: boolean }) => { // Read the registry snapshot once and pass it to the resolver — this @@ -80,18 +79,13 @@ export const removeCommand = async (target: string, options?: { force?: boolean return; } - // Safety guard (#1003 review — @magyargergo): refuse to proceed if - // the registry entry's `storagePath` isn't the canonical - // `/.gitnexus` subfolder. `~/.gitnexus/registry.json` is - // user-writable, so a corrupted or hand-edited entry could point - // storagePath at the repo root, an empty string (→ cwd), a parent - // dir, or anywhere else; `fs.rm(recursive: true, force: true)` on - // any of those would be a runtime disaster. Bail before touching - // disk, with an actionable hint for recovering a broken registry. + // Validate immediately before deletion. `--force` skips confirmation only; + // it does not bypass the ownership and dangerous-path checks. + let storagePath: string; try { - assertSafeStoragePath(entry); + storagePath = await requireDeletableStoragePath(entry); } catch (err) { - if (err instanceof UnsafeStoragePathError) { + if (err instanceof StorageDeletionError) { cliError(t('common.error', { message: err.message })); process.exit(1); } @@ -104,7 +98,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean // orphaned — `listRegisteredRepos({ validate: true })` prunes those on // next read, so the failure is self-healing. try { - await fs.rm(entry.storagePath, { recursive: true, force: true }); + await fs.rm(storagePath, { recursive: true, force: true }); await unregisterRepo(entry.path); console.log(t('remove.removed', { name: entry.name })); console.log(` ${t('common.path')}: ${entry.path}`); diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 7ca83643c..3c74877e8 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -450,6 +450,7 @@ const HOOK_HELPERS = [ 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1', 'resolve-analyze-cmd.cjs', + 'registry-query.cjs', ] as const; // win-rm-list-json.ps1 is best-effort: it is read (not require()'d) by diff --git a/gitnexus/src/cli/status.ts b/gitnexus/src/cli/status.ts index c51a09e13..de69c513c 100644 --- a/gitnexus/src/cli/status.ts +++ b/gitnexus/src/cli/status.ts @@ -5,7 +5,22 @@ */ import path from 'path'; -import { findRepo, getStoragePaths, loadMeta, hasKuzuIndex } from '../storage/repo-manager.js'; +import { + getStoragePaths, + loadMeta, + hasKuzuIndex, + readRegistryStrict, + resolveRegistryEntry, + RegistryNotFoundError, + RegistryAmbiguousTargetError, +} from '../storage/repo-manager.js'; +import { + requireRegisteredStoragePath, + requireStoragePath, + STATUS_STORAGE_REQUIREMENTS, + StorageRequirementError, + isUnusableIndexInspection, +} from '../storage/storage-resolver.js'; import { getCurrentCommit, getCurrentBranch, @@ -20,6 +35,11 @@ import { import { getIndexIncompleteReasons } from '../core/index-freshness.js'; import { getFtsDisabledReason, FTS_DISABLED_MESSAGE } from '../core/search/fts-policy.js'; import { detectIndexContentDrift, type IndexContentDrift } from '../core/index-content-drift.js'; +import { + checkoutIsDirectory, + contentRetentionFromMeta, + isFullSourceAvailable, +} from '../core/content-retention.js'; import { t } from './i18n/index.js'; /** How many drifted paths the report names before summarizing the rest. */ @@ -82,11 +102,141 @@ const printDriftDetail = (drift: Extract } }; +const isExpectedUnindexedStatus = (error: StorageRequirementError): boolean => + isUnusableIndexInspection(error.inspection); + +const printNotIndexed = (repoPath: string, storagePath: string, json: boolean): void => { + if (json) { + console.log( + JSON.stringify({ + schemaVersion: 1, + repository: repoPath, + storagePath, + error: 'not-indexed', + }), + ); + } else { + console.log(t('status.repoNotIndexed')); + console.log(t('common.runAnalyzeShort')); + } +}; + export interface StatusOptions { json?: boolean; + /** Resolve a registered index without requiring its original checkout to remain on disk. */ + repo?: string; } export const statusCommand = async (options: StatusOptions = {}) => { + if (options.repo) { + let entry; + try { + entry = resolveRegistryEntry(await readRegistryStrict(), options.repo); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + if (err instanceof RegistryNotFoundError || err instanceof RegistryAmbiguousTargetError) { + if (options.json) { + console.log( + JSON.stringify({ schemaVersion: 1, repository: options.repo, error: 'not-indexed' }), + ); + } else { + console.log(error); + } + return; + } + throw err; + } + + let storagePath: string; + try { + storagePath = await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS); + } catch (err) { + if (!(err instanceof StorageRequirementError) || !isExpectedUnindexedStatus(err)) throw err; + if (err.inspection.state === 'owned' && !err.inspection.hasCodeIndexDB) { + const staleKuzu = await hasKuzuIndex(err.inspection.storagePath); + if (options.json) { + console.log( + JSON.stringify({ + schemaVersion: 1, + repository: entry.path, + storagePath: err.inspection.storagePath, + error: staleKuzu ? 'stale-kuzu-index' : 'not-indexed', + }), + ); + } else if (staleKuzu) { + console.log(t('status.staleKuzu')); + console.log(t('status.rebuildLadybug')); + } else { + console.log(`No usable code index at ${err.inspection.storagePath}`); + } + } else { + printNotIndexed(entry.path, err.inspection.storagePath, Boolean(options.json)); + } + return; + } + + const meta = await loadMeta(storagePath); + if (!meta) { + if (options.json) { + console.log( + JSON.stringify({ + schemaVersion: 1, + repository: entry.path, + storagePath, + error: 'not-indexed', + }), + ); + } else { + console.log(`No readable index metadata at ${storagePath}`); + } + return; + } + + const currentRunnerIdentity = resolveAnalyzerRunnerIdentity(import.meta.url); + const runnerIdentityIsCurrent = analyzerRunnerIdentitiesEqual( + meta.runnerIdentity, + currentRunnerIdentity, + ); + const incompleteReasons = getIndexIncompleteReasons(meta); + const sourceAvailable = isFullSourceAvailable( + contentRetentionFromMeta(meta), + await checkoutIsDirectory(entry.path), + ); + const payload = { + schemaVersion: 1, + repository: entry.path, + storagePath, + sourceAvailable, + index: { + indexedAt: meta.indexedAt, + commit: meta.lastCommit, + runnerIdentity: meta.runnerIdentity ?? null, + runnerIdentityStatus: runnerIdentityIsCurrent ? 'current' : 'stale-or-unknown', + incompleteReasons, + contentRetention: meta.contentRetention ?? 'full', + }, + current: sourceAvailable ? { commit: getCurrentCommit(entry.path) } : null, + // Without a checkout GitNexus can prove the index is readable, but cannot + // certify that it is current relative to source. Keep that distinction in + // the machine-readable status instead of reporting a false all-clear. + status: sourceAvailable ? 'registered' : 'source-unavailable', + }; + if (options.json) { + console.log(JSON.stringify(payload)); + } else { + console.log(`Repository: ${entry.path}`); + console.log(`Index storage: ${storagePath}`); + console.log(`Indexed: ${new Date(meta.indexedAt).toLocaleString()}`); + console.log(`Indexed commit: ${meta.lastCommit?.slice(0, 7)}`); + console.log( + sourceAvailable + ? 'Status: registered index (use status without --repo for working-tree freshness)' + : 'Status: source checkout unavailable; graph index remains queryable through the registry', + ); + } + return; + } + const cwd = process.cwd(); if (!isGitRepo(cwd)) { @@ -98,23 +248,33 @@ export const statusCommand = async (options: StatusOptions = {}) => { return; } - const repo = await findRepo(cwd); - if (!repo) { - // Check if there's a stale KuzuDB index that needs migration - const repoRoot = getGitRoot(cwd) ?? cwd; - const { storagePath } = getStoragePaths(repoRoot); - const staleKuzu = await hasKuzuIndex(storagePath); + const repoPath = getGitRoot(cwd); + if (!repoPath) { + if (options.json) { + console.log(JSON.stringify({ schemaVersion: 1, error: 'not-git-repository' })); + return; + } + console.log(t('status.notGitRepo')); + return; + } + + let storagePath: string; + try { + storagePath = await requireStoragePath(repoPath, STATUS_STORAGE_REQUIREMENTS); + } catch (err) { + if (!(err instanceof StorageRequirementError) || !isExpectedUnindexedStatus(err)) throw err; + const inspection = err.inspection; + const staleKuzu = await hasKuzuIndex(inspection.storagePath); if (options.json) { console.log( JSON.stringify({ schemaVersion: 1, - repository: repoRoot, + repository: repoPath, + storagePath: inspection.storagePath, error: staleKuzu ? 'stale-kuzu-index' : 'not-indexed', }), ); - return; - } - if (staleKuzu) { + } else if (staleKuzu) { console.log(t('status.staleKuzu')); console.log(t('status.rebuildLadybug')); } else { @@ -124,6 +284,18 @@ export const statusCommand = async (options: StatusOptions = {}) => { return; } + const meta = await loadMeta(storagePath); + if (!meta) { + printNotIndexed(repoPath, storagePath, Boolean(options.json)); + return; + } + + const repo = { + repoPath, + storagePath, + meta, + }; + const currentCommit = getCurrentCommit(repo.repoPath); const currentBranch = getCurrentBranch(repo.repoPath); @@ -135,7 +307,7 @@ export const statusCommand = async (options: StatusOptions = {}) => { let activeMeta = repo.meta; let workspaceLagsBranch = false; if (currentBranch && repo.meta.branch && currentBranch !== repo.meta.branch) { - const { metaPath } = getStoragePaths(repo.repoPath, currentBranch); + const { metaPath } = getStoragePaths(repo.repoPath, currentBranch, repo.storagePath); const branchMeta = await loadMeta(path.dirname(metaPath)); if (branchMeta) activeMeta = branchMeta; else workspaceLagsBranch = true; diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 007451ef5..e9218c7e5 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -10,12 +10,13 @@ import readline from 'readline'; import { execSync, execFileSync } from 'child_process'; import cliProgress from 'cli-progress'; import { getGitRoot, isGitRepo } from '../storage/git.js'; +import { getStoragePaths, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js'; import { - getStoragePaths, - loadMeta, - loadCLIConfig, - saveCLIConfig, -} from '../storage/repo-manager.js'; + requireStoragePath, + STATUS_STORAGE_REQUIREMENTS, + StorageRequirementError, + isUnusableIndexInspection, +} from '../storage/storage-resolver.js'; import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js'; import { MINIMAX_MODEL_IDS, @@ -183,15 +184,23 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions) } // ── Check for existing index ──────────────────────────────────────── - const { storagePath, lbugPath } = getStoragePaths(repoPath); - const meta = await loadMeta(storagePath); - - if (!meta) { - console.log(' Error: No GitNexus index found.'); + let storagePath: string; + try { + storagePath = await requireStoragePath(repoPath, STATUS_STORAGE_REQUIREMENTS); + } catch (error) { + if (!(error instanceof StorageRequirementError)) throw error; + const inspection = error.inspection; + if (!isUnusableIndexInspection(inspection)) { + console.log(` Error: ${error.message}\n`); + process.exitCode = 1; + return; + } + console.log(` Error: No GitNexus index found at ${error.inspection.storagePath}.`); console.log(' Run `gitnexus analyze` first to index this repository.\n'); process.exitCode = 1; return; } + const { lbugPath } = getStoragePaths(repoPath, undefined, storagePath); let timeoutSeconds: number | undefined; let retries: number | undefined; diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index 9e1189ad4..23e563819 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -16,6 +16,13 @@ import path from 'path'; import { listRegisteredRepos } from '../../storage/repo-manager.js'; +import { + requireRegisteredStoragePath, + STATUS_STORAGE_REQUIREMENTS, +} from '../../storage/storage-resolver.js'; +import { LBUG_DIRECTORY } from '../../storage/storage-constants.js'; +import { BRANCHES_DIR, branchSlug } from '../../storage/branch-index.js'; +import { getCurrentBranch } from '../../storage/git.js'; import { escapeCypherString } from '../lbug/cypher-escape.js'; /** @@ -44,19 +51,15 @@ async function findRepoForCwd(cwd: string): Promise<{ const repoResolved = path.resolve(entry.path); const normalizedRepo = isWindows ? repoResolved.toLowerCase() : repoResolved; - // Check if cwd is inside repo OR repo is inside cwd - // Must match at a path separator boundary to avoid false positives - // (e.g. /projects/gitnexusv2 should NOT match /projects/gitnexus) + // Exact path, or cwd inside the repo at a separator boundary. + // Parent-directory invocation must not attach to a nested registered checkout. let matched = false; if (normalizedCwd === normalizedRepo) { matched = true; } else { const repoPrefix = normalizedRepo.endsWith(sep) ? normalizedRepo : normalizedRepo + sep; - const cwdPrefix = normalizedCwd.endsWith(sep) ? normalizedCwd : normalizedCwd + sep; if (normalizedCwd.startsWith(repoPrefix)) { matched = true; - } else if (normalizedRepo.startsWith(cwdPrefix)) { - matched = true; } } @@ -68,10 +71,20 @@ async function findRepoForCwd(cwd: string): Promise<{ if (!bestMatch) return null; + const storagePath = await requireRegisteredStoragePath(bestMatch, STATUS_STORAGE_REQUIREMENTS); + const branch = getCurrentBranch(bestMatch.path); + const branchIsIndexed = + Boolean(branch) && + Array.isArray(bestMatch.branches) && + bestMatch.branches.some((summary) => summary.branch === branch); + const indexDir = + branchIsIndexed && branch + ? path.join(storagePath, BRANCHES_DIR, branchSlug(branch)) + : storagePath; return { name: bestMatch.name, - storagePath: bestMatch.storagePath, - lbugPath: path.join(bestMatch.storagePath, 'lbug'), + storagePath, + lbugPath: path.join(indexDir, LBUG_DIRECTORY), }; } catch { return null; diff --git a/gitnexus/src/core/content-retention.ts b/gitnexus/src/core/content-retention.ts new file mode 100644 index 000000000..38a81bedd --- /dev/null +++ b/gitnexus/src/core/content-retention.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs/promises'; +import type { KnowledgeGraph } from './graph/types.js'; +import { + CONTENT_RETENTION_SCHEMA_VERSION, + type ContentRetention, + type FtsProfile, + type RepoMeta, +} from '../storage/repo-meta.js'; + +export const CONTENT_RETENTION_ENV = 'GITNEXUS_CONTENT_RETENTION'; + +export const contentRetentionFromEnvironment = (): ContentRetention => { + const raw = process.env[CONTENT_RETENTION_ENV]; + if (raw === undefined || raw.trim() === '') return 'full'; + const value = raw.trim(); + if (value === 'full' || value === 'symbol' || value === 'none') return value; + throw new Error( + `Invalid ${CONTENT_RETENTION_ENV} "${raw}". Expected one of: full, symbol, none.`, + ); +}; + +export const contentRetentionFromMeta = ( + meta: Pick | null | undefined, +): ContentRetention => { + const retention = meta?.contentRetention; + if (retention === undefined || retention === 'full') return 'full'; + if (retention === 'symbol' || retention === 'none') return retention; + // Legacy metadata has no field; an explicit unknown value is corrupt and must not expose text. + return 'none'; +}; + +export const ftsProfileForContentRetention = (retention: ContentRetention): FtsProfile => { + switch (retention) { + case 'symbol': + return 'symbol-no-file-content'; + case 'none': + return 'name-only'; + default: + return 'full'; + } +}; + +/** + * Legacy metadata predates retention fields and is therefore semantically full. + * It remains incrementally readable under the default profile; explicit newer + * stamps must match exactly because an FTS/layout change requires a fresh DB. + */ +export const contentRetentionMismatch = ( + meta: Pick, + requested: ContentRetention, +): boolean => { + if (meta.contentRetention === undefined) return requested !== 'full'; + return ( + meta.contentRetention !== requested || + meta.contentRetentionSchemaVersion !== CONTENT_RETENTION_SCHEMA_VERSION || + meta.ftsProfile !== ftsProfileForContentRetention(requested) + ); +}; + +/** True when `repoPath` exists and is a directory (uploads / `--allow-non-git` included). */ +export const checkoutIsDirectory = async (repoPath: string): Promise => { + try { + return (await fs.stat(repoPath)).isDirectory(); + } catch { + return false; + } +}; + +/** Full-file HTTP/MCP source is available only for `full` retention plus a live checkout. */ +export const isFullSourceAvailable = ( + retention: ContentRetention, + checkoutIsDir: boolean, +): boolean => retention === 'full' && checkoutIsDir; + +/** Remove text that the active index profile is not allowed to persist. */ +export const applyContentRetention = (graph: KnowledgeGraph, retention: ContentRetention): void => { + if (retention === 'full') return; + + graph.forEachNode((node) => { + if (retention === 'symbol') { + if (node.label === 'File') delete node.properties.content; + // BasicBlocks hold statement source, not a symbol snippet. + if (node.label === 'BasicBlock') delete node.properties.text; + return; + } + + delete node.properties.content; + + delete node.properties.description; + if (node.label === 'BasicBlock') delete node.properties.text; + }); +}; diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 3c8c07bf2..12b10c1ec 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -1,3 +1,4 @@ +import type { Stats } from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import { createHash } from 'node:crypto'; @@ -16,6 +17,7 @@ import { recordedMatchStages, recordedRepoList } from './completeness.js'; import { closeLbugConnection, openLbugConnection, + sleep, type LbugConnectionHandle, } from '../lbug/lbug-config.js'; import { dedupeContracts, dedupeCrossLinks } from './normalization.js'; @@ -667,6 +669,47 @@ export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promi await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(persisted, null, 2)); } +const sameFileStamp = ( + left: { size: number; mtimeMs: number }, + right: { size: number; mtimeMs: number }, +): boolean => left.size === right.size && left.mtimeMs === right.mtimeMs; + +const stampMatchesStat = ( + stat: { size: number; mtimeMs: number }, + meta: Pick, +): boolean => + stat.size === meta.bridgeSize && + // Do not `Math.round`: two same-size databases written in the same + // millisecond must still fail if their filesystem mtimes differ. + stat.mtimeMs === meta.bridgeMtimeMs; + +/** + * LadybugDB can still flush WAL/shadow into the main file after `close` and + * the atomic rename. Stamping the first `stat` then loses the exact-equality + * check the moment that flush lands. + * + * Wait a quiet interval before every sample — including the first — so an + * initially-stable pair is not stamped on the same tick as close/rename. Two + * consecutive agreeing stats after that interval are treated as settled. A + * flush that arrives later than the retry budget can still miss; this is a + * best-effort wait, not a lock on the file. + */ +const BRIDGE_SETTLE_MS = 10; +const BRIDGE_SETTLE_ATTEMPTS = 10; + +const statSettledBridgeFile = async (filePath: string): Promise => { + let current: Stats | undefined; + for (let i = 0; i < BRIDGE_SETTLE_ATTEMPTS; i++) { + await sleep(BRIDGE_SETTLE_MS); + const next = await fsp.stat(filePath); + if (current && sameFileStamp(next, current)) { + return next; + } + current = next; + } + return current ?? (await fsp.stat(filePath)); +}; + /** * Does `meta` still describe the `bridge.lbug` sitting next to it? * @@ -712,7 +755,7 @@ export async function bridgeMetaMatchesFile(groupDir: string, meta: BridgeMeta): if (!stampedSize || !stampedMtime) return false; try { const stat = await fsp.stat(path.join(groupDir, 'bridge.lbug')); - return stat.size === meta.bridgeSize && stat.mtimeMs === meta.bridgeMtimeMs; + return stampMatchesStat(stat, meta); } catch { return false; } @@ -1373,7 +1416,7 @@ export async function writeBridgeUnlocked( // still belongs together (`bridgeMetaMatchesFile`). A stale meta cannot match // a freshly renamed database, and a sync that fails before the swap leaves a // matching pair untouched. - const finalStat = await fsp.stat(finalPath); + const finalStat = await statSettledBridgeFile(finalPath); await writeBridgeMeta(groupDir, { version: BRIDGE_SCHEMA_VERSION, generatedAt: new Date().toISOString(), diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index de7ce5c9e..ad3708168 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -16,6 +16,12 @@ import { RegistryAmbiguousTargetError, type RegistryEntry, } from '../../storage/repo-manager.js'; +import { + requireRegisteredStoragePath, + STATUS_STORAGE_REQUIREMENTS, +} from '../../storage/storage-resolver.js'; +import { loadMeta } from '../../storage/repo-meta.js'; +import { LBUG_DIRECTORY } from '../../storage/storage-constants.js'; import type { GroupConfig, RepoHandle, @@ -346,15 +352,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis const includeEx = new IncludeExtractor(); for (const [groupPath, regName] of Object.entries(config.repos)) { - const handle = await resolve(regName, groupPath); - if (!handle) { - missingRepos.push(groupPath); - continue; - } - resolvedRepoPaths.set(groupPath, handle.repoPath); - - const poolId = handle.id; - const lbugPath = path.join(handle.storagePath, 'lbug'); + let lbugPath = ''; // Staged per repo, not appended straight to `autoContracts`. Extractors // run in sequence and any one of them can throw; appending as we go left // a repo whose HTTP extractor succeeded and whose gRPC extractor failed @@ -363,6 +361,27 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis // output is now all-or-nothing, which is what that message describes. const repoContracts: StoredContract[] = []; try { + let handle = await resolve(regName, groupPath); + if (!handle) { + missingRepos.push(groupPath); + continue; + } + resolvedRepoPaths.set(groupPath, handle.repoPath); + + // Validate the registry-selected slot immediately before it is + // opened. A foreign or incomplete external slot is a registered but + // unreadable member, so the existing per-member degradation path + // remains the user-visible behavior. + if (!opts?.resolveRepoHandle) { + const storagePath = await requireRegisteredStoragePath( + { path: handle.repoPath, storagePath: handle.storagePath }, + STATUS_STORAGE_REQUIREMENTS, + ); + handle = { ...handle, storagePath }; + } + + const poolId = handle.id; + lbugPath = path.join(handle.storagePath, LBUG_DIRECTORY); await initLbug(poolId, lbugPath); // No pin here: contract extraction below uses `executor` while this // repo is freshly initialized and live, and completes before the next @@ -444,10 +463,9 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } - const metaPath = path.join(handle.storagePath, 'meta.json'); try { - const raw = await fs.readFile(metaPath, 'utf-8'); - const m = JSON.parse(raw) as { indexedAt?: string; lastCommit?: string }; + const m = await loadMeta(handle.storagePath); + if (!m) throw new Error('Index metadata is unavailable.'); repoSnapshots[groupPath] = { indexedAt: m.indexedAt || '', lastCommit: m.lastCommit || '', diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 1d5c680b6..5e652319c 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -18,6 +18,7 @@ import path from 'path'; import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { KnowledgeGraph } from '../graph/types.js'; import { NodeTableName, RELATION_SCHEMA } from './schema.js'; +import type { ContentRetention } from '../../storage/repo-meta.js'; import { VALID_NODE_TABLES, parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js'; import { parseTruthyEnv } from '../ingestion/utils/env.js'; import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js'; @@ -294,7 +295,16 @@ const formatFtsDescription = (description: string): string => // 0-based line invariant. Kept as a named alias to read intent at the use site. const EXACT_SYMBOL_CONTENT_LABELS = SYMBOL_NODE_LABELS; -const extractContent = async (node: GraphNode, contentCache: FileContentCache): Promise => { +const extractContent = async ( + node: GraphNode, + contentCache: FileContentCache, + contentRetention: ContentRetention, +): Promise => { + // File content is intentionally lazy-read for full indexes. Do not let this + // compatibility path recreate source text that the selected profile forbids. + if (contentRetention === 'none' || (contentRetention === 'symbol' && node.label === 'File')) { + return ''; + } const filePath = node.properties.filePath; const prepared = await contentCache.get(filePath); const content = prepared.content; @@ -476,6 +486,7 @@ export const streamAllCSVsToDisk = async ( repoPath: string, csvDir: string, onNodePhaseComplete?: (nodeFiles: Map) => void, + contentRetention: ContentRetention = 'full', ): Promise => { // Deterministic (id-sorted) node/relationship row order when enabled; // default off = today's graph-insertion order (byte-identical). @@ -626,7 +637,7 @@ export const streamAllCSVsToDisk = async ( let pending: Promise | undefined; switch (node.label) { case 'File': { - const content = await extractContent(node, contentCache); + const content = await extractContent(node, contentCache, contentRetention); pending = fileWriter.addRow( [ escapeCSVField(node.id), @@ -681,7 +692,7 @@ export const streamAllCSVsToDisk = async ( break; } case 'Method': { - const content = await extractContent(node, contentCache); + const content = await extractContent(node, contentCache, contentRetention); pending = methodWriter.addRow( [ escapeCSVField(node.id), @@ -699,7 +710,7 @@ export const streamAllCSVsToDisk = async ( break; } case 'Section': { - const content = await extractContent(node, contentCache); + const content = await extractContent(node, contentCache, contentRetention); pending = sectionWriter.addRow( [ escapeCSVField(node.id), @@ -801,7 +812,7 @@ export const streamAllCSVsToDisk = async ( // Code element nodes (Function, Class, Interface, CodeElement) const writer = codeWriterMap[node.label]; if (writer) { - const content = await extractContent(node, contentCache); + const content = await extractContent(node, contentCache, contentRetention); const row = [ escapeCSVField(node.id), escapeCSVField(node.properties.name || ''), @@ -822,7 +833,7 @@ export const streamAllCSVsToDisk = async ( // Multi-language node types (Struct, Impl, Trait, Macro, etc.) const mlWriter = multiLangWriters.get(node.label); if (mlWriter) { - const content = await extractContent(node, contentCache); + const content = await extractContent(node, contentCache, contentRetention); pending = mlWriter.addRow( [ escapeCSVField(node.id), diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 71c3dfe69..ced1c48bd 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -12,6 +12,7 @@ import { escapeCypherString } from './cypher-escape.js'; import { withConnLock } from './conn-lock.js'; import { isWalDriverActive } from './wal-driver-state.js'; import { KnowledgeGraph } from '../graph/types.js'; +import type { ContentRetention } from '../../storage/repo-meta.js'; import { NODE_TABLES, REL_TABLE_NAME, @@ -1146,6 +1147,8 @@ export const loadGraphToLbug = async ( * which holds one CSV per pair and would silently drop one of them). */ graphEmitManifest?: GraphEmitManifest, + /** Content profile for CSV emission; default preserves the historical full index. */ + contentRetention: ContentRetention = 'full', ) => { if (!conn) { throw new Error('LadybugDB not initialized. Call initLbug first.'); @@ -1226,8 +1229,8 @@ export const loadGraphToLbug = async ( let csvResult: StreamedCSVResult; try { csvResult = SERIAL - ? await streamAllCSVsToDisk(graph, repoPath, csvDir) - : await streamAllCSVsToDisk(graph, repoPath, csvDir, beginNodeCopy); + ? await streamAllCSVsToDisk(graph, repoPath, csvDir, undefined, contentRetention) + : await streamAllCSVsToDisk(graph, repoPath, csvDir, beginNodeCopy, contentRetention); } catch (emitErr) { // Relationship emit failed. In overlap mode a node COPY may be in flight — // settle it (the .catch above means this never rejects) before rethrowing so diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 30b54ece4..ce98c98ed 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -90,6 +90,13 @@ import { initialiseSearchFTSStemmer, verifySearchFTSIndexes, } from './search/fts-indexes.js'; +import { getFtsIndexes } from './search/fts-schema.js'; +import { + applyContentRetention, + contentRetentionFromEnvironment, + contentRetentionMismatch, + ftsProfileForContentRetention, +} from './content-retention.js'; import { cjkSegmentationModeMismatch, getSearchFTSCjkSegmentation, @@ -127,11 +134,19 @@ import { isRepoRegistered, cleanupOldKuzuFiles, reconcileMetadataFiles, + ensureStoragePathWritable, isMissingFilesystemError, INDEX_METADATA_FILE, + CONTENT_RETENTION_SCHEMA_VERSION, type AnalyzerRunnerIdentity, + type ContentRetention, type RepoMeta, } from '../storage/repo-manager.js'; +import { + ANALYZE_FORCE_STORAGE_REQUIREMENTS, + ANALYZE_STORAGE_REQUIREMENTS, + requireStoragePath, +} from '../storage/storage-resolver.js'; import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js'; import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, @@ -540,6 +555,8 @@ export function analyzeFailureMayHaveMutatedLiveIndex(error: unknown): boolean { export interface AnalyzeResult { repoName: string; repoPath: string; + /** The exact storage slot selected and validated for this analysis. */ + storagePath: string; stats: { files?: number; nodes?: number; @@ -1023,8 +1040,14 @@ interface WriteTarget { async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Promise { // `storagePath` is ALWAYS the flat `.gitnexus` — content-addressed caches // (parse-cache, parsedfile-store) and kuzu-migration cleanup live there and - // are shared across branches (#2106 KTD7). - const { storagePath } = getStoragePaths(repoPath); + // are shared across branches (#2106 KTD7). Always re-run requireStoragePath: + // a cached path string must not skip ownership (STORAGE_PATH can move to a + // foreign slot while the lock is waited out). `--force` may adopt a + // repository-local foreign slot; the non-force set stays ANALYZE_STORAGE. + const storagePath = await requireStoragePath( + repoPath, + options.force ? ANALYZE_FORCE_STORAGE_REQUIREMENTS : ANALYZE_STORAGE_REQUIREMENTS, + ); const repoHasGit = hasGitDir(repoPath); const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : ''; // Normalize the auto-detected branch the same way an explicit `--branch` is @@ -1045,8 +1068,10 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr ); } const branchLabel = options.branch ?? checkedOutBranch; - const placement = options.branch ? await resolveBranchPlacement(repoPath, branchLabel) : {}; - const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch); + const placement = options.branch + ? await resolveBranchPlacement(repoPath, branchLabel, storagePath) + : {}; + const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch, storagePath); return { storagePath, repoHasGit, @@ -1100,6 +1125,7 @@ export async function runFullAnalysis( } initialiseSearchFTSStemmer(); initialiseSearchFTSCjkSegmentation(); + const contentRetention = contentRetentionFromEnvironment(); // Scope the degraded-parse log throttle to this run (module-level counter // would otherwise stay saturated on a reused process). resetDegradedParseCounter(); @@ -1135,6 +1161,9 @@ export async function runFullAnalysis( // checkout) still releases the held lock via `finally` (no leak). const MAX_RELOCK = 3; for (let attempt = 0; attempt < MAX_RELOCK; attempt++) { + // Never pass the pre-lock storagePath as already-validated: requireStoragePath + // must run again under the lock so a now-foreign slot aborts (and finally + // still releases the lock). const fresh = await resolveWriteTarget(repoPath, options); if (fresh.metaDir === writeTarget.metaDir) { writeTarget = fresh; // same slot — adopt the freshly-read commit/branch/placement @@ -1160,6 +1189,7 @@ export async function runFullAnalysis( options, callbacks, writeTarget, + contentRetention, runnerIdentityAtBootstrap, ); } finally { @@ -1172,6 +1202,7 @@ async function runFullAnalysisInner( options: AnalyzeOptions, callbacks: AnalyzeCallbacks, writeTarget: WriteTarget, + contentRetention: ContentRetention, runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity, ): Promise { const ftsDisabledReason = resolveFtsDisableReason(options.skipFts); @@ -1192,6 +1223,13 @@ 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 storageWritable: Promise | undefined; + const ensureWritableStorage = (): Promise => { + storageWritable ??= ensureStoragePathWritable(storagePath); + return storageWritable; + }; + const ftsProfile = ftsProfileForContentRetention(contentRetention); + const ftsIndexes = getFtsIndexes(ftsProfile); let coldParseRebuildDir: string | undefined; // Start each analyze with a clean buffer-pool hint: any pre-pipeline DB open @@ -1205,13 +1243,13 @@ async function runFullAnalysisInner( log('Migrating from KuzuDB to LadybugDB — rebuilding index...'); } - // Keep gitnexus.json and the legacy meta.json mirror in sync (fresher - // indexedAt wins; nothing is deleted). Best-effort: loadMeta has its own - // legacy fallback, so a reconciliation failure (read-only mount, full disk) - // must never abort the analyze run — a repo that indexed fine read-only - // before the rename must keep doing so. + // Keep gitnexus.json and the legacy meta.json mirror in sync. Use the + // ownership-validated write target rather than resolving storage again from + // the registry while holding the index lock. Best-effort: loadMeta has its + // own legacy fallback, so a reconciliation failure (read-only mount, full + // disk) must never abort the analyze run. try { - await reconcileMetadataFiles(repoPath); + await reconcileMetadataFiles(repoPath, storagePath); } catch (err) { const code = (err as NodeJS.ErrnoException)?.code; log(`Metadata reconciliation failed (non-critical${code ? `, ${code}` : ''}); continuing.`); @@ -1231,7 +1269,31 @@ async function runFullAnalysisInner( ? withExplicitFtsDisablement(loadedMeta, ftsDisabledReason) : undefined; + // Claim a fresh, ownership-validated slot before the pipeline writes caches. + // A later registry-name collision or pipeline failure can otherwise leave + // cache files without metadata, which must be treated as unowned on the next + // invocation. This marker deliberately has no DB/freshness receipt, so read + // paths still reject it until a successful analyze writes the final metadata. + if (!existingMeta && !(await loadMeta(storagePath))) { + await saveMeta(storagePath, { + repoPath, + storagePath, + lastCommit: '', + indexedAt: new Date().toISOString(), + }); + } + // ── FTS-only repair path ──────────────────────────────────────────── + if ( + options.repairFts && + existingMeta && + contentRetentionMismatch(existingMeta, contentRetention) + ) { + log( + 'content retention or FTS profile changed; forcing a full rebuild before rebuilding search indexes.', + ); + options = { ...options, force: true, repairFts: false }; + } if (options.repairFts) { if (!existingMeta) { throw new Error( @@ -1281,6 +1343,7 @@ async function runFullAnalysisInner( 'Run `gitnexus analyze` (full) to rebuild from scratch.', ); } + await ensureWritableStorage(); try { await initAnalysisLbug(lbugPath); // Gate on FTS availability BEFORE touching any index. createSearchFTSIndexes @@ -1322,6 +1385,7 @@ async function runFullAnalysisInner( } progress('fts', 85, 'Repairing search indexes...'); const repairFailures = await createSearchFTSIndexes({ + indexes: ftsIndexes, onIndexStart: options.verbose ? (table, indexName) => log(`FTS: creating ${table}.${indexName}`) : undefined, @@ -1329,7 +1393,7 @@ async function runFullAnalysisInner( ? (table, indexName) => log(`FTS: ready ${table}.${indexName}`) : undefined, }); - const missing = await verifySearchFTSIndexes(executeQuery); + const missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes); if (missing.length > 0) { // #2889: name WHY each index is missing when the build itself said so. // Repair now rebuilds every table it can before reporting, so the tables @@ -1338,14 +1402,16 @@ async function runFullAnalysisInner( // only ever list "missing", never a reason. Same sentence the analyze // degrade path prints, so one failure does not read two ways. const reasons = - repairFailures.length > 0 ? ` ${summarizeFtsIndexBuildFailures(repairFailures)}.` : ''; + repairFailures.length > 0 + ? ` ${summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes)}.` + : ''; throw new Error( `FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}.${reasons} ` + 'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' + 'if that also fails, verify FTS extension availability via `gitnexus doctor`.', ); } - await ensureGitNexusIgnored(repoPath); + await ensureGitNexusIgnored(repoPath, storagePath); // #2767: stamp ONLY capabilities.fts so a long-lived MCP session's // ensureInitialized() has an explicit, correctly-scoped signal that FTS // changed — indexedAt/lastCommit/runnerIdentity/stats are copied through @@ -1387,6 +1453,7 @@ async function runFullAnalysisInner( name: options.registryName, allowDuplicateName: options.allowDuplicateName, branch: placement.branch, + storagePath, }); } return { @@ -1395,6 +1462,7 @@ async function runFullAnalysisInner( getInferredRepoName(repoPath) ?? path.basename(resolveRepoIdentityRoot(repoPath)), repoPath, + storagePath, stats: existingMeta.stats ?? {}, ftsRepairedOnly: true, }; @@ -1498,6 +1566,7 @@ async function runFullAnalysisInner( // rebuild wipe that would discard it. Park the WAL/shadow sidecars aside // now, while nothing is open, so every open in this run is replay-free. // The rebuild wipes the DB regardless, so no committed data is at stake. + await ensureWritableStorage(); const { removed, failed } = await quarantineSidecarsForDirtyRecovery(lbugPath, log); if (removed.length > 0) { log( @@ -1552,6 +1621,18 @@ async function runFullAnalysisInner( options = { ...options, force: true }; } + // Retention controls the DB's persisted text and FTS columns. Incremental + // writeback only touches changed files, so changing it in place would leave + // old source text and index pages behind. Rebuild the database instead. + if (existingMeta && contentRetentionMismatch(existingMeta, contentRetention)) { + const recorded = existingMeta.contentRetention ?? 'full (legacy)'; + log( + `content retention changed (index built with ${recorded}, this run uses ${contentRetention}); ` + + 'forcing a full rebuild so stored text and FTS indexes are recreated.', + ); + options = { ...options, force: true }; + } + // ── schema mismatch forces full rebuild (#2289 P1, #2798) ───────── // Mirrors the pdg-mode block above: an index whose tables were created from // a different DDL cannot be reconciled by an incremental top-up — a @@ -1873,6 +1954,7 @@ async function runFullAnalysisInner( name: options.registryName, allowDuplicateName: options.allowDuplicateName, branch: placement.branch, + storagePath, }); if (!placement.branch) { try { @@ -1917,7 +1999,7 @@ async function runFullAnalysisInner( // date" run must not fail over it; read-only storage — the // documented Docker :ro workflow (#1549) — degrades to a warning. try { - await adoptFlatBranchLabel(repoPath, branchLabel); + await adoptFlatBranchLabel(repoPath, branchLabel, storagePath); await saveMeta(metaDir, { ...existingMeta, branch: branchLabel }); } catch (err) { // EACCES/EPERM also arise from ownership problems and transient @@ -1938,7 +2020,7 @@ async function runFullAnalysisInner( ); } } - await ensureGitNexusIgnored(repoPath); + await ensureGitNexusIgnored(repoPath, storagePath); return { // `resolveRepoIdentityRoot` collapses worktree roots to the // canonical repo basename (#1259) but leaves arbitrary subdirs @@ -1948,6 +2030,7 @@ async function runFullAnalysisInner( getInferredRepoName(repoPath) ?? path.basename(resolveRepoIdentityRoot(repoPath)), repoPath, + storagePath, stats: existingMeta.stats ?? {}, alreadyUpToDate: true, ...(ftsDisabledReason ? { ftsSkipped: true, ftsSkipReason: ftsDisabledReason } : {}), @@ -1957,6 +2040,8 @@ async function runFullAnalysisInner( } } + await ensureWritableStorage(); + // ── Cache embeddings from existing index before rebuild ──────────── // Four modes: // --embeddings -> load cache, restore, then generate any new ones @@ -2124,8 +2209,8 @@ async function runFullAnalysisInner( 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), + // offloaded BasicBlock layer. Non-full retention profiles must keep + // BasicBlock source text in memory until the retention pass below. pdgEmitChunkSize: resolvePdgEmitChunkSize(options), // Streamed structural emit (#2680) — same full-rebuild gate as the PDG // toggle above, for the same incremental-writeback reason. @@ -2142,6 +2227,10 @@ async function runFullAnalysisInner( springActuatorPath: options.springActuatorPath, asyncApiSpecPath: options.asyncApiSpecPath, springActuatorScanExclusions, + // Streaming/chunked PDG emit must remain disabled for non-full content + // retention profiles because BasicBlock source text is stripped only + // after all semantic phases have completed. + streamPdgEmit: contentRetention === 'full' && resolveStreamPdgEmit(options), }, ); } catch (err) { @@ -2162,6 +2251,11 @@ async function runFullAnalysisInner( // ── Phase 2: LadybugDB (60–85%) ────────────────────────────────── progress('lbug', 60, 'Loading into LadybugDB...'); + // Parsing and graph construction always see the original source. Apply the + // retention boundary only after all semantic phases have completed and + // before any graph rows, FTS values, or embeddings are persisted. + applyContentRetention(pipelineResult.graph, contentRetention); + // Compute current per-file content hashes from the pipeline's File nodes. // Used both to drive the incremental DB writeback (when eligible) and to // populate meta.json.fileHashes for the next run. @@ -2978,11 +3072,19 @@ async function runFullAnalysisInner( await wipeLbugDbFiles(buildPath); await initAnalysisLbug(buildPath); walCheckpointDriver = startWalCheckpointDriver(); - await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => { - lbugMsgCount++; - const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19)); - progress('lbug', pct, msg); - }); + await loadGraphToLbug( + pipelineResult.graph, + pipelineResult.repoPath, + storagePath, + (msg) => { + lbugMsgCount++; + const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19)); + progress('lbug', pct, msg); + }, + undefined, + undefined, + contentRetention, + ); } else { // 1a. Drop every FTS index before touching a single row (#2589). // `deleteNodesForFiles` below DETACH DELETEs rows out of tables @@ -3047,7 +3149,7 @@ async function runFullAnalysisInner( const derivedSnapshot = preserveDerivedLayer ? await snapshotDerivedRelsForFiles(filesToDelete, [...tablesWithRows]) : []; - await dropSearchFTSIndexes(indexCatalogRows, incrementalFtsRebuildTables); + await dropSearchFTSIndexes(indexCatalogRows, ftsIndexes, incrementalFtsRebuildTables); // 1b. Remove the write set's existing rows — batched (#2409): one // DETACH DELETE per table per 200-file chunk. The former per-file // loop issued a count + delete per table per FILE — ~13k @@ -3147,11 +3249,19 @@ async function runFullAnalysisInner( effectiveWriteCount: effectiveWriteSet.size, deleteCount: filesToDelete.length, }); - await loadGraphToLbug(subgraph, pipelineResult.repoPath, storagePath, (msg) => { - lbugMsgCount++; - const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19)); - progress('lbug', pct, msg); - }); + await loadGraphToLbug( + subgraph, + pipelineResult.repoPath, + storagePath, + (msg) => { + lbugMsgCount++; + const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19)); + progress('lbug', pct, msg); + }, + undefined, + undefined, + contentRetention, + ); if (preserveDerivedLayer && derivedSnapshot.length > 0) { await restoreDerivedRels(derivedSnapshot); } @@ -3180,6 +3290,7 @@ async function runFullAnalysisInner( }, pipelineResult.pdgEmitManifest, pipelineResult.graphEmitManifest, + contentRetention, ); } @@ -3222,6 +3333,7 @@ async function runFullAnalysisInner( // pre-existing row (#2544/#2546) must not discard this run's otherwise- // successful graph/embeddings work — only keyword search degrades. const ftsResult = await buildSearchIndexesOrDegrade(executeQuery, { + indexes: ftsIndexes, tables: incrementalFtsRebuildTables, onIndexStart: options.verbose ? (table, indexName) => log(`FTS: creating ${table}.${indexName}`) @@ -3951,8 +4063,12 @@ async function runFullAnalysisInner( // honesty contract silently decays to "whatever interpolates". const meta: RepoMeta = { repoPath, + storagePath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), + contentRetention, + contentRetentionSchemaVersion: CONTENT_RETENTION_SCHEMA_VERSION, + ftsProfile, runnerIdentity, // Persist only normalized repo-relative exclusions, never absolute paths // or payloads. Keep them after runtime enrichment is disabled so a later @@ -4147,6 +4263,7 @@ async function runFullAnalysisInner( // primary/flat run (placement.branch === undefined) refreshes the // top-level fields (#2106). branch: placement.branch, + storagePath, }); // ── #2354: the flat workspace slot has adopted this run's branch ────── @@ -4161,7 +4278,7 @@ async function runFullAnalysisInner( // already-stamped meta label). if (!placement.branch && branchLabel) { try { - await adoptFlatBranchLabel(repoPath, branchLabel); + await adoptFlatBranchLabel(repoPath, branchLabel, storagePath); } catch (e) { log( `Warning: could not sync the workspace branch label (${(e as Error).message}); continuing.`, @@ -4170,7 +4287,7 @@ async function runFullAnalysisInner( } // Keep generated .gitnexus contents ignored without editing the user's root .gitignore. - await ensureGitNexusIgnored(repoPath); + await ensureGitNexusIgnored(repoPath, storagePath); // ── Generate AI context files (best-effort) ─────────────────────── let aggregatedClusterCount = 0; @@ -4335,6 +4452,7 @@ async function runFullAnalysisInner( return { repoName: projectName, repoPath, + storagePath, stats: meta.stats, pipelineResult, ...(graphWriteCollapsed ? { graphWriteCollapsed } : {}), diff --git a/gitnexus/src/core/search/fts-indexes.ts b/gitnexus/src/core/search/fts-indexes.ts index 8846ff348..4591923cd 100644 --- a/gitnexus/src/core/search/fts-indexes.ts +++ b/gitnexus/src/core/search/fts-indexes.ts @@ -10,7 +10,7 @@ import { import { getFtsCapability } from '../lbug/extension-loader.js'; import { FTS_DISABLED_MESSAGE, type FtsDisabledReason } from './fts-policy.js'; import { classifyExtensionLoadError } from '../lbug/extension-load-error.js'; -import { FTS_INDEXES } from './fts-schema.js'; +import { FTS_INDEXES, type FTSIndexDefinition } from './fts-schema.js'; /** * Strip filesystem paths from a LadybugDB error before it reaches the HTTP @@ -161,6 +161,7 @@ export const SUPPORTED_FTS_STEMMERS: ReadonlySet = new Set([ ]); export interface CreateSearchFTSIndexesOptions { + indexes?: readonly FTSIndexDefinition[]; onIndexStart?: (table: string, indexName: string) => void; onIndexReady?: (table: string, indexName: string) => void; /** @@ -229,9 +230,12 @@ export function getSearchFTSStemmer(): string { * THIS connection with no index created or dropped since — the same freshness * contract, and the same one-shared-`SHOW_INDEXES`-read purpose, as the gates in * `lbug-adapter.ts`. Omit it to have the sweep read the catalog itself. + * @param indexes FTS definitions for the active content-retention profile. + * @param tables When set, restrict the sweep to these node tables. */ export async function dropSearchFTSIndexes( indexRows?: IndexCatalogSnapshot, + indexes: readonly FTSIndexDefinition[] = FTS_INDEXES, tables?: ReadonlySet, ): Promise { // One catalog read for the whole sweep, decided PER CONFIGURED INDEX on @@ -253,7 +257,7 @@ export async function dropSearchFTSIndexes( // so an index left over from an older, differently-named set was never dropped // whether the sweep ran or not. const rows = await resolveGateRows(indexRows); - for (const { table, indexName } of FTS_INDEXES) { + for (const { table, indexName } of indexes) { if (tables && !tables.has(table)) continue; // Skip only what the catalog POSITIVELY proves absent. Without this, a // machine whose FTS extension cannot load, analyzing a DB that never carried @@ -330,7 +334,7 @@ export async function createSearchFTSIndexes( ): Promise { const stemmer = getSearchFTSStemmer(); const failures: FtsIndexBuildFailure[] = []; - for (const { table, indexName, properties } of FTS_INDEXES) { + for (const { table, indexName, properties } of options?.indexes ?? FTS_INDEXES) { if (options?.tables && !options.tables.has(table)) continue; options?.onIndexStart?.(table, indexName); // Drop first so the live `properties` always win. `createFTSIndex` is @@ -379,12 +383,16 @@ export async function createSearchFTSIndexes( * Anything heading for a network response has to pass it through * {@link redactPaths} first, the same rule the query-side warnings follow. */ -export const summarizeFtsIndexBuildFailures = (failures: readonly FtsIndexBuildFailure[]): string => - `FTS index build failed for ${failures.length} of ${FTS_INDEXES.length} tables: ` + +export const summarizeFtsIndexBuildFailures = ( + failures: readonly FtsIndexBuildFailure[], + indexes: readonly FTSIndexDefinition[] = FTS_INDEXES, +): string => + `FTS index build failed for ${failures.length} of ${indexes.length} tables: ` + failures.map((f) => `${f.table}.${f.indexName} (${f.error})`).join(', '); export async function verifySearchFTSIndexes( executeQuery: (cypher: string) => Promise, + indexes: readonly FTSIndexDefinition[] = FTS_INDEXES, ): Promise { // Read the catalog once and check each configured index both EXISTS and // covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX @@ -414,7 +422,7 @@ export async function verifySearchFTSIndexes( } const missing: string[] = []; - for (const { table, indexName, properties } of FTS_INDEXES) { + for (const { table, indexName, properties } of indexes) { const actual = propsByIndex.get(indexName); // Absent from the catalog, or present but not covering every expected column. if (!actual || !properties.every((p) => actual.includes(p))) { @@ -522,7 +530,8 @@ export async function buildSearchIndexesOrDegrade( // name+content-only index is invisible to the build (it succeeds) yet still // means description search is broken (#2299). const failures = await createSearchFTSIndexes(options); - const missing = await verifySearchFTSIndexes(executeQuery); + const indexes = options?.indexes ?? FTS_INDEXES; + const missing = await verifySearchFTSIndexes(executeQuery, indexes); if (failures.length === 0 && missing.length === 0) return { ok: true }; // A table that failed to build is necessarily missing too — report it once, @@ -530,7 +539,7 @@ export async function buildSearchIndexesOrDegrade( const named = new Set(failures.map((f) => `${f.table}.${f.indexName}`)); const unexplained = missing.filter((name) => !named.has(name)); const error = [ - failures.length > 0 ? summarizeFtsIndexBuildFailures(failures) : '', + failures.length > 0 ? summarizeFtsIndexBuildFailures(failures, indexes) : '', // Structural incompleteness with no thrown error — classified capability // (degrade) below, matching prior behavior; a broken *write* surfaces as // a thrown IO/checkpoint error and is classified integrity there. diff --git a/gitnexus/src/core/search/fts-schema.ts b/gitnexus/src/core/search/fts-schema.ts index 56acb8385..09b62c0dc 100644 --- a/gitnexus/src/core/search/fts-schema.ts +++ b/gitnexus/src/core/search/fts-schema.ts @@ -1,3 +1,5 @@ +import type { FtsProfile } from '../../storage/repo-meta.js'; + export interface FTSIndexDefinition { readonly table: string; readonly indexName: string; @@ -47,3 +49,16 @@ export const FTS_INDEXES: readonly FTSIndexDefinition[] = [ { table: 'Static', indexName: 'static_fts', properties: FTS_PROPERTIES }, { table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES }, ]; + +const NAME_ONLY_PROPERTIES = ['name'] as const; + +/** Return the FTS definitions compatible with one persisted content profile. */ +export const getFtsIndexes = (profile: FtsProfile = 'full'): readonly FTSIndexDefinition[] => { + if (profile === 'full') return FTS_INDEXES; + if (profile === 'symbol-no-file-content') { + return FTS_INDEXES.map((index) => + index.table === 'File' ? { ...index, properties: NAME_ONLY_PROPERTIES } : index, + ); + } + return FTS_INDEXES.map((index) => ({ ...index, properties: NAME_ONLY_PROPERTIES })); +}; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 1e6f561b0..b3486865b 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -46,7 +46,6 @@ import { import { realpathSync } from 'fs'; import { listRegisteredRepos, - cleanupOldKuzuFiles, canonicalizePath, getStoragePaths, loadMeta, @@ -98,6 +97,11 @@ import { isSupportedCjkSegmentationMode, MAX_CJK_SEGMENTATION_QUERY_LENGTH, } from '../../core/search/cjk-segmentation.js'; +import { + checkoutIsDirectory, + contentRetentionFromMeta, + isFullSourceAvailable, +} from '../../core/content-retention.js'; import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js'; import { stalenessPayload, @@ -173,6 +177,29 @@ const VALUE_CANDIDATE_TYPES: ReadonlySet = new Set(['Const', 'Variable', */ const CANDIDATE_WINDOW = 20; +/** + * `content` is an index capability rather than a promise that every symbol has + * text. Retention `none` intentionally omits it, while `symbol` retains only + * symbol spans. Keep this response additive and emit it only when requested so + * callers relying on the legacy response shape remain compatible. + */ +const requestedContentAvailability = ( + requested: boolean, + meta: Awaited>, +) => { + if (!requested) return undefined; + const profile = contentRetentionFromMeta(meta); + return { + requested: true as const, + profile, + available: profile !== 'none', + scope: profile, + ...(profile === 'none' + ? { reason: 'Source-derived content is not retained by this index.' } + : {}), + }; +}; + /** * The pieces every ambiguous-resolution payload shares, derived once. * @@ -1348,6 +1375,9 @@ export interface RepoListing { branch?: string; /** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */ branches?: Array>; + storagePath?: string; + contentRetention?: 'full' | 'symbol' | 'none'; + sourceAvailable?: boolean; } /** Continuation metadata for the paginated `list_repos` MCP tool (#2119). */ @@ -1791,15 +1821,6 @@ export class LocalBackend { const storagePath = entry.storagePath; const lbugPath = path.join(storagePath, 'lbug'); - // Clean up any leftover KuzuDB files from before the LadybugDB migration. - // If kuzu exists but lbug doesn't, warn so the user knows to re-analyze. - const kuzu = await cleanupOldKuzuFiles(storagePath); - if (kuzu.found && kuzu.needsReindex) { - logger.error( - `GitNexus: "${entry.name}" has a stale KuzuDB index. Run: gitnexus analyze ${entry.path}`, - ); - } - const handle: RepoHandle = { id, name: entry.name, @@ -1843,7 +1864,7 @@ export class LocalBackend { // memory registry snapshot; no disk I/O on this hot path (#2106 R3). for (const entry of entries) { for (const b of entry.branches ?? []) { - liveLbugPaths.add(getStoragePaths(entry.path, b.branch).lbugPath); + liveLbugPaths.add(getStoragePaths(entry.path, b.branch, entry.storagePath).lbugPath); } } // initializedRepos is the authoritative set of OPENED pool keys (flat AND @@ -2095,7 +2116,7 @@ export class LocalBackend { const summary = handle.branch !== branch ? handle.branches?.find((b) => b.branch === branch) : undefined; if (summary) { - const { lbugPath } = getStoragePaths(handle.repoPath, branch); + const { lbugPath } = getStoragePaths(handle.repoPath, branch, handle.storagePath); // The lbug is the artifact the pool opens, so its presence is the // serviceability truth — a half-deleted dir can outlive its meta.json // while the lbug is gone, and vice versa (#2364 review F1 arm ii). @@ -2455,12 +2476,22 @@ export class LocalBackend { // Check staleness for all repos in parallel instead of sequentially. // Each check spawns an async `git rev-list` — with 200 repos the sync // variant took ~50 s; parallel async brings it under a second (#1363). - const stalenessResults = await Promise.all( - handles.map((h) => checkStalenessAsync(h.repoPath, h.lastCommit)), + const listing = await Promise.all( + handles.map(async (h) => { + const [stale, meta] = await Promise.all([ + checkStalenessAsync(h.repoPath, h.lastCommit), + loadMeta(h.storagePath).catch(() => null), + ]); + const contentRetention = contentRetentionFromMeta(meta); + const sourceAvailable = isFullSourceAvailable( + contentRetention, + contentRetention === 'full' ? await checkoutIsDirectory(h.repoPath) : false, + ); + return { h, stale, source: { contentRetention, sourceAvailable } }; + }), ); - return handles.map((h, i) => { - const stale = stalenessResults[i]; + return listing.map(({ h, stale, source }) => { const selfNorm = norm(h.repoPath); const siblings = h.remoteUrl ? (byRemote.get(h.remoteUrl) ?? []).filter((e) => norm(e.repoPath) !== selfNorm) @@ -2490,6 +2521,9 @@ export class LocalBackend { lastCommit: b.lastCommit, })) : undefined, + storagePath: h.storagePath, + contentRetention: source.contentRetention, + sourceAvailable: source.sourceAvailable, }; }); } @@ -2902,7 +2936,16 @@ export class LocalBackend { const processLimit = params.limit || 5; const maxSymbolsPerProcess = params.max_symbols || 10; - const includeContent = params.include_content ?? false; + const requestedContent = params.include_content ?? false; + // Do not trust a lingering graph property when the metadata contract says + // source-derived text is unavailable. A full rebuild normally removes the + // column values; this guard keeps a partially migrated/corrupt index from + // disclosing text merely because a caller asked for it. `query` already + // reads this metadata for CJK and embedding-dimension drift diagnostics, + // so keep that legacy read unconditional. + const meta = await loadMeta(path.dirname(repo.lbugPath)); + const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none'; + const contentAvailability = requestedContentAvailability(requestedContent, meta); const searchQuery = rawQuery.trim(); // Per-phase timing instrumentation (#553). Records wall time for each @@ -2918,7 +2961,6 @@ export class LocalBackend { // each so both get independent wall-time records without fighting // over a single `current` phase slot. const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results - const meta = await loadMeta(path.dirname(repo.lbugPath)); const ftsDisabledReason = getFtsDisabledReason(meta?.capabilities?.fts); const [bm25SearchResult, semanticResults] = await Promise.all([ timer.time('bm25', this.bm25Search(repo, searchQuery, searchLimit, ftsDisabledReason)), @@ -3400,6 +3442,7 @@ export class LocalBackend { process_symbols: dedupedSymbols, definitions: definitions.slice(0, 20), // cap standalone definitions timing, + ...(contentAvailability ? { contentAvailability } : {}), ...(warnings.length > 0 && { warning: warnings.join(' ') }), ...((enrichmentDegraded || ftsPartial) && { partial: true }), }; @@ -4416,6 +4459,12 @@ export class LocalBackend { await this.ensureInitialized(repo); const { name, uid, file_path, kind, include_content } = params; + const requestedContent = include_content ?? false; + // Content retention matters only to the opt-in content response. Avoid a + // metadata dependency for the long-standing default context operation. + const meta = requestedContent ? await loadMeta(path.dirname(repo.lbugPath)) : null; + const contentAvailability = requestedContentAvailability(requestedContent, meta); + const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none'; if (!name && !uid) { return { error: 'Either "name" or "uid" parameter is required.' }; @@ -4423,18 +4472,22 @@ export class LocalBackend { const outcome = await this.resolveSymbolCandidates( repo, - { uid, name, include_content }, + { uid, name, include_content: includeContent }, { file_path, kind }, ); if (outcome.kind === 'not_found') { - return { error: `Symbol '${name || uid}' not found` }; + return { + error: `Symbol '${name || uid}' not found`, + ...(contentAvailability ? { contentAvailability } : {}), + }; } if (outcome.kind === 'ambiguous') { const { atLeast, showing, fields } = ambiguityReport(outcome, outcome.candidates.length); return { status: 'ambiguous', + ...(contentAvailability ? { contentAvailability } : {}), message: `Found ${atLeast}${outcome.total} symbols matching '${name}'${showing}. Use uid, file_path, or kind to disambiguate.`, ...fields, candidates: outcome.candidates.map((c) => ({ @@ -4806,6 +4859,7 @@ export class LocalBackend { return { status: 'found', + ...(contentAvailability ? { contentAvailability } : {}), symbol: { uid: sym.id || sym[0], name: sym.name || sym[1], @@ -4813,7 +4867,7 @@ export class LocalBackend { filePath: sym.filePath || sym[3], startLine: toDisplayLine(sym.startLine ?? sym[4]), endLine: toDisplayLine(sym.endLine ?? sym[5]), - ...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}), + ...(includeContent && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}), ...(methodMetadata ? { methodMetadata } : {}), ...(beanMetadata ? { bean: beanMetadata } : {}), ...(aopMetadata ? { aop: aopMetadata } : {}), diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 4e85cfd45..8436629e8 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -10,6 +10,11 @@ import { checkStaleness } from './staleness.js'; import { loadMeta } from '../storage/repo-manager.js'; import { ANALYZER_RUNNER_IDENTITY_SCHEMA_VERSION } from '../core/analyzer-identity.js'; import { getIndexIncompleteReasons } from '../core/index-freshness.js'; +import { + checkoutIsDirectory, + contentRetentionFromMeta, + isFullSourceAvailable, +} from '../core/content-retention.js'; export interface ResourceDefinition { uri: string; @@ -362,9 +367,18 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro // receipt intact lets agents compare every identity field without parsing a // lossy human rendering; null explicitly means legacy/unknown provenance. lines.push(''); + const contentRetention = contentRetentionFromMeta(freshMeta); + const sourceAvailable = isFullSourceAvailable( + contentRetention, + repo.repoPath ? await checkoutIsDirectory(repo.repoPath) : false, + ); + lines.push('index:'); lines.push(` commit: ${JSON.stringify(lastCommit)}`); lines.push(` indexed_at: ${JSON.stringify(freshMeta?.indexedAt ?? null)}`); + lines.push(` storage_path: ${JSON.stringify(repo.storagePath)}`); + lines.push(` content_retention: ${JSON.stringify(contentRetention)}`); + lines.push(` source_available: ${JSON.stringify(sourceAvailable)}`); lines.push(` runner_identity: ${JSON.stringify(freshMeta?.runnerIdentity ?? null)}`); lines.push(` incomplete_reasons: ${JSON.stringify(incompleteReasons)}`); lines.push(` spring_actuator: ${JSON.stringify(freshMeta?.springActuator ?? null)}`); diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index dfb9423ff..146936f7a 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -181,7 +181,8 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W }, include_content: { type: 'boolean', - description: 'Include full symbol source code (default: false)', + description: + 'Include source text retained for matching symbols (default: false). The response reports contentAvailability; indexes built with content retention "none" explicitly report unavailable content.', default: false, }, maxTokens: { @@ -328,7 +329,8 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep }, include_content: { type: 'boolean', - description: 'Include full symbol source code (default: false)', + description: + 'Include source text retained for this symbol (default: false). The response reports contentAvailability; indexes built with content retention "none" explicitly report unavailable content.', default: false, }, maxTokens: { diff --git a/gitnexus/src/server/analyze-launch.ts b/gitnexus/src/server/analyze-launch.ts index 901963a87..4f576ec14 100644 --- a/gitnexus/src/server/analyze-launch.ts +++ b/gitnexus/src/server/analyze-launch.ts @@ -15,13 +15,13 @@ import { existsSync, statSync } from 'node:fs'; import { fork } from 'child_process'; import { fileURLToPath, pathToFileURL } from 'url'; import { createRequire } from 'node:module'; +import { INDEX_METADATA_FILE } from '../storage/repo-manager.js'; +import { LBUG_DIRECTORY } from '../storage/storage-constants.js'; import { - canonicalizePath, - getStoragePath, - INDEX_METADATA_FILE, - listRegisteredRepos, - registryPathEquals, -} from '../storage/repo-manager.js'; + ANALYZE_FORCE_STORAGE_REQUIREMENTS, + ANALYZE_STORAGE_REQUIREMENTS, + requireStoragePath, +} from '../storage/storage-resolver.js'; import { BRANCHES_DIR, branchSlug } from '../storage/branch-index.js'; import { logger } from '../core/logger.js'; import { autoHeapCapMb } from '../core/ingestion/utils/effective-ram.js'; @@ -71,8 +71,8 @@ const MAX_WORKER_RETRIES = 2; /** * The worker reports `complete` over IPC before its on-disk finalization * (LadybugDB checkpoint + native handle release + metadata write) is visible - * at `getStoragePath(targetPath)` — observed up to ~6.5s behind the IPC - * message. Opening the database inside that window is what the pre-IPC + * at the ownership-validated storage path — observed up to ~6.5s behind the + * IPC message. Opening the database inside that window is what the pre-IPC * ordering was meant to prevent and is actively dangerous: reads fail with * binder errors or return an empty graph, the open can quarantine the * in-flight WAL, and the native layer racing the rewrite has crashed the @@ -81,28 +81,14 @@ const MAX_WORKER_RETRIES = 2; const FINALIZE_SETTLE_TIMEOUT_MS = 60_000; const FINALIZE_SETTLE_POLL_MS = 200; -/** - * Look up the analyzed repo's registered storage path. The request's - * user-provided path is used only as a comparison key; the filesystem probes - * below run against the registry's own `storagePath` — the server-owned - * record readers resolve through, and not a user-controlled value - * (CodeQL js/path-injection). - */ -const registeredStoragePath = async (targetPath: string): Promise => { - const target = canonicalizePath(path.resolve(targetPath)); - const entries = await listRegisteredRepos(); - const entry = entries.find((e) => registryPathEquals(canonicalizePath(e.path), target)); - return entry?.storagePath ?? null; -}; - /** * Resolve the directory this run's index actually landed in. * - * `registerRepo` always records the FLAT `.gitnexus` as `entry.storagePath`, - * but a pinned `--branch` run whose label differs from the flat slot's owner - * writes `lbug`/`gitnexus.json` under `branches//` instead. Probing the - * flat path for such a run watches files it never rewrote, so the gate below - * would spin to its timeout on a perfectly successful analysis (#3199 review). + * `requireStoragePath` / `registerRepo` record the FLAT storage slot, but a + * pinned `--branch` run whose label differs from the flat slot's owner writes + * `lbug`/`gitnexus.json` under `branches//` instead. Probing the flat + * path for such a run watches files it never rewrote, so the gate below would + * spin to its timeout on a perfectly successful analysis (#3199 review). * * `isPrimaryBranch` is the worker's own report of `!placement.branch`, so this * follows the placement core actually chose rather than recomputing it here @@ -125,44 +111,72 @@ const settleDirFor = ( * the previous index in place while it works), and no transient WAL/shadow/ * checkpoint sidecars remain (the worker's native close has finished). * - * Never rejects. Timing out logs and proceeds (pre-gate behavior) rather - * than failing a job whose analysis genuinely succeeded. The `alreadyUpToDate` - * fast path never rewrites `lbug` (see `run-analyze.ts`) and skips this wait - * at the `complete` handler so it does not hold the analyze slot for 60s. + * `storagePath` is the ownership-validated path from `requireStoragePath`, + * not the request's user-provided repo path (CodeQL js/path-injection). + * + * Never rejects. Returns `true` once the index is settled. Timing out logs + * a warning and returns `false` — the caller must fail the job without + * publishing. The `alreadyUpToDate` fast path never rewrites `lbug` (see + * `run-analyze.ts`) and is treated as settled without waiting so it does + * not hold the analyze slot for 60s of polling. */ const waitForSettledIndex = async ( - targetPath: string, + storagePath: string, jobStartMs: number, branch?: string, isPrimaryBranch?: boolean, -): Promise => { - const settled = (storagePath: string): boolean => { +): Promise => { + const settled = (probePath: string): boolean => { try { - const lbugStat = statSync(path.join(storagePath, 'lbug')); - const metaStat = statSync(path.join(storagePath, INDEX_METADATA_FILE)); - return ( - lbugStat.mtimeMs >= jobStartMs && - metaStat.mtimeMs >= jobStartMs && - ['lbug.wal', 'lbug.shadow', 'lbug.wal.checkpoint'].every( - (f) => !existsSync(path.join(storagePath, f)), - ) - ); + // Inline path.relative barriers at every filesystem sink. CodeQL tracks + // `storagePath` from the HTTP analyze `path` through requireStoragePath + // and does not treat that helper as a js/path-injection sanitizer. + const storageRoot = path.resolve(storagePath); + const probeRoot = path.resolve(probePath); + const probeRel = path.relative(storageRoot, probeRoot); + if (probeRel.startsWith('..') || path.isAbsolute(probeRel)) { + return false; + } + + const lbugPath = path.resolve(probeRoot, LBUG_DIRECTORY); + const lbugRel = path.relative(storageRoot, lbugPath); + if (lbugRel.startsWith('..') || path.isAbsolute(lbugRel)) { + return false; + } + const lbugStat = statSync(lbugPath); + + const metaPath = path.resolve(probeRoot, INDEX_METADATA_FILE); + const metaRel = path.relative(storageRoot, metaPath); + if (metaRel.startsWith('..') || path.isAbsolute(metaRel)) { + return false; + } + const metaStat = statSync(metaPath); + + if (lbugStat.mtimeMs < jobStartMs || metaStat.mtimeMs < jobStartMs) { + return false; + } + + return ['lbug.wal', 'lbug.shadow', 'lbug.wal.checkpoint'].every((name) => { + const sidePath = path.resolve(probeRoot, name); + const sideRel = path.relative(storageRoot, sidePath); + if (sideRel.startsWith('..') || path.isAbsolute(sideRel)) { + return false; + } + return !existsSync(sidePath); + }); } catch { return false; // not written yet } }; const deadline = Date.now() + FINALIZE_SETTLE_TIMEOUT_MS; for (;;) { - // Re-resolved each round: the worker registers the repo as part of the - // finalization this gate is waiting out. - const storagePath = await registeredStoragePath(targetPath); - if (storagePath && settled(settleDirFor(storagePath, branch, isPrimaryBranch))) return; + if (settled(settleDirFor(storagePath, branch, isPrimaryBranch))) return true; if (Date.now() > deadline) { logger.warn( - { targetPath }, - 'analyze finalization not visible after timeout; completing job anyway', + { storagePath }, + 'analyze finalization not visible after timeout; not publishing', ); - return; + return false; } await new Promise((resolve) => setTimeout(resolve, FINALIZE_SETTLE_POLL_MS)); } @@ -171,22 +185,37 @@ const waitForSettledIndex = async ( export function createLaunchAnalysisWorker(deps: LaunchDeps) { const { jobManager, backend, acquireRepoLock, releaseRepoLock, closeDbHandle } = deps; - return function launchAnalysisWorker( + return async function launchAnalysisWorker( job: { id: string }, targetPath: string, opts: LaunchOptions, - ): void { + ): Promise { // For waitForSettledIndex: files (re)written by this job have mtimes at or // after this instant. Taken before the fork so no worker write predates it. const jobStartMs = Date.now(); - // Acquire shared repo lock (keyed on storagePath to match embed handler) - const analyzeLockKey = getStoragePath(targetPath); + const analyzeLockKey = await requireStoragePath( + targetPath, + opts.force ? ANALYZE_FORCE_STORAGE_REQUIREMENTS : ANALYZE_STORAGE_REQUIREMENTS, + ); + // Acquire shared repo lock only after ownership validation. The same + // resolved path is retained for finalization instead of being looked up + // again through the registry after the worker exits. const lockErr = acquireRepoLock(analyzeLockKey); if (lockErr) { jobManager.updateJob(job.id, { status: 'failed', error: lockErr }); return; } + // One launch, one release. `releaseRepoLock` is Set.delete (idempotent), + // and this flag also stops error / exit / child.error / complete-finally + // from racing a second drop if a late terminal message lands mid-settle. + let lockReleased = false; + const releaseLockOnce = (): void => { + if (lockReleased) return; + lockReleased = true; + releaseRepoLock(analyzeLockKey); + }; + jobManager.updateJob(job.id, { repoPath: targetPath, status: 'analyzing' }); // ── Worker fork with auto-retry ────────────────────────────── @@ -245,7 +274,13 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) { progress: { phase: msg.phase, percent: msg.percent, message: msg.message }, }); } else if (msg.type === 'complete') { - releaseRepoLock(analyzeLockKey); + // Hold the write lock through settle AND the collapse/publish + // decision. Release in `finally` so timeout / collapse / init + // failure / complete each drop it exactly once. alreadyUpToDate + // skips the mtime wait (resolved `true` immediately) so this + // does not occupy the slot for 60s of polling — the lock still + // drops only after that short path finishes. + // // Before marking complete: (1) wait for the worker's on-disk // finalization to settle (see waitForSettledIndex), (2) evict the // cached DB handle — same invalidation DELETE /api/repo performs, a @@ -262,12 +297,33 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) { // ftsRepairedOnly DOES rewrite `lbug` (initLbug + createSearchFTSIndexes) // so it still waits. const settle = msg.result.alreadyUpToDate - ? Promise.resolve() - : waitForSettledIndex(targetPath, jobStartMs, opts.branch, msg.result.isPrimaryBranch); + ? Promise.resolve(true) + : waitForSettledIndex( + analyzeLockKey, + jobStartMs, + opts.branch, + msg.result.isPrimaryBranch, + ); settle - .then(() => closeDbHandle()) - .catch(() => {}) // best-effort: eviction failure must not fail the job - .then(() => { + .then((settled) => { + if (!settled) { + // Finalization never became visible. Do not evict the cached + // handle (a previously published index should keep being + // served) and do not publish. On-disk files stay for a retry. + jobManager.updateJob(job.id, { + status: 'failed', + repoName: msg.result.repoName, + error: + 'Analysis finalization not visible after timeout. The index was not published; on-disk files were left for a retry.', + }); + return false; + } + return closeDbHandle() + .catch(() => {}) // best-effort: eviction failure must not fail the job + .then(() => true); + }) + .then((readyToPublish) => { + if (!readyToPublish) return; // PARITY WITH THE CLI, which is what the IPC projection was added // for. `analyze-worker-ipc.ts` carries `graphWriteCollapsed` // "so a server-side caller sees the same degraded outcome the CLI @@ -328,7 +384,10 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) { // ordering comment above the chain true — the repo really is // queryable when the client receives the SSE complete event. return backend.init().then(() => { - jobManager.updateJob(job.id, { status: 'complete', repoName: msg.result.repoName }); + jobManager.updateJob(job.id, { + status: 'complete', + repoName: msg.result.repoName, + }); }); }) .catch((err) => { @@ -337,9 +396,12 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) { status: 'failed', error: 'Server failed to reload after analysis. Try again.', }); + }) + .finally(() => { + releaseLockOnce(); }); } else if (msg.type === 'error') { - releaseRepoLock(analyzeLockKey); + releaseLockOnce(); // A failed (force) analyze may still have rewritten DB files first. void closeDbHandle().catch(() => {}); jobManager.updateJob(job.id, { status: 'failed', error: msg.message }); @@ -347,7 +409,7 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) { }); child.on('error', (err) => { - releaseRepoLock(analyzeLockKey); + releaseLockOnce(); jobManager.updateJob(job.id, { status: 'failed', error: `Worker process error: ${err.message}`, @@ -386,7 +448,7 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) { setTimeout(forkWorker, delay); } else { // Exhausted retries — permanent failure - releaseRepoLock(analyzeLockKey); + releaseLockOnce(); jobManager.updateJob(job.id, { status: 'failed', error: `Worker crashed ${MAX_WORKER_RETRIES + 1} times (code ${code})${stderrChunks ? ': ' + stderrChunks.trim().split('\n').pop() : ''}`, @@ -413,6 +475,11 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) { }); }; - forkWorker(); + try { + forkWorker(); + } catch (error) { + releaseLockOnce(); + throw error; + } }; } diff --git a/gitnexus/src/server/analyze-upload.ts b/gitnexus/src/server/analyze-upload.ts index adb8b861b..df96f5f5c 100644 --- a/gitnexus/src/server/analyze-upload.ts +++ b/gitnexus/src/server/analyze-upload.ts @@ -27,7 +27,11 @@ export interface AnalyzeUploadDeps { /** Create (or throw on busy) an analysis job for the given upload dir. */ createJob: (params: { repoPath: string }) => UploadJobRef; /** Launch the analyze worker against an already-resolved repo directory. */ - launch: (job: UploadJobRef, targetPath: string, opts: { registryName: string }) => void; + launch: ( + job: UploadJobRef, + targetPath: string, + opts: { registryName: string }, + ) => void | Promise; /** * Mark a created job failed. The job occupies the single analysis slot from * createJob onward, so ANY error before launch must release it — otherwise a @@ -129,7 +133,7 @@ export function createAnalyzeUploadHandler(deps: AnalyzeUploadDeps) { .rm(path.join(finalDir, '.gitnexus'), { recursive: true, force: true }) .catch(() => {}); - deps.launch(job, finalDir, { registryName: finalName }); + await deps.launch(job, finalDir, { registryName: finalName }); launched = true; res.status(202).json({ jobId: job.id, status: job.status }); diff --git a/gitnexus/src/server/analyze-worker-core.ts b/gitnexus/src/server/analyze-worker-core.ts index b51e0e759..ddda81a83 100644 --- a/gitnexus/src/server/analyze-worker-core.ts +++ b/gitnexus/src/server/analyze-worker-core.ts @@ -72,7 +72,7 @@ export async function runWorkerAnalysis( // registry) — must NOT be reported as a successful analysis. Mirror the CLI's // assertAnalysisFinalized guard so the worker surfaces it as an error instead // of a false `complete` that leaves the repo invisible to list_repos. - await deps.assertAnalysisFinalized(repoPath); + await deps.assertAnalysisFinalized(repoPath, result.storagePath); // Send a JSON-safe projection, NOT the raw result: the IPC channel is // default-JSON serialization and `result.pipelineResult` carries the live diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index eb97e81e6..393782e5f 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -18,10 +18,16 @@ import { loadMeta, saveMeta, listRegisteredRepos, - getStoragePath, registryPathEquals, type RegistryEntry, } from '../storage/repo-manager.js'; +import { + requireDeletableStoragePath, + requireRegisteredStoragePath, + STATUS_STORAGE_REQUIREMENTS, + StorageDeletionError, + StorageRequirementError, +} from '../storage/storage-resolver.js'; import { executeQuery, executePrepared, @@ -37,6 +43,12 @@ import { NODE_TABLES, type GraphNode, type GraphRelationship } from 'gitnexus-sh import { searchFTSFromLbug } from '../core/search/bm25-index.js'; import { hybridSearch } from '../core/search/hybrid-search.js'; import { ftsDegradedWarning } from '../core/search/fts-indexes.js'; +import { + checkoutIsDirectory, + contentRetentionFromMeta, + isFullSourceAvailable, +} from '../core/content-retention.js'; +import { LBUG_DIRECTORY } from '../storage/storage-constants.js'; import { getFtsDisabledReason, type FtsDisabledReason } from '../core/search/fts-policy.js'; import { LocalBackend } from '../mcp/local/local-backend.js'; import { installServeMcpAuth, mountMCPEndpoints } from './mcp-http.js'; @@ -644,6 +656,65 @@ export const resolveRegisteredRepoEntry = ( ); }; +export interface SourceAvailability { + available: boolean; + reason?: 'content-retention' | 'checkout-missing'; + contentRetention?: ReturnType; +} + +/** Map a failed storage probe to a catalog status. Missing/empty slots are 404; anything else is 503. */ +export const storageRequirementToHttp = ( + err: StorageRequirementError, +): { status: 404 | 503; body: { error: string; code: 'index-unavailable'; state: string } } => { + const notPresent = err.inspection.state === 'missing' || err.inspection.state === 'empty'; + return { + status: notPresent ? 404 : 503, + body: { + error: err.message, + code: 'index-unavailable', + state: err.inspection.state, + }, + }; +}; + +const sendStorageRequirementHttp = ( + err: unknown, + res: { status: (code: number) => { json: (body: unknown) => void } }, +): boolean => { + if (!(err instanceof StorageRequirementError)) return false; + const mapped = storageRequirementToHttp(err); + res.status(mapped.status).json(mapped.body); + return true; +}; + +/** Full-file endpoints require a live checkout; normalized index text is not source-viewer data. */ +export const getSourceAvailability = async ( + entry: Pick, + loadedMeta?: Awaited>, +): Promise => { + const meta = loadedMeta === undefined ? await loadMeta(entry.storagePath) : loadedMeta; + const contentRetention = contentRetentionFromMeta(meta); + if (contentRetention !== 'full') { + return { available: false, reason: 'content-retention', contentRetention }; + } + return isFullSourceAvailable(contentRetention, await checkoutIsDirectory(entry.path)) + ? { available: true, contentRetention } + : { available: false, reason: 'checkout-missing', contentRetention }; +}; + +const sendSourceUnavailable = ( + res: { status: (code: number) => { json: (body: any) => void } }, + availability: SourceAvailability, +): void => { + const reason = + availability.reason === 'content-retention' ? 'content retention' : 'source checkout'; + res.status(410).json({ + error: `Full source is unavailable because the ${reason} is unavailable.`, + code: 'source-unavailable', + reason: availability.reason, + }); +}; + /** * Handle a GET /api/file request body. Extracted from createServer's route * registration so it can be unit-tested without spinning up an HTTP server @@ -663,6 +734,7 @@ export const handleFileRequest = async ( json: (body: any) => void; }, repoPath: string, + availability: SourceAvailability = { available: true }, ): Promise => { try { // Type-confusion guard — req.query.path is `string | string[] | ParsedQs`. @@ -676,6 +748,11 @@ export const handleFileRequest = async ( } const filePath = assertString(rawFilePath, 'path'); + if (!availability.available) { + sendSourceUnavailable(res, availability); + return; + } + // Path-injection containment — inline at the sink with the canonical // path.relative idiom that CodeQL's js/path-injection sanitizer // recognizes. assertSafePath in validation.ts performs the equivalent @@ -780,6 +857,7 @@ export const handleQueryRequest = async ( ); res.json({ result }); } catch (err: any) { + if (sendStorageRequirementHttp(err, res)) return; if (isReadOnlyDbError(err)) { res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' }); return; @@ -923,11 +1001,30 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => */ const HOLD_QUEUE_TIMEOUT_SECS = 300; // 5 minutes + const validateResolvedRepoEntry = async ( + entry: RegistryEntry | null, + ): Promise => { + if (!entry) return null; + await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS); + return entry; + }; + // Helper: resolve a repo by name from the global registry, or default to first. // Pass `req` to enable early exit if the client disconnects during the hold-queue wait. - const resolveRepo = async (repoName?: string, isRetry = false, req?: any): Promise => { - const repos = await listRegisteredRepos(); + // Deletion passes `validateStorage: false` because it has a separate policy that + // intentionally permits a missing/empty local slot to be removed. + const resolveRepo = async ( + repoName?: string, + isRetry = false, + req?: any, + options: { validateStorage?: boolean } = {}, + ): Promise => { + const repos = await listRegisteredRepos({ + validate: options.validateStorage !== false, + }); const found = resolveRegisteredRepoEntry(repos, repoName); + const validate = (entry: RegistryEntry | null): Promise => + options.validateStorage === false ? Promise.resolve(entry) : validateResolvedRepoEntry(entry); const normalizedName = repoName ? repoParamBasename(repoName) : undefined; @@ -967,8 +1064,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => if (!currentJob || currentJob.status === 'failed') break; if (currentJob.status === 'complete') { await backend.init(); - const freshRepos = await listRegisteredRepos(); - return resolveRegisteredRepoEntry(freshRepos, repoName); + const freshRepos = await listRegisteredRepos({ + validate: options.validateStorage !== false, + }); + return validate(resolveRegisteredRepoEntry(freshRepos, repoName)); } await new Promise((r) => setTimeout(r, 1000)); } @@ -989,10 +1088,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => ); } await backend.init(); - return await resolveRepo(repoName, true, req); + return await resolveRepo(repoName, true, req, options); } - return found; + return validate(found); }; // Lightweight healthcheck for Docker/orchestrator probes (#1147). @@ -1034,7 +1133,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // already carries. Web callers hit this on connect/switch, never in a loop. app.get('/api/repos', createRouteLimiter(), async (_req, res) => { try { - const repos = await listRegisteredRepos(); + const repos = await listRegisteredRepos({ validate: true }); // Checked in parallel, for the reason `list_repos` already does it that // way: each check spawns an async `git rev-list`, and the sequential // variant took ~50s across 200 repos (#1363). Projecting inside the map @@ -1042,9 +1141,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // array is the shape that silently mispairs them if either is reordered. res.json( await Promise.all( - repos.map(async (r) => - projectRepoListEntry(r, await checkStalenessAsync(r.path, r.lastCommit)), - ), + repos.map(async (r) => { + const [staleness, availability] = await Promise.all([ + checkStalenessAsync(r.path, r.lastCommit), + getSourceAvailability(r), + ]); + return projectRepoListEntry(r, staleness, { + contentRetention: availability.contentRetention ?? 'full', + sourceAvailable: availability.available, + }); + }), ), ); } catch (err: any) { @@ -1072,9 +1178,18 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => return; } const meta = await loadMeta(entry.storagePath); - const staleness = await checkStalenessAsync(entry.path, resolveLastCommit(entry, meta)); - res.json(projectRepoDetail(entry, meta, staleness)); + const [staleness, availability] = await Promise.all([ + checkStalenessAsync(entry.path, resolveLastCommit(entry, meta)), + getSourceAvailability(entry, meta), + ]); + res.json( + projectRepoDetail(entry, meta, staleness, { + contentRetention: availability.contentRetention ?? contentRetentionFromMeta(meta), + sourceAvailable: availability.available, + }), + ); } catch (err: any) { + if (sendStorageRequirementHttp(err, res)) return; res.status(500).json({ error: err.message || 'Failed to get repo info' }); } }); @@ -1090,14 +1205,25 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => res.status(400).json({ error: 'Missing repo name' }); return; } - const entry = await resolveRepo(repoName); + const entry = await resolveRepo(repoName, false, undefined, { validateStorage: false }); if (!entry) { res.status(404).json({ error: 'Repository not found' }); return; } + let storagePath: string; + try { + storagePath = await requireDeletableStoragePath(entry); + } catch (err: any) { + if (err instanceof StorageDeletionError) { + res.status(400).json({ error: err.message }); + return; + } + res.status(400).json({ error: err.message || 'Unsafe index storage path' }); + return; + } // Acquire repo lock — prevents deleting while analyze/embed is in flight - const lockKey = getStoragePath(entry.path); + const lockKey = storagePath; const lockErr = acquireRepoLock(lockKey); if (lockErr) { res.status(409).json({ error: lockErr }); @@ -1111,7 +1237,6 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => } catch {} // 1. Delete the .gitnexus index/storage directory - const storagePath = getStoragePath(entry.path); await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {}); // 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/. @@ -1229,6 +1354,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => ); res.json(graph); } catch (err: any) { + if (sendStorageRequirementHttp(err, res)) return; if (err instanceof ClientDisconnectedError) { return; } @@ -1425,6 +1551,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => } res.json(response); } catch (err: any) { + if (sendStorageRequirementHttp(err, res)) return; res.status(500).json({ error: err.message || 'Search failed' }); } }); @@ -1432,12 +1559,17 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Read file — with path traversal guard // Rate-limited (CodeQL js/missing-rate-limiting): per-request fs.readFile. app.get('/api/file', createRouteLimiter(), async (req, res) => { - const entry = await resolveRepo(requestedRepo(req)); - if (!entry) { - res.status(404).json({ error: 'Repository not found' }); - return; + try { + const entry = await resolveRepo(requestedRepo(req)); + if (!entry) { + res.status(404).json({ error: 'Repository not found' }); + return; + } + await handleFileRequest(req, res, entry.path, await getSourceAvailability(entry)); + } catch (err: any) { + if (sendStorageRequirementHttp(err, res)) return; + res.status(500).json({ error: err.message || 'Failed to read file' }); } - await handleFileRequest(req, res, entry.path); }); // Grep — regex search across file contents in the indexed repo @@ -1452,6 +1584,11 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => res.status(404).json({ error: 'Repository not found' }); return; } + const sourceAvailability = await getSourceAvailability(entry); + if (!sourceAvailability.available) { + sendSourceUnavailable(res, sourceAvailability); + return; + } // Pattern parsing lives in grep-params.ts (unit-testable without // Express + LadybugDB). Matching runs in a worker so terminate() can // cut a stuck regex.test() when the wall-clock budget expires. @@ -1485,6 +1622,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => res.json({ results, ...(timedOut ? { timedOut: true } : {}) }); } catch (err: any) { + if (sendStorageRequirementHttp(err, res)) return; res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' }); } }); @@ -1709,7 +1847,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => throw new Error('No target path resolved'); } - launchAnalysisWorker(job, targetPath, { + await launchAnalysisWorker(job, targetPath, { force, embeddings, dropEmbeddings, @@ -1731,7 +1869,6 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => : {}), }); } catch (err: any) { - if (targetPath) releaseRepoLock(getStoragePath(targetPath)); jobManager.updateJob(job.id, { status: 'failed', error: err.message || 'Analysis failed', @@ -1821,15 +1958,20 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => return; } + // Re-check the exact registered slot immediately before taking the lock. + // The query resolver already validates it, but this closes the gap between + // lookup and a long-running metadata-writing job. + const storagePath = await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS); + // Check shared repo lock — prevent concurrent analyze + embed on same repo - const repoLockPath = entry.storagePath; + const repoLockPath = storagePath; const lockErr = acquireRepoLock(repoLockPath); if (lockErr) { res.status(409).json({ error: lockErr }); return; } - const job = embedJobManager.createJob({ repoPath: entry.storagePath }); + const job = embedJobManager.createJob({ repoPath: storagePath }); embedJobManager.updateJob(job.id, { repoName: entry.name, status: 'analyzing' as any, @@ -1853,8 +1995,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => let partialRunError: string | undefined; let partialRunDetail: AnalyzeJobPartialOutcome | undefined; try { - const lbugPath = path.join(entry.storagePath, 'lbug'); - const ftsSession = await loadFtsSession(entry.storagePath); + const lbugPath = path.join(storagePath, LBUG_DIRECTORY); + const ftsSession = await loadFtsSession(storagePath); let embeddingMeta = ftsSession.meta; await withLbugDb( lbugPath, @@ -1903,7 +2045,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // without a fresh read, a concurrent writer's update (e.g. a // --repair-fts capability stamp) would be silently reverted on // every checkpoint save for the job's whole lifetime. - const latestMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta; + const latestMeta = (await loadMeta(storagePath)) ?? embeddingMeta; // `stats.embeddings` only moves when the caller MEASURED the // live count (the post-flush `onCheckpoint`). The window-start // callback measures nothing and passes nothing: restating the @@ -1922,7 +2064,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => }, embeddings, ); - await saveMeta(entry.storagePath, embeddingMeta); + await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS); + await saveMeta(storagePath, embeddingMeta); }; /** * Count the persisted rows, or report the answer never arrived. @@ -2021,7 +2164,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // `embeddingCheckpoint` is the marker this run's own mid-run // writer saved, which is the only record of the work when the // count query could not answer. - const finalMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta; + const finalMeta = (await loadMeta(storagePath)) ?? embeddingMeta; const finalizeContext: EmbedRunFinalizeContext = { measuredEmbeddings: persistedEmbeddingCountOrUndefined(measuredEmbeddings), onDisk: finalMeta, @@ -2041,7 +2184,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => { ...finalMeta, embeddingCheckpoint: outcome.checkpoint }, measuredEmbeddings, ); - await saveMeta(entry.storagePath, embeddingMeta); + await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS); + await saveMeta(storagePath, embeddingMeta); }, skipFtsOption(ftsSession.skipFts), ); @@ -2087,6 +2231,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => res.status(202).json({ jobId: job.id, status: 'analyzing' }); } catch (err: any) { + if (sendStorageRequirementHttp(err, res)) return; if (err.message?.includes('already in progress')) { res.status(409).json({ error: err.message }); } else { diff --git a/gitnexus/src/server/repo-projection.ts b/gitnexus/src/server/repo-projection.ts index 1084260c3..5370c608f 100644 --- a/gitnexus/src/server/repo-projection.ts +++ b/gitnexus/src/server/repo-projection.ts @@ -14,8 +14,14 @@ import { type StalenessInfo, type StalenessPayload, } from '../core/staleness-status.js'; +import type { ContentRetention, RepoMeta } from '../storage/repo-meta.js'; import type { RegistryEntry } from '../storage/repo-manager.js'; -import type { RepoMeta } from '../storage/repo-meta.js'; + +/** Retention + checkout facts computed by the route (see getSourceAvailability). */ +export interface RepoProjectionSource { + contentRetention: ContentRetention; + sourceAvailable: boolean; +} /** * Staleness through the shared {@link stalenessPayload} builder, so this route @@ -38,13 +44,20 @@ export const stalenessField = (info: StalenessInfo): { staleness?: StalenessPayl }; /** One entry of `GET /api/repos`. */ -export const projectRepoListEntry = (entry: RegistryEntry, staleness: StalenessInfo) => ({ +export const projectRepoListEntry = ( + entry: RegistryEntry, + staleness: StalenessInfo, + source: RepoProjectionSource, +) => ({ name: entry.name, path: entry.path, repoPath: entry.path, + storagePath: entry.storagePath, indexedAt: entry.indexedAt, lastCommit: entry.lastCommit, stats: entry.stats, + contentRetention: source.contentRetention, + sourceAvailable: source.sourceAvailable, // The registry has carried these since #2106; #3199 made them load-bearing // over HTTP, because a branch-pinned analyze now gets its own entry and the // only other way to tell two entries apart is to parse the clone-directory @@ -63,13 +76,17 @@ export const projectRepoDetail = ( entry: RegistryEntry, meta: RepoMeta | null | undefined, staleness: StalenessInfo, + source: RepoProjectionSource, ) => ({ name: entry.name, repoPath: entry.path, + storagePath: entry.storagePath, indexedAt: meta?.indexedAt ?? entry.indexedAt, stats: meta?.stats ?? entry.stats ?? {}, lastCommit: meta?.lastCommit ?? entry.lastCommit, branch: meta?.branch ?? entry.branch, + contentRetention: source.contentRetention, + sourceAvailable: source.sourceAvailable, ...stalenessField(staleness), }); diff --git a/gitnexus/src/server/upload-sweep.ts b/gitnexus/src/server/upload-sweep.ts index 46dcd4aed..442ac6415 100644 --- a/gitnexus/src/server/upload-sweep.ts +++ b/gitnexus/src/server/upload-sweep.ts @@ -4,13 +4,18 @@ * A crashed/killed process can leave a `.staging-*` directory under * UPLOAD_ROOT (the normal path removes it on success/failure/abort). This * sweep, run once at server startup, removes staging dirs older than a - * threshold. Promoted upload dirs are persistent registered repos (like - * clones) and are NOT touched here — they are removed via DELETE /api/repo. + * threshold. Stale promoted upload directories that are no longer registered + * are also removed. Registered promoted dirs stay (DELETE /api/repo). */ import path from 'path'; -import fsp from 'fs/promises'; +import fsp from 'node:fs/promises'; import { UPLOAD_ROOT, STAGING_PREFIX } from './upload-paths.js'; +import { + canonicalizePath, + readRegistryStrictIfPresent, + registryPathEquals, +} from '../storage/repo-manager.js'; export interface SweepOptions { /** Remove staging dirs older than this (default 6h). */ @@ -21,6 +26,15 @@ export interface SweepOptions { now?: number; } +const removeSweptDir = async (full: string, removed: string[]): Promise => { + try { + await fsp.rm(full, { recursive: true, force: true }); + removed.push(full); + } catch { + /* Permission or transient errors leave the path unlisted. */ + } +}; + export async function sweepStaleUploads(opts: SweepOptions = {}): Promise<{ removed: string[] }> { const maxAgeMs = opts.maxAgeMs ?? 6 * 60 * 60 * 1000; const root = opts.root ?? UPLOAD_ROOT; @@ -34,6 +48,8 @@ export async function sweepStaleUploads(opts: SweepOptions = {}): Promise<{ remo return { removed }; // root does not exist yet — nothing to sweep } + const stalePromotedDirs: string[] = []; + for (const entry of entries) { if (!entry.isDirectory()) continue; const full = path.join(root, entry.name); @@ -43,25 +59,43 @@ export async function sweepStaleUploads(opts: SweepOptions = {}): Promise<{ remo if (entry.name.startsWith(STAGING_PREFIX)) { // Transient staging dir orphaned by a crash — always removable. - await fsp.rm(full, { recursive: true, force: true }).catch(() => {}); - removed.push(full); + await removeSweptDir(full, removed); } else { - // Promoted upload dir. A successfully-analyzed (registered) repo always - // has a `.gitnexus` index inside it; a stale promoted dir WITHOUT one is - // an orphan from an analysis that failed before registering — remove it. - const hasIndex = await fsp - .access(path.join(full, '.gitnexus')) - .then(() => true) - .catch(() => false); - if (!hasIndex) { - await fsp.rm(full, { recursive: true, force: true }).catch(() => {}); - removed.push(full); - } + stalePromotedDirs.push(full); } } catch { /* stat race — skip */ } } + // Promoted upload directories are source repositories. Their persistence is + // determined by registry membership, not by whether their index currently + // happens to be materialized or where that index is stored. Registry failure + // must never turn into deletion; staging cleanup above remains independent. + // + // A missing registry.json is first-run emptiness in readRegistryStrict. + // That must not be treated as "nothing is registered" here — we cannot + // prove a promoted dir is unregistered when the file is absent. + let registeredPaths: string[]; + try { + const entries = await readRegistryStrictIfPresent(); + if (entries === undefined) return { removed }; + registeredPaths = entries + .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0) + .map((entry) => canonicalizePath(entry.path)); + } catch { + return { removed }; + } + + for (const full of stalePromotedDirs) { + const canonical = canonicalizePath(full); + const isRegistered = registeredPaths.some((registeredPath) => + registryPathEquals(registeredPath, canonical), + ); + if (!isRegistered) { + await removeSweptDir(full, removed); + } + } + return { removed }; } diff --git a/gitnexus/src/storage/branch-index.ts b/gitnexus/src/storage/branch-index.ts index 96f4a5027..43e3a661e 100644 --- a/gitnexus/src/storage/branch-index.ts +++ b/gitnexus/src/storage/branch-index.ts @@ -68,13 +68,13 @@ export const branchSlug = (rawRef: string): string => { export const resolveBranchPlacement = async ( repoPath: string, label: string | null, + resolvedStoragePath?: string, ): Promise<{ branch?: string }> => { // Detached HEAD / non-git / no label → flat (CI-safe, byte-identical). if (!label) return {}; - // The flat slot only — identical to `getStoragePaths(repoPath).storagePath`, - // which is `getStoragePath(repoPath)` verbatim (the `branch` argument only - // ever scopes `lbugPath`/`metaPath`, never `storagePath`). - const storagePath = getStoragePath(repoPath); + // The resolved flat slot. Callers may pass an ownership-validated path + // (including external storage); otherwise this falls back to getStoragePath. + const storagePath = resolvedStoragePath ?? getStoragePath(repoPath); const flatMeta = await loadMeta(storagePath); // The flat slot's owner is authoritative ONLY when it is a non-empty string. // A corrupt/hand-edited meta (empty string, or a non-string value that slips diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 560c273e5..1c7c1073b 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -250,7 +250,6 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // capture schemas would have shared one PARSE_CACHE_VERSION and the durable // ParsedFile store would have replayed pre-fix ParsedFiles verbatim for one of // them. Only comparing against origin/main at MERGE time surfaces it. -// PR #2840 (Objective-C, draft) still claims 44 as well — it must move too. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // 45 -> 46 for the JavaScript bare-identifier read captures (A2), which emit // `@reference.read.identifier` in value positions (call arguments, @@ -664,6 +663,7 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // `route-extractors/` and `workers/` module content — would close the missing- // bump axis without invalidating on unrelated churn, and is the real follow-up. // RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. +// // 80 -> 81: ParsedFile and parse-cache shards are one immutable `.v8` envelope // each (no JSON/path/generation siblings). A v80 index still names `.json` // keys and would skip workers while scope-resolution found nothing — the diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index b835db848..a4db22b46 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -17,7 +17,12 @@ import fs from 'fs/promises'; import { realpathSync } from 'fs'; import path from 'path'; -import { getInferredRepoName, resolveRepoIdentityRoot, stripUrlCredentials } from './git.js'; +import { + getGitRoot, + getInferredRepoName, + resolveRepoIdentityRoot, + stripUrlCredentials, +} from './git.js'; import { stripWindowsLongPathPrefix } from '../lib/utils.js'; import { writeFileAtomic } from './fs-atomic.js'; import { getGlobalDir } from './global-dir.js'; @@ -41,10 +46,23 @@ import { getStoragePath, isMissingFilesystemError, loadMeta, - tryReadMetaFile, type AnalyzerRunnerIdentity, type RepoMeta, } from './repo-meta.js'; +import { LBUG_DIRECTORY } from './storage-constants.js'; +import { + defaultStoragePath, + ensureStoragePathWritable, + InvalidStoragePathError, + inspectResolvedStorage, + inspectRegisteredStorage, + inspectStoragePath, + isTransientStorageInspection, + LIST_STORAGE_REQUIREMENTS, + requireDeletableStoragePath, + StorageDeletionError, + validateConfiguredStoragePath, +} from './storage-resolver.js'; // Re-export the #2106 branch primitives (extracted to branch-index.ts, R10) so // existing `repo-manager` import sites and tests keep working unchanged. @@ -54,9 +72,12 @@ export type { BranchSummary }; // Re-export the metadata primitives (extracted to repo-meta.ts) for the same // reason. They moved DOWN a layer so `branch-index.ts` can read the flat slot's // metadata without importing back out of this module — see repo-meta.ts for the -// cycle that made the extraction necessary. `LEGACY_METADATA_FILE` and -// `tryReadMetaFile` stay module-private here, exactly as before. +// cycle that made the extraction necessary. `LEGACY_METADATA_FILE` stays +// module-private here, exactly as before. export { getStoragePath, INDEX_METADATA_FILE, isMissingFilesystemError, loadMeta }; +export { ensureStoragePathWritable, InvalidStoragePathError }; +export { CONTENT_RETENTION_SCHEMA_VERSION } from './repo-meta.js'; +export type { ContentRetention, FtsProfile } from './repo-meta.js'; export type { AnalyzerRunnerIdentity, RepoMeta }; export { getGlobalDir } from './global-dir.js'; @@ -194,12 +215,16 @@ const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`; * metaDir is the directory containing the metadata file — both handle the * legacy mirror automatically. */ -export const getStoragePaths = (repoPath: string, branch?: string) => { - const storagePath = getStoragePath(repoPath); +export const getStoragePaths = ( + repoPath: string, + branch?: string, + resolvedStoragePath?: string, +) => { + const storagePath = resolvedStoragePath ?? getStoragePath(repoPath); const baseDir = branch ? path.join(storagePath, BRANCHES_DIR, branchSlug(branch)) : storagePath; return { storagePath, - lbugPath: path.join(baseDir, 'lbug'), + lbugPath: path.join(baseDir, LBUG_DIRECTORY), metaPath: path.join(baseDir, INDEX_METADATA_FILE), // Branch-specific metadata file }; }; @@ -288,99 +313,97 @@ export const saveMeta = async (metaDir: string, meta: RepoMeta): Promise = } }; -/** - * Check if a path has a GitNexus index (metadata file or legacy location) - */ +/** Check whether the resolved storage contains an owned, usable code index. */ export const hasIndex = async (repoPath: string): Promise => { - const paths = getStoragePaths(repoPath); - // Check new metadata file first - try { - await fs.access(paths.metaPath); - return true; - } catch { - // Fall back to legacy location - try { - await fs.access(path.join(paths.storagePath, LEGACY_METADATA_FILE)); - return true; - } catch { - return false; - } - } + const inspection = await inspectResolvedStorage(repoPath); + return inspection.state === 'owned' && inspection.hasCodeIndexDB; }; -/** - * Load an indexed repo from a path (checks metadata file first, then legacy) - */ +/** Load an owned index from one already-determined repository path. */ export const loadRepo = async (repoPath: string): Promise => { - const paths = getStoragePaths(repoPath); + const inspection = await inspectResolvedStorage(repoPath); + // Do not require LadybugDB here: clean needs to locate an owned + // metadata-only slot so it can remove interrupted or legacy remnants. + if (inspection.state !== 'owned') return null; + + const paths = getStoragePaths(inspection.repoPath, undefined, inspection.storagePath); const meta = await loadMeta(paths.storagePath); if (!meta) return null; return { - repoPath: path.resolve(repoPath), + repoPath: inspection.repoPath, ...paths, meta, }; }; -/** `indexedAt` as epoch millis; 0 when absent/unparseable (i.e. oldest). */ -const metaTimestamp = (meta: RepoMeta): number => { - const t = Date.parse(meta.indexedAt ?? ''); - return Number.isFinite(t) ? t : 0; +type ReconcileMetadataRead = + | { state: 'absent' } + | { state: 'invalid'; error: unknown } + | { state: 'valid'; meta: RepoMeta }; + +const readReconcileMetadata = async ( + dir: string, + filename: typeof INDEX_METADATA_FILE | typeof LEGACY_METADATA_FILE, +): Promise => { + let raw: string; + try { + raw = await fs.readFile(path.join(dir, filename), 'utf-8'); + } catch (error) { + return isMissingFilesystemError(error) ? { state: 'absent' } : { state: 'invalid', error }; + } + + try { + return { state: 'valid', meta: JSON.parse(raw) as RepoMeta }; + } catch (error) { + return { state: 'invalid', error }; + } }; /** - * Reconcile `gitnexus.json` and the legacy `meta.json` mirror in one - * directory: whichever parses and is fresher (by `indexedAt`) wins and is - * re-written to BOTH files via `saveMeta`. Never deletes anything. + * Reconcile `gitnexus.json` and the legacy `meta.json` mirror in one directory. + * A valid primary is authoritative; legacy metadata is used only when the + * primary is provably absent. Never deletes anything. * Returns true when a write occurred. */ const reconcileMetaDir = async (dir: string): Promise => { - const primary = await tryReadMetaFile(dir, INDEX_METADATA_FILE); - const legacy = await tryReadMetaFile(dir, LEGACY_METADATA_FILE); - - if (!primary && !legacy) { - // Fresh directory (neither file) is a silent no-op; a file that exists - // but doesn't parse deserves a warning — loadMeta will treat it as "no - // prior index" and the next successful saveMeta self-heals it. - for (const filename of [INDEX_METADATA_FILE, LEGACY_METADATA_FILE]) { - try { - await fs.access(path.join(dir, filename)); - logger.warn( - { dir, filename }, - 'Metadata file exists but is unreadable/corrupt; leaving as-is (next successful analyze rewrites it)', - ); - } catch { - // absent — expected for a fresh directory - } - } + const primary = await readReconcileMetadata(dir, INDEX_METADATA_FILE); + if (primary.state === 'invalid') { + logger.warn( + { dir, filename: INDEX_METADATA_FILE, err: primary.error }, + 'Primary metadata is unreadable/corrupt; leaving both metadata files unchanged', + ); return false; } - if (primary && legacy) { - if (JSON.stringify(primary) === JSON.stringify(legacy)) return false; // converged - // Both parse but differ — the fresher one wins (an older binary may have - // re-analyzed and written only meta.json AFTER gitnexus.json was created; - // blind-preferring the primary would permanently shadow that fresher - // state, silently certifying a stale index as up to date). - const winner = metaTimestamp(legacy) > metaTimestamp(primary) ? legacy : primary; - await saveMeta(dir, winner); - logger.info( - { dir, winner: winner === legacy ? LEGACY_METADATA_FILE : INDEX_METADATA_FILE }, - 'Reconciled diverged metadata files (fresher indexedAt wins, written to both)', - ); + if (primary.state === 'valid') { + const legacy = await readReconcileMetadata(dir, LEGACY_METADATA_FILE); + if (legacy.state === 'valid' && JSON.stringify(primary.meta) === JSON.stringify(legacy.meta)) { + return false; + } + await saveMeta(dir, primary.meta); return true; } - // Exactly one parses — establish/repair the other so both stay in sync. - const survivor = (primary ?? legacy) as RepoMeta; - await saveMeta(dir, survivor); - return true; + const legacy = await readReconcileMetadata(dir, LEGACY_METADATA_FILE); + if (legacy.state === 'valid') { + await saveMeta(dir, legacy.meta); + return true; + } + if (legacy.state === 'invalid') { + logger.warn( + { dir, filename: LEGACY_METADATA_FILE, err: legacy.error }, + 'Legacy metadata is unreadable/corrupt; leaving it unchanged', + ); + } + return false; }; /** * Reconcile the metadata files for a repo's flat slot and every - * `branches//` slot. Runs once per `analyze` (see run-analyze.ts). + * `branches//` slot. `resolvedStoragePath` is the ownership-validated + * write target supplied by analyze; callers that omit it retain the historic + * repository-path lookup for compatibility. * * This is a best-effort compatibility sync, NOT a one-way migration: the * legacy `meta.json` mirror is kept in sync indefinitely (removal happens at @@ -389,8 +412,11 @@ const reconcileMetaDir = async (dir: string): Promise => { * pre-rename version sees current metadata instead of "no prior index". * Returns true when any file was written. */ -export const reconcileMetadataFiles = async (repoPath: string): Promise => { - const storagePath = getStoragePath(repoPath); +export const reconcileMetadataFiles = async ( + repoPath: string, + resolvedStoragePath?: string, +): Promise => { + const storagePath = resolvedStoragePath ?? getStoragePath(repoPath); let changed = await reconcileMetaDir(storagePath); const branchesDir = path.join(storagePath, BRANCHES_DIR); @@ -423,20 +449,10 @@ export const reconcileMetadataFiles = async (repoPath: string): Promise return changed; }; -/** - * Find .gitnexus by walking up from a starting path - */ +/** Resolve the Git worktree first, then load only that repository's index. */ export const findRepo = async (startPath: string): Promise => { - let current = path.resolve(startPath); - const root = path.parse(current).root; - - while (current !== root) { - const repo = await loadRepo(current); - if (repo) return repo; - current = path.dirname(current); - } - - return null; + const resolved = path.resolve(startPath); + return loadRepo(getGitRoot(resolved) ?? resolved); }; export function isReadOnlyFilesystemError(err: unknown): boolean { @@ -447,8 +463,12 @@ export function isReadOnlyFilesystemError(err: unknown): boolean { /** * Keep .gitnexus/ ignored. It contains local index state and caches. */ -export const ensureGitNexusIgnored = async (repoPath: string): Promise => { - const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore'); +export const ensureGitNexusIgnored = async ( + repoPath: string, + resolvedStoragePath?: string, +): Promise => { + const storagePath = resolvedStoragePath ?? getStoragePath(repoPath); + const gitignorePath = path.join(storagePath, '.gitignore'); const desired = '*\n'; // Idempotent fast path: skip the write entirely when the file already has @@ -665,14 +685,7 @@ const isResolvableEntry = (value: unknown): value is RegistryEntry => { * ENOENT is lenient in BOTH modes: no file genuinely means nothing has been * registered yet, and every first-run path depends on that. */ -const readRegistryFile = async (strict: boolean): Promise => { - let raw: string; - try { - raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8'); - } catch (err) { - if (strict && (err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - return []; - } +const parseRegistryContents = async (raw: string, strict: boolean): Promise => { try { // The parse gets its OWN guarded region, narrower than the checks below, // and the parser's error is DISCARDED rather than rethrown. @@ -704,24 +717,50 @@ const readRegistryFile = async (strict: boolean): Promise => { } return []; } + // `storagePath` was not present in pre-external-storage registry files. + // Normalize only that legacy absence at the read boundary; malformed values + // remain visible to the strict destructive-operation safety checks below. + const entries = data.map((entry) => + entry && + typeof entry === 'object' && + !Array.isArray(entry) && + typeof (entry as Record).path === 'string' && + (entry as Record).storagePath === undefined + ? { + ...(entry as Record), + storagePath: defaultStoragePath((entry as Record).path as string), + } + : entry, + ) as RegistryEntry[]; if (strict) { // Reject the WHOLE registry, never filter the bad rows out. Dropping them // would report the repos they name as unregistered, which is precisely // the unreadable-as-missing answer this mode refuses to give. - const bad = data.findIndex((entry) => !isResolvableEntry(entry)); + const bad = entries.findIndex((entry) => !isResolvableEntry(entry)); if (bad !== -1) { throw new Error( `${getGlobalRegistryPath()} entry ${bad} does not identify a repo — name and storagePath must be non-empty strings and path must be a string (registry is corrupt)`, ); } } - return sanitizeEntries(data as RegistryEntry[]); + return sanitizeEntries(entries); } catch (err) { if (strict) throw err; return []; } }; +const readRegistryFile = async (strict: boolean): Promise => { + let raw: string; + try { + raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8'); + } catch (err) { + if (strict && (err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + return []; + } + return parseRegistryContents(raw, strict); +}; + /** * Read the global registry. Returns empty array if not found — and, note, also * when the file exists but cannot be read or parsed. That is fine for a @@ -752,6 +791,23 @@ export const readRegistry = async (): Promise => readRegistryFi */ export const readRegistryStrict = async (): Promise => readRegistryFile(true); +/** + * Strict registry read that distinguishes "file is absent" from "file is + * empty or unreadable". ENOENT returns `undefined`; corrupt/unreadable + * files still throw. Callers that delete based on membership must not treat + * a missing file as an empty registry. + */ +export const readRegistryStrictIfPresent = async (): Promise => { + let raw: string; + try { + raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw err; + } + return parseRegistryContents(raw, true); +}; + /** * Write the global registry to disk. * @@ -808,6 +864,12 @@ export interface RegisterRepoOptions { * existing branch summaries). */ branch?: string; + /** + * The storage slot already selected and validated by the caller. Passing it + * prevents registry registration from re-resolving configuration after an + * analysis or index operation has begun. + */ + storagePath?: string; } /** @@ -903,13 +965,47 @@ const registerRepoUnlocked = async ( // Canonicalisation is applied at COMPARE points only (see below), // which is where the cross-platform divergence actually matters. const resolved = path.resolve(repoPath); - const { storagePath } = getStoragePaths(resolved); + const storagePath = + opts?.storagePath === undefined + ? getStoragePaths(resolved).storagePath + : validateConfiguredStoragePath(opts.storagePath); // Canonical form used strictly for comparison — `realpathSync.native` // expands macOS /var → /private/var and Windows 8.3 → long-name, // falling back to `path.resolve` when the path doesn't exist. const canonicalInput = canonicalizePath(repoPath); + // Production write paths pass the storage slot they already validated. Do + // not let a changed environment/registry redirect their registry entry, and + // require the metadata receipt to describe that same repository and slot. + // The omitted-option path deliberately retains legacy direct-call behavior. + if (opts?.storagePath !== undefined) { + if (!registryPathEquals(canonicalizePath(meta.repoPath), canonicalInput)) { + throw new Error( + `Refusing to register ${resolved}: metadata belongs to ${meta.repoPath}, not this repository.`, + ); + } + if ( + meta.storagePath === undefined && + !registryPathEquals( + canonicalizePath(storagePath), + canonicalizePath(defaultStoragePath(resolved)), + ) + ) { + throw new Error( + `Refusing to register ${resolved}: external storage metadata must bind storagePath to the selected directory.`, + ); + } + if ( + meta.storagePath !== undefined && + !registryPathEquals(canonicalizePath(meta.storagePath), canonicalizePath(storagePath)) + ) { + throw new Error( + `Refusing to register ${resolved}: metadata storagePath does not match the selected storage directory.`, + ); + } + } + // Mutating writes must not treat an unreadable/truncated registry as empty // (#3094): lenient `readRegistry()` returns `[]` on parse failure and would // replace the machine-wide file with only this entry. ENOENT stays empty. @@ -1134,22 +1230,51 @@ export const removeBranchIndex = async (repoPath: string, branch: string): Promi * delete (large sub-index, AV scan, network mount) never blocks every other * registry operation on the machine. */ -export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Promise => { +export const adoptFlatBranchLabel = async ( + repoPath: string, + branch: string, + resolvedStoragePath?: string, +): Promise => { const canonicalInput = canonicalizePath(repoPath); const isRegistered = (list: RegistryEntry[]): number => list.findIndex((e) => registryPathEquals(canonicalizePath(e.path), canonicalInput)); // Cheap membership gate only (#2364 review F2): never touch the disk for an // unregistered repo. The mutate below re-reads its own fresh snapshot. - if (isRegistered(await readRegistry()) < 0) return; // no-op, disk included (no self-heal) + const initialEntries = await readRegistry(); + const initialIdx = isRegistered(initialEntries); + if (initialIdx < 0) return; // no-op, disk included (no self-heal) const resolved = path.resolve(repoPath); - const { storagePath } = getStoragePaths(resolved); + const storagePath = + resolvedStoragePath === undefined + ? getStoragePaths(resolved).storagePath + : validateConfiguredStoragePath(resolvedStoragePath); + const registeredStoragePath = initialEntries[initialIdx].storagePath; + if (!registryPathEquals(canonicalizePath(registeredStoragePath), canonicalizePath(storagePath))) { + throw new Error( + `Refusing to adopt branch metadata: the registry storage path does not match the selected storage directory.`, + ); + } + if (resolvedStoragePath !== undefined) { + const inspection = await inspectStoragePath(storagePath, resolved); + if (inspection.state !== 'owned') { + throw new Error( + `Refusing to adopt branch metadata: storage is not owned by this repository (${inspection.state}).`, + ); + } + } // Remove a shadowed sub-index directory, mirroring `clean --branch`'s // containment guard: the target MUST live under .gitnexus/branches/. - const branchDir = path.join(storagePath, BRANCHES_DIR, branchSlug(branch)); - const branchesRoot = path.join(storagePath, BRANCHES_DIR) + path.sep; + const branchesRoot = path.join(storagePath, BRANCHES_DIR); + const branchDir = path.join(branchesRoot, branchSlug(branch)); + const branchRelativePath = path.relative(branchesRoot, branchDir); + const branchDirIsContained = + branchRelativePath !== '' && + branchRelativePath !== '..' && + !branchRelativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(branchRelativePath); let dirGone = false; - if (branchDir.startsWith(branchesRoot)) { + if (branchDirIsContained) { let rmError: NodeJS.ErrnoException | undefined; await fs.rm(branchDir, { recursive: true, force: true }).catch((err: unknown) => { rmError = err as NodeJS.ErrnoException; @@ -1181,6 +1306,8 @@ export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Pr 'Could not remove the shadowed branch sub-index; keeping its registry summary so `gitnexus clean --branch` can still target it.', ); } + } else { + throw new Error('Refusing to adopt branch metadata: branch storage target escapes branches/.'); } // Re-read AFTER the potentially slow recursive rm, and under the lock: the @@ -1192,6 +1319,9 @@ export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Pr const idx = isRegistered(entries); if (idx < 0) return; // unregistered concurrently → still a no-op const entry = entries[idx]; + if (!registryPathEquals(canonicalizePath(entry.storagePath), canonicalizePath(storagePath))) { + return; // a concurrent registration selected a different slot + } const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches; const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0); if (entry.branch === branch && !droppedSummary) return; // already coherent @@ -1306,21 +1436,32 @@ export const isRepoRegistered = async (repoPath: string): Promise => { * Verify that a successful `analyze` call actually produced an indexed, * registered repo on disk. Two checks, both strictly required: * - * 1. `gitnexus.json` must exist at `/.gitnexus/gitnexus.json` + * 1. `gitnexus.json` must exist in the storage directory selected for this + * analysis (the caller may pass that exact directory). * (the primary metadata file; the legacy `meta.json` mirror is not * sufficient — a finalized analyze always writes the primary). * 2. The global registry (`getGlobalRegistryPath()`) must contain an - * entry whose canonical path matches `repoPath`. + * entry whose canonical path matches `repoPath`. The registry is read + * with {@link readRegistryStrict}: a corrupt or unreadable file throws + * rather than being treated as a missing registry-entry. * * Throws {@link AnalysisNotFinalizedError} on the first failure with the * specific missing artifact. Pure read — does not mutate disk state. * - * Callers must skip this assertion on the `alreadyUpToDate` early-return - * path, where the rebuild was deliberately not run. + * The optional `resolvedStoragePath` carries the exact slot used by the + * analysis. Omitting it preserves the legacy local-resolution behavior for + * direct callers. */ -export const assertAnalysisFinalized = async (repoPath: string): Promise => { +export const assertAnalysisFinalized = async ( + repoPath: string, + resolvedStoragePath?: string, +): Promise => { const resolved = path.resolve(repoPath); - const { storagePath, metaPath } = getStoragePaths(resolved); + const storagePath = + resolvedStoragePath === undefined + ? getStoragePaths(resolved).storagePath + : validateConfiguredStoragePath(resolvedStoragePath); + const metaPath = path.join(storagePath, INDEX_METADATA_FILE); try { await fs.access(metaPath); @@ -1328,7 +1469,14 @@ export const assertAnalysisFinalized = async (repoPath: string): Promise = throw new AnalysisNotFinalizedError(resolved, storagePath, 'meta', getGlobalRegistryPath()); } - if (!(await isRepoRegistered(resolved))) { + const canonicalRepoPath = canonicalizePath(resolved); + const canonicalStoragePath = canonicalizePath(storagePath); + const registeredAtStoragePath = (await readRegistryStrict()).some( + (entry) => + registryPathEquals(canonicalizePath(entry.path), canonicalRepoPath) && + registryPathEquals(canonicalizePath(entry.storagePath), canonicalStoragePath), + ); + if (!registeredAtStoragePath) { throw new AnalysisNotFinalizedError( resolved, storagePath, @@ -1339,13 +1487,12 @@ export const assertAnalysisFinalized = async (repoPath: string): Promise = }; /** - * Thrown by {@link assertSafeStoragePath} when a registry entry's - * `storagePath` does NOT point at the expected `/.gitnexus` - * subfolder. CLI destructive commands (`remove`, `clean --all`) should - * catch this and exit non-zero without deleting anything — the usual - * cause is a corrupted or hand-edited `~/.gitnexus/registry.json`, and - * proceeding would mean `fs.rm(recursive: true)` on whatever odd path - * the entry is pointing at. + * Thrown by {@link assertSafeStoragePath} when {@link requireDeletableStoragePath} + * rejects the registry entry. Repository-local `.gitnexus` may still be + * removed when it is missing, empty, unowned, or foreign; an external slot + * is removable only when metadata binds both the repository and that exact + * path. CLI destructive commands (`remove`, `clean --all`) should catch this + * and exit non-zero without deleting anything. */ export class UnsafeStoragePathError extends Error { readonly kind = 'UnsafeStoragePathError' as const; @@ -1369,9 +1516,10 @@ export class UnsafeStoragePathError extends Error { /** * Guard rail for destructive CLI paths (`remove` #664, * `clean --all` #258, future MCP `remove` tool): verify that a - * registry entry's `storagePath` is the canonical `/.gitnexus` - * subfolder of its `path`. If not, throw {@link UnsafeStoragePathError} - * so the caller exits without touching disk. + * registry entry's `storagePath` names the registered index. Repository-local + * indexes are validated by their canonical `/.gitnexus` path; external + * slots must additionally prove their ownership through matching persisted + * metadata before a recursive deletion is allowed. * * Why this exists (#1003 review — @magyargergo): * - `~/.gitnexus/registry.json` is a plain-text user-writable file. @@ -1388,22 +1536,23 @@ export class UnsafeStoragePathError extends Error { * the registry field. But `clean --all` DOES iterate the registry * and trust each entry's stored storagePath (same shape as * `remove`), so this helper must be wired into that loop too. - * - `server/api.ts` recomputes storagePath from `getStoragePath(entry.path)` - * and so is likewise safe-by-construction. + * - An external slot is intentionally not constrained under a checkout. Its + * own metadata must bind both the source checkout path and the resolved + * storage path before it may be removed. * - * Pure string check — does NOT require the paths to exist on disk. - * Windows: case-insensitive; POSIX: case-sensitive. Matches the - * comparison shape used elsewhere in this module. + * The resolver preserves the legacy local-path allowance while also rejecting + * foreign or malformed metadata. External slots additionally require metadata + * ownership so a hand-edited registry cannot redirect a destructive command to + * an arbitrary directory. */ -export const assertSafeStoragePath = (entry: RegistryEntry): void => { - const expected = path.join(path.resolve(entry.path), '.gitnexus'); - const actual = path.resolve(entry.storagePath); - const matches = - process.platform === 'win32' - ? expected.toLowerCase() === actual.toLowerCase() - : expected === actual; - if (!matches) { - throw new UnsafeStoragePathError(entry, expected, actual); +export const assertSafeStoragePath = async (entry: RegistryEntry): Promise => { + try { + await requireDeletableStoragePath(entry); + } catch (error) { + if (error instanceof StorageDeletionError) { + throw new UnsafeStoragePathError(entry, error.expectedStoragePath, error.actualStoragePath); + } + throw error; } }; @@ -1497,81 +1646,102 @@ export const findRegistryEntryByName = ( /** * List all registered repos from the global registry. * - * With `validate: true`, prunes only entries whose metadata is *provably* gone - * (fs.access on both gitnexus.json and legacy meta.json fails with ENOENT or - * ENOTDIR) and persists the result on a best-effort basis: the pruned view is - * always returned, even when the write fails. Entries that are merely "not provably - * absent" — any other fs.access failure (EIO/EAGAIN/EBUSY/EACCES, etc.) — are - * KEPT, so a transient I/O storm cannot wipe the registry. A kept entry is - * therefore "not confirmed present," not "confirmed present"; downstream DB - * opens are independently and lazily guarded. + * With `validate: true`, returns only entries whose storage is owned by the + * registered repository and contains a LadybugDB index. Entries whose storage + * path is provably gone, empty, or has no ownership metadata are pruned and + * persisted on a best-effort basis. A storage entry that cannot be inspected + * because of a transient filesystem error is kept in the returned view, so an + * I/O storm cannot make the registry disappear; it remains unconfirmed until a + * later validating read succeeds. */ +const mapPool = async ( + items: readonly T[], + mapper: (item: T) => Promise, + concurrency: number, +): Promise => { + if (items.length === 0) return []; + const results = new Array(items.length); + let next = 0; + const workerCount = Math.max(1, Math.min(concurrency, items.length)); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = next; + next += 1; + if (index >= items.length) return; + results[index] = await mapper(items[index] as T); + } + }), + ); + return results; +}; + export const listRegisteredRepos = async (opts?: { validate?: boolean; }): Promise => { const entries = await readRegistry(); if (!opts?.validate) return entries; - // Validate each entry still has a .gitnexus/ directory with metadata + // Validate each entry through the shared storage resolver. The registry's + // storagePath is intentional here: GITNEXUS_STORAGE_PATH must not redirect + // validation of an explicitly registered repository to another slot. const valid: RegistryEntry[] = []; - for (const entry of entries) { - // Named to avoid shadowing the exported `hasIndex` function above. - let indexFound = false; - let firstNonMissingError: NodeJS.ErrnoException | null = null; - let lastMissingError: NodeJS.ErrnoException | null = null; - - // Check for new metadata file first - try { - await fs.access(path.join(entry.storagePath, INDEX_METADATA_FILE)); - indexFound = true; - } catch (err: any) { - if (isMissingFilesystemError(err)) lastMissingError = err; - else firstNonMissingError = err; - } - - // Fall back to legacy meta.json - if (!indexFound) { - try { - await fs.access(path.join(entry.storagePath, LEGACY_METADATA_FILE)); - indexFound = true; - } catch (err: any) { - if (isMissingFilesystemError(err)) lastMissingError = err; - else if (!firstNonMissingError) firstNonMissingError = err; - } - } - - if (indexFound) { + // Keep the exact inspected registry slot, not just the repository path. + // A concurrent analyze may re-register the same checkout into another + // external slot while this read-only validation walk is in flight. + const prunedSlots = new Set(); + const registrySlotKey = (entry: RegistryEntry): string => + `${canonicalizePath(entry.path)}\0${canonicalizePath(entry.storagePath)}`; + const inspections = await mapPool(entries, inspectRegisteredStorage, 8); + for (const [entry, inspection] of entries.map( + (entry, i) => [entry, inspections[i]] as [RegistryEntry, (typeof inspections)[number]], + )) { + const meetsRequirements = + LIST_STORAGE_REQUIREMENTS.allowedStates.includes(inspection.state) && + (!LIST_STORAGE_REQUIREMENTS.requireCodeIndexDB || inspection.hasCodeIndexDB); + if (meetsRequirements) { + valid.push(entry); + } else if ( + inspection.state === 'missing' || + inspection.state === 'empty' || + (inspection.state === 'unowned' && + inspection.reason === 'Storage directory contains data but no valid ownership metadata.') + ) { + // A missing/empty directory or a registry slot with no ownership + // metadata is not a usable index. Removing only the registry row is + // safe; the storage directory itself is never deleted here. + prunedSlots.add(registrySlotKey(entry)); + } else if (isTransientStorageInspection(inspection)) { + // Not provably absent or invalid. Keep the old safety behavior for + // EIO/EAGAIN/EBUSY/EACCES-style filesystem failures. valid.push(entry); - } else if (!firstNonMissingError && lastMissingError) { - // Index genuinely removed — safe to prune } else { - // Not provably absent — keep entry to prevent mass registry wipe. - // Warn so an I/O storm becomes observable instead of silently - // keeping (or, pre-fix, silently wiping) entries. logger.warn( - { name: entry.name, storagePath: entry.storagePath, code: firstNonMissingError?.code }, - 'Keeping registry entry despite fs.access failure (not provably absent); not pruning to avoid mass registry wipe.', + { + name: entry.name, + storagePath: entry.storagePath, + state: inspection.state, + hasCodeIndexDB: inspection.hasCodeIndexDB, + reason: inspection.reason, + }, + 'Skipping registry entry during validation because its storage is not a usable owned code index.', ); - valid.push(entry); } } // If we pruned any entries, save the cleaned registry — under the lock, and - // only then. The validation walk above is read-only (an fs.access per entry, - // slow on a network mount or a large registry) and the common case prunes - // nothing, so holding the global lock across it would serialize every - // `gitnexus augment` behind unrelated registry work for no benefit. Re-read - // inside the lock and drop the provably-absent paths from that fresh - // snapshot, so a concurrent registration in the validation window survives. - if (valid.length !== entries.length) { - const pruned = new Set( - entries.filter((entry) => !valid.includes(entry)).map((entry) => entry.path), - ); + // only then. The validation walk above is read-only and can touch several + // files per entry, so holding the global lock across it would serialize + // every `gitnexus augment` behind unrelated registry work for no benefit. + // Re-read inside the lock and drop only the same provably-absent storage + // slots from that fresh snapshot, so a concurrent re-registration of the + // same repository path into another slot survives. + if (prunedSlots.size > 0) { try { await withRegistryLock(async () => { const fresh = await readRegistry(); await writeRegistry( - fresh.filter((entry) => !pruned.has(entry.path)), + fresh.filter((entry) => !prunedSlots.has(registrySlotKey(entry))), 1, ); }); @@ -1581,7 +1751,7 @@ export const listRegisteredRepos = async (opts?: { // — this runs on MCP startup (LocalBackend.init → refreshRepos), where // nothing catches and a rejection reads as "Server disconnected". logger.warn( - { err, prunedCount: pruned.size }, + { err, prunedCount: prunedSlots.size }, 'Could not persist the pruned global registry; continuing with the in-memory pruned view.', ); } diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index 3796eb561..363900b4c 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -3,8 +3,9 @@ * * Holds the on-disk shape of a GitNexus index's metadata file * (`.gitnexus/gitnexus.json`, plus its legacy `meta.json` mirror) and the - * read-side helpers that locate and parse it. Nothing here writes, and nothing - * here knows about the global registry. + * read-side helpers that locate and parse it. Nothing here writes. Path + * lookup may consult configured or registered storage via `resolveStoragePath`; + * registry mutation stays in `repo-manager.ts`. * * Why it is its own module: `repo-manager.ts` owns the registry and the write * side, and `branch-index.ts` (#2106) owns the multi-branch slug/placement @@ -30,14 +31,16 @@ import path from 'path'; import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js'; import type { NameFallbackSummary } from '../core/ingestion/scope-resolution/name-fallback-summary.js'; import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js'; +import { resolveStoragePath } from './storage-resolver.js'; import type { ScopeExtractionFailureSummary } from '../core/ingestion/scope-resolution/scope-extraction-failures.js'; +import { INDEX_METADATA_FILE, LEGACY_METADATA_FILE } from './storage-constants.js'; -/** The `.gitnexus` directory name, relative to a repo root. */ -export const GITNEXUS_DIR = '.gitnexus'; -export const INDEX_METADATA_FILE = 'gitnexus.json'; -// Dual-written mirror of INDEX_METADATA_FILE, kept for backward compatibility -// with consumers that only know the pre-rename filename (see MIGRATION.md). -export const LEGACY_METADATA_FILE = 'meta.json'; +export { GITNEXUS_DIR, INDEX_METADATA_FILE, LEGACY_METADATA_FILE } from './storage-constants.js'; + +/** How much source text an index is allowed to persist. */ +export type ContentRetention = 'full' | 'symbol' | 'none'; +export type FtsProfile = 'full' | 'symbol-no-file-content' | 'name-only'; +export const CONTENT_RETENTION_SCHEMA_VERSION = 1; /** * Versioned receipt for the analyzer process that produced an index. @@ -85,8 +88,14 @@ export interface AnalyzerRunnerIdentity { export interface RepoMeta { repoPath: string; + /** Complete index directory selected for this successful analysis. */ + storagePath?: string; lastCommit: string; indexedAt: string; + /** Missing on legacy metadata means the upstream-compatible `full` profile. */ + contentRetention?: ContentRetention; + contentRetentionSchemaVersion?: number; + ftsProfile?: FtsProfile; /** * Runtime enrichment mode plus redacted scan exclusions. Payload data and * absolute/external paths are deliberately excluded from metadata. @@ -579,11 +588,12 @@ export interface RepoMeta { } /** - * Get the .gitnexus storage path for a repository. - * Used for local metadata and caches that are not committed. + * Resolve the configured storage path for a repository. + * This can be its repository-local `.gitnexus` directory, an external slot, + * or a previously registered storage path. */ export const getStoragePath = (repoPath: string): string => { - return path.join(path.resolve(repoPath), GITNEXUS_DIR); + return resolveStoragePath(repoPath); }; /** diff --git a/gitnexus/src/storage/storage-constants.ts b/gitnexus/src/storage/storage-constants.ts new file mode 100644 index 000000000..07bd16903 --- /dev/null +++ b/gitnexus/src/storage/storage-constants.ts @@ -0,0 +1,6 @@ +/** Shared on-disk names for GitNexus repository indexes. */ +export const GITNEXUS_DIR = '.gitnexus'; +export const INDEX_METADATA_FILE = 'gitnexus.json'; +// Dual-written mirror kept for consumers that still use the pre-rename name. +export const LEGACY_METADATA_FILE = 'meta.json'; +export const LBUG_DIRECTORY = 'lbug'; diff --git a/gitnexus/src/storage/storage-resolver.ts b/gitnexus/src/storage/storage-resolver.ts new file mode 100644 index 000000000..b44dcc4ad --- /dev/null +++ b/gitnexus/src/storage/storage-resolver.ts @@ -0,0 +1,736 @@ +import { createHash } from 'node:crypto'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import path from 'path'; +import { stripWindowsLongPathPrefix } from '../lib/utils.js'; +import { getGlobalDir } from './global-dir.js'; +import { + GITNEXUS_DIR, + INDEX_METADATA_FILE, + LEGACY_METADATA_FILE, + LBUG_DIRECTORY, +} from './storage-constants.js'; + +export const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH'; +export const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT'; + +const STORAGE_SLOT_HASH_LENGTH = 12; + +/** File-backend lock sidecars (`index-lock.ts`). Not ownership data. */ +const INDEX_LOCK_ARTIFACTS = new Set(['analyze.lock', 'analyze.lock.guard']); + +export type StorageState = + | 'invalid_param' + | 'missing' + | 'invalid_storage' + | 'empty' + | 'unowned' + | 'owned' + | 'foreign'; + +type MetadataFilename = typeof INDEX_METADATA_FILE | typeof LEGACY_METADATA_FILE; + +export interface StorageInspection { + repoPath: string; + storagePath: string; + state: StorageState; + hasCodeIndexDB: boolean; + reason?: string; +} + +export interface StorageRequirements { + allowedStates: readonly StorageState[]; + requireCodeIndexDB?: boolean; +} + +export const ANALYZE_STORAGE_REQUIREMENTS = { + allowedStates: ['missing', 'empty', 'owned'], +} as const satisfies StorageRequirements; + +export const ANALYZE_FORCE_STORAGE_REQUIREMENTS = { + allowedStates: ['missing', 'empty', 'owned', 'unowned', 'foreign'], +} as const satisfies StorageRequirements; + +export const INDEX_STORAGE_REQUIREMENTS = { + allowedStates: ['owned'], + requireCodeIndexDB: true, +} as const satisfies StorageRequirements; + +export const INDEX_FORCE_STORAGE_REQUIREMENTS = { + allowedStates: ['owned', 'unowned', 'foreign'], + requireCodeIndexDB: true, +} as const satisfies StorageRequirements; + +export const STATUS_STORAGE_REQUIREMENTS: StorageRequirements = { + allowedStates: ['owned'], + requireCodeIndexDB: true, +}; + +export const LIST_STORAGE_REQUIREMENTS = STATUS_STORAGE_REQUIREMENTS; + +export const getIndexStorageRequirements = (force: boolean): StorageRequirements => + force ? INDEX_FORCE_STORAGE_REQUIREMENTS : INDEX_STORAGE_REQUIREMENTS; + +interface RegistryStorageEntry { + path?: unknown; + storagePath?: unknown; +} + +interface OwnershipMetadata { + repoPath: string; + storagePath?: string; +} + +type MetadataReadResult = + | { state: 'absent' } + | { state: 'invalid'; reason: string } + | { state: 'valid'; value: OwnershipMetadata }; + +const TRANSIENT_FILESYSTEM_CODES = new Set([ + 'EACCES', + 'EAGAIN', + 'EBUSY', + 'EIO', + 'EMFILE', + 'ENFILE', + 'EPERM', + 'EROFS', +]); + +export class InvalidStoragePathError extends Error { + readonly kind = 'InvalidStoragePathError' as const; + + constructor(message: string) { + super(message); + this.name = 'InvalidStoragePathError'; + } +} + +export class StorageRequirementError extends Error { + readonly kind = 'StorageRequirementError' as const; + + constructor( + public readonly inspection: StorageInspection, + public readonly requirements: StorageRequirements, + ) { + const storagePath = inspection.storagePath || ''; + const stateAllowed = requirements.allowedStates.includes(inspection.state); + const detail = stateAllowed + ? `Storage path does not contain a LadybugDB code index: ${storagePath}.` + : `Storage path is in state "${inspection.state}" but requires one of: ${requirements.allowedStates.join(', ')}.`; + super(inspection.reason ? `${detail} ${inspection.reason}` : detail); + this.name = 'StorageRequirementError'; + } +} + +/** Raised when a destructive command cannot prove that a storage path is safe to remove. */ +export class StorageDeletionError extends Error { + readonly kind = 'StorageDeletionError' as const; + + constructor( + public readonly expectedStoragePath: string, + public readonly actualStoragePath: string, + public readonly inspection?: StorageInspection, + detail = 'the storage path is not owned by the registered repository', + ) { + super( + `Refusing to remove storage path for safety: ${detail}. ` + + `Expected "${expectedStoragePath}" or an externally owned index, ` + + `but the registry entry has "${actualStoragePath}". ` + + `This usually means the registry entry is corrupted or was hand-edited. ` + + `Delete the entry manually from ~/.gitnexus/registry.json and re-run analyze.`, + ); + this.name = 'StorageDeletionError'; + } +} + +const registryPath = (): string => path.join(getGlobalDir(), 'registry.json'); + +/** CLI "no usable index" — missing/empty, or owned metadata without LadybugDB. */ +export const isUnusableIndexInspection = ( + inspection: Pick, +): boolean => + inspection.state === 'missing' || + inspection.state === 'empty' || + (inspection.state === 'owned' && !inspection.hasCodeIndexDB); + +const samePath = (left: string, right: string): boolean => + process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; + +const isRepositoryLocalStoragePath = (repoPath: string, storagePath: string): boolean => + samePath(comparablePath(defaultStoragePath(repoPath)), comparablePath(storagePath)); + +const isMissingFilesystemError = (error: unknown): boolean => { + const code = (error as NodeJS.ErrnoException)?.code; + return code === 'ENOENT' || code === 'ENOTDIR'; +}; + +const filesystemErrorDetail = (error: unknown): string => { + const code = (error as NodeJS.ErrnoException)?.code; + return code ? `${code}: ${(error as Error)?.message ?? String(error)}` : String(error); +}; + +const resolveRepoPath = (value: string): string => { + if (typeof value !== 'string' || value.length === 0) { + throw new InvalidStoragePathError('Repository path must be non-empty.'); + } + if (value.includes('\0')) { + throw new InvalidStoragePathError('Repository path must not contain a NUL character.'); + } + return path.resolve(value); +}; + +const validateAbsolutePath = (value: string, label: string): string => { + if (typeof value !== 'string' || value.length === 0) { + throw new InvalidStoragePathError(`${label} must be an absolute, non-empty path.`); + } + if (value.includes('\0')) { + throw new InvalidStoragePathError(`${label} must not contain a NUL character.`); + } + if (!path.isAbsolute(value)) { + throw new InvalidStoragePathError(`${label} must be an absolute path.`); + } + return path.resolve(value); +}; + +// Mirror registry lookup semantics without importing repo-manager and creating a cycle. +const canonicalRegistryPath = (value: string): string => { + const resolved = path.resolve(value); + try { + return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved)); + } catch { + return stripWindowsLongPathPrefix(resolved); + } +}; + +const canonicalRepoPath = (repoPath: string): string => + canonicalRegistryPath(resolveRepoPath(repoPath)); + +const comparablePath = (value: string): string => { + const canonical = canonicalRegistryPath(value); + return process.platform === 'win32' ? canonical.toLowerCase() : canonical; +}; + +const sanitizeSlotBasename = (value: string): string => { + // Linear: a quantified `/[. ]+$/` on attacker-controlled basenames is + // js/polynomial-redos (CodeQL #1056). Cap first, then walk the tail once. + const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); + let end = sanitized.length; + while (end > 0) { + const code = sanitized.charCodeAt(end - 1); + if (code !== 0x20 && code !== 0x2e) break; + end--; + } + const candidate = sanitized.slice(0, end) || 'repository'; + return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) + ? `repository-${candidate}` + : candidate; +}; + +/** + * Stable slot name for one checkout inside a configured external storage root. + * The canonical absolute path prevents symlink aliases from creating duplicate + * slots, while the hash keeps same-basename repositories isolated. + */ +export const storageSlotName = (repoPath: string): string => { + const canonical = canonicalRepoPath(repoPath); + const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical; + const basename = sanitizeSlotBasename(path.basename(canonical)); + const digest = createHash('sha256') + .update(identity) + .digest('hex') + .slice(0, STORAGE_SLOT_HASH_LENGTH); + return `${basename}-${digest}`; +}; + +export const defaultStoragePath = (repoPath: string): string => + path.join(resolveRepoPath(repoPath), GITNEXUS_DIR); + +export const validateConfiguredStoragePath = (value: string): string => { + const resolved = validateAbsolutePath(value, 'Storage path'); + const parent = path.dirname(resolved); + const base = path.basename(resolved); + if (base.length === 0) { + throw new InvalidStoragePathError('Storage path must not be a filesystem root.'); + } + // Rebuild through parent + basename and apply the path.relative idiom + // CodeQL's js/path-injection sanitizer recognizes. The reconstructed path + // is what callers pass to filesystem APIs. + const inspected = path.resolve(parent, base); + const rel = path.relative(parent, inspected); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new InvalidStoragePathError('Storage path escaped its parent directory.'); + } + return inspected; +}; + +/** Resolve one repository's isolated slot under an external storage root. */ +export const storagePathFromRoot = (rootPath: string, repoPath: string): string => { + const root = validateAbsolutePath(rootPath, STORAGE_ROOT_ENV); + const storagePath = path.resolve(root, storageSlotName(repoPath)); + const rel = path.relative(root, storagePath); + if ( + rel === '' || + rel.startsWith('..') || + path.isAbsolute(rel) || + !samePath(path.dirname(storagePath), root) + ) { + throw new InvalidStoragePathError( + `Resolved storage path must remain directly inside ${STORAGE_ROOT_ENV}.`, + ); + } + return storagePath; +}; + +const configuredStoragePath = (): string | undefined => { + const value = process.env[STORAGE_PATH_ENV]; + return value === undefined ? undefined : validateConfiguredStoragePath(value); +}; + +const configuredStorageRoot = (repoPath: string): string | undefined => { + const value = process.env[STORAGE_ROOT_ENV]; + return value === undefined ? undefined : storagePathFromRoot(value, repoPath); +}; + +const registeredStoragePath = (repoPath: string): string | undefined => { + let entries: unknown[]; + try { + const data = JSON.parse(fs.readFileSync(registryPath(), 'utf-8')); + if (!Array.isArray(data)) return undefined; + entries = data; + } catch { + return undefined; + } + + const resolvedRepoPath = canonicalRegistryPath(repoPath); + for (const entry of entries) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue; + const registryEntry = entry as RegistryStorageEntry; + if (typeof registryEntry.path !== 'string') continue; + if (!samePath(canonicalRegistryPath(registryEntry.path), resolvedRepoPath)) continue; + if (registryEntry.storagePath === undefined) { + // Pre-external-storage rows have no storagePath. Match readRegistry(): + // fall through to the repository-local default instead of failing closed. + return undefined; + } + if (typeof registryEntry.storagePath !== 'string') { + throw new InvalidStoragePathError( + `Registered storage path for ${repoPath} must be an absolute, non-empty path.`, + ); + } + return validateConfiguredStoragePath(registryEntry.storagePath); + } + return undefined; +}; + +/** Resolve one repository's complete index directory. */ +export const resolveStoragePath = (repoPath: string): string => { + const resolvedRepoPath = resolveRepoPath(repoPath); + const configuredPath = configuredStoragePath(); + if (configuredPath) return configuredPath; + + const configuredRoot = configuredStorageRoot(resolvedRepoPath); + if (configuredRoot) return configuredRoot; + + const registered = registeredStoragePath(resolvedRepoPath); + if (registered) return registered; + + return defaultStoragePath(resolvedRepoPath); +}; + +const readOwnershipMetadata = async ( + storagePath: string, + filename: MetadataFilename, +): Promise => { + const storageRoot = path.resolve(storagePath); + const metadataPath = path.resolve(storageRoot, filename); + // Inline at the readFile sink — CodeQL does not treat a helper return as a + // js/path-injection sanitizer across calls (see handleFileRequest). + const metadataRel = path.relative(storageRoot, metadataPath); + if (metadataRel.startsWith('..') || path.isAbsolute(metadataRel)) { + return { state: 'invalid', reason: `${filename} is not contained in the storage directory.` }; + } + let raw: string; + try { + raw = await fsp.readFile(metadataPath, 'utf-8'); + } catch (error) { + return isMissingFilesystemError(error) + ? { state: 'absent' } + : { + state: 'invalid', + reason: `${filename} could not be read: ${filesystemErrorDetail(error)}`, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { state: 'invalid', reason: `${filename} is not valid JSON.` }; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { state: 'invalid', reason: `${filename} must contain a JSON object.` }; + } + + const record = parsed as Record; + if (typeof record.repoPath !== 'string') { + return { state: 'invalid', reason: `${filename} does not contain a valid repoPath.` }; + } + + let repoPath: string; + let storagePathValue: string | undefined; + try { + repoPath = validateAbsolutePath(record.repoPath, `${filename} repoPath`); + if (record.storagePath !== undefined) { + if (typeof record.storagePath !== 'string') { + return { state: 'invalid', reason: `${filename} contains an invalid storagePath.` }; + } + storagePathValue = validateConfiguredStoragePath(record.storagePath); + } + } catch (error) { + return { + state: 'invalid', + reason: error instanceof Error ? error.message : `${filename} contains invalid paths.`, + }; + } + + return { state: 'valid', value: { repoPath, storagePath: storagePathValue } }; +}; + +/** Check for the LadybugDB path independently from metadata ownership. */ +const inspectCodeIndexDB = async ( + storagePath: string, +): Promise<{ present: boolean; transientCode?: string }> => { + const resolved = validateConfiguredStoragePath(storagePath); + const lbugPath = path.resolve(resolved, LBUG_DIRECTORY); + const lbugRel = path.relative(resolved, lbugPath); + if (lbugRel.startsWith('..') || path.isAbsolute(lbugRel)) { + return { present: false }; + } + try { + await fsp.access(lbugPath); + return { present: true }; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + return { + present: false, + ...(code && TRANSIENT_FILESYSTEM_CODES.has(code) ? { transientCode: code } : {}), + }; + } +}; + +/** Whether an inspection failed because the filesystem could not be read reliably. */ +export const isTransientStorageInspection = (inspection: StorageInspection): boolean => { + const reason = inspection.reason ?? ''; + return [...TRANSIENT_FILESYSTEM_CODES].some((code) => reason.includes(`${code}:`)); +}; + +/** + * Inspect filesystem and metadata facts without deciding whether a command may + * read, write, adopt, or delete the slot. Callers apply their scenario policy + * to the returned state. `gitnexus.json` remains primary; legacy metadata is + * selected only when the primary file is provably absent. + */ +export const inspectStoragePath = async ( + storagePath: string, + repoPath: string, +): Promise => { + let context: Pick; + try { + const resolvedRepoPath = resolveRepoPath(repoPath); + const resolvedStoragePath = validateConfiguredStoragePath(storagePath); + context = { + repoPath: resolvedRepoPath, + storagePath: resolvedStoragePath, + }; + } catch (error) { + return { + repoPath, + storagePath, + state: 'invalid_param', + hasCodeIndexDB: false, + reason: error instanceof Error ? error.message : 'Invalid storage parameters.', + }; + } + + const storageParent = path.dirname(context.storagePath); + const storageBase = path.basename(context.storagePath); + if (storageBase.length === 0) { + return { + ...context, + state: 'invalid_param', + hasCodeIndexDB: false, + reason: 'Storage path must not be a filesystem root.', + }; + } + // Rebuild the inspected directory through parent + basename and keep the + // path.relative barrier on this SSA value at every filesystem sink. + const inspectedStorage = path.resolve(storageParent, storageBase); + const inspectedRel = path.relative(storageParent, inspectedStorage); + if (inspectedRel.startsWith('..') || path.isAbsolute(inspectedRel)) { + return { + ...context, + state: 'invalid_param', + hasCodeIndexDB: false, + reason: 'Storage path escaped its parent directory.', + }; + } + context = { ...context, storagePath: inspectedStorage }; + + const repositoryLocal = isRepositoryLocalStoragePath(context.repoPath, inspectedStorage); + let directoryEntries: string[]; + let codeIndex: Awaited>; + let primary: MetadataReadResult; + try { + const linkStat = await fsp.lstat(inspectedStorage); + const targetStat = linkStat.isSymbolicLink() ? await fsp.stat(inspectedStorage) : linkStat; + if (!targetStat.isDirectory()) { + return { + ...context, + state: 'invalid_storage', + hasCodeIndexDB: false, + reason: 'Storage path exists but is not a directory.', + }; + } + const [entries, codeIndexResult, primaryResult] = await Promise.all([ + fsp.readdir(inspectedStorage), + inspectCodeIndexDB(inspectedStorage), + readOwnershipMetadata(inspectedStorage, INDEX_METADATA_FILE), + ]); + directoryEntries = entries; + codeIndex = codeIndexResult; + primary = primaryResult; + } catch (error) { + if (isMissingFilesystemError(error)) { + return { ...context, state: 'missing', hasCodeIndexDB: false }; + } + return { + ...context, + state: 'invalid_storage', + hasCodeIndexDB: false, + reason: `Storage directory could not be inspected: ${filesystemErrorDetail(error)}`, + }; + } + + const hasCodeIndexDB = codeIndex.present; + const transientDBReason = codeIndex.transientCode + ? `${codeIndex.transientCode}: LadybugDB directory could not be inspected.` + : undefined; + if (primary.state === 'invalid') { + return { + ...context, + state: 'invalid_storage', + hasCodeIndexDB, + reason: primary.reason, + }; + } + + let metadata: OwnershipMetadata; + if (primary.state === 'valid') { + metadata = primary.value; + } else { + const legacy = await readOwnershipMetadata(context.storagePath, LEGACY_METADATA_FILE); + if (legacy.state === 'invalid') { + return { + ...context, + state: 'invalid_storage', + hasCodeIndexDB, + reason: legacy.reason, + }; + } + if (legacy.state === 'absent') { + const hasNonLockEntries = directoryEntries.some((name) => !INDEX_LOCK_ARTIFACTS.has(name)); + return { + ...context, + state: hasNonLockEntries ? 'unowned' : 'empty', + hasCodeIndexDB, + reason: + transientDBReason ?? + (hasNonLockEntries + ? 'Storage directory contains data but no valid ownership metadata.' + : undefined), + }; + } + metadata = legacy.value; + } + + const repoMatches = samePath(comparablePath(metadata.repoPath), comparablePath(context.repoPath)); + const storageMatches = + metadata.storagePath !== undefined && + samePath(comparablePath(metadata.storagePath), comparablePath(context.storagePath)); + + let state: StorageState; + let reason: string | undefined; + if (!repoMatches || (metadata.storagePath !== undefined && !storageMatches)) { + state = 'foreign'; + reason = 'Storage metadata identifies a different repository or storage directory.'; + } else if (!repositoryLocal && metadata.storagePath === undefined) { + state = 'unowned'; + reason = 'External storage metadata does not bind the index to this storage directory.'; + } else { + state = 'owned'; + } + + return { + ...context, + state, + hasCodeIndexDB, + reason: reason ?? transientDBReason, + }; +}; + +/** Resolve and inspect a repo-initiated storage lookup in one operation. */ +export const inspectResolvedStorage = async (repoPath: string): Promise => { + let storagePath: string; + try { + storagePath = resolveStoragePath(repoPath); + } catch (error) { + return { + repoPath, + storagePath: '', + state: 'invalid_param', + hasCodeIndexDB: false, + reason: error instanceof Error ? error.message : 'Storage path could not be resolved.', + }; + } + return inspectStoragePath(storagePath, repoPath); +}; + +/** + * Inspect a registry-selected slot without allowing an environment override to + * redirect the entry to another repository's storage. + */ +export const inspectRegisteredStorage = async (entry: { + path: string; + storagePath: string; +}): Promise => inspectStoragePath(entry.storagePath, entry.path); + +const requireInspectedStoragePath = ( + inspection: StorageInspection, + requirements: StorageRequirements, +): string => { + if (!requirements.allowedStates.includes(inspection.state)) { + throw new StorageRequirementError(inspection, requirements); + } + // `foreign` is only adoptable for this checkout's own `.gitnexus`. An + // external slot that names another repository stays rejected even when the + // caller opted into `foreign` (analyze/index --force). + if ( + inspection.state === 'foreign' && + !isRepositoryLocalStoragePath(inspection.repoPath, inspection.storagePath) + ) { + throw new StorageRequirementError(inspection, { + ...requirements, + allowedStates: requirements.allowedStates.filter((state) => state !== 'foreign'), + }); + } + if (requirements.requireCodeIndexDB && !inspection.hasCodeIndexDB) { + throw new StorageRequirementError(inspection, requirements); + } + return inspection.storagePath; +}; + +/** Resolve and validate storage selected from a repository path. */ +export const requireStoragePath = async ( + repoPath: string, + requirements: StorageRequirements, +): Promise => + requireInspectedStoragePath(await inspectResolvedStorage(repoPath), requirements); + +/** Validate the exact storage path persisted in a registry entry. */ +export const requireRegisteredStoragePath = async ( + entry: { path: string; storagePath: string }, + requirements: StorageRequirements, +): Promise => + requireInspectedStoragePath(await inspectRegisteredStorage(entry), requirements); + +const isPathAncestor = (ancestor: string, child: string): boolean => { + const relative = path.relative(ancestor, child); + return ( + relative === '' || + (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); +}; + +/** + * Resolve a registry entry for a destructive operation. + * + * Repository-local `.gitnexus` is a path-owned namespace, so it remains + * removable when it is missing, empty, contains data without metadata, or + * carries foreign metadata (this checkout's directory, another repo's stamp). + * External storage has no such physical ownership proof and therefore must + * contain metadata binding both the repository and the exact storage path. + * External foreign or malformed metadata is never removable. + */ +export const requireDeletableStoragePath = async (entry: { + path: string; + storagePath: string; +}): Promise => { + let repoPath: string; + let actualStoragePath: string; + try { + repoPath = resolveRepoPath(entry.path); + actualStoragePath = validateConfiguredStoragePath(entry.storagePath); + } catch (error) { + const rawStoragePath = + typeof entry.storagePath === 'string' + ? path.resolve(entry.storagePath) + : String(entry.storagePath); + const fallbackRepoPath = + typeof entry.path === 'string' && entry.path.length > 0 && !entry.path.includes('\0') + ? entry.path + : process.cwd(); + throw new StorageDeletionError( + defaultStoragePath(fallbackRepoPath), + rawStoragePath, + undefined, + error instanceof Error ? error.message : 'the registry storage path is invalid', + ); + } + + const expectedStoragePath = defaultStoragePath(repoPath); + const storageIsLocal = isRepositoryLocalStoragePath(repoPath, actualStoragePath); + const comparableStorage = comparablePath(actualStoragePath); + const comparableRepo = comparablePath(repoPath); + const comparableRoot = comparablePath(path.parse(actualStoragePath).root); + if ( + samePath(comparableStorage, comparableRoot) || + samePath(comparableStorage, comparableRepo) || + isPathAncestor(comparableStorage, comparableRepo) + ) { + throw new StorageDeletionError( + expectedStoragePath, + actualStoragePath, + undefined, + 'the target is the repository, one of its parents, or a filesystem root', + ); + } + + const inspection = await inspectRegisteredStorage({ + path: repoPath, + storagePath: actualStoragePath, + }); + const allowedStates: readonly StorageState[] = storageIsLocal + ? ['missing', 'empty', 'unowned', 'owned', 'foreign'] + : ['owned']; + if (!allowedStates.includes(inspection.state)) { + throw new StorageDeletionError( + expectedStoragePath, + actualStoragePath, + inspection, + `the storage inspection state is "${inspection.state}"`, + ); + } + return actualStoragePath; +}; + +/** Ensure a selected index directory is usable before an analysis takes its lock. */ +export const ensureStoragePathWritable = async (storagePath: string): Promise => { + const resolved = validateConfiguredStoragePath(storagePath); + await fsp.mkdir(resolved, { recursive: true }); + const stat = await fsp.stat(resolved); + if (!stat.isDirectory()) { + throw new InvalidStoragePathError(`Index storage path is not a directory: ${resolved}`); + } + await fsp.access(resolved, fs.constants.R_OK | fs.constants.W_OK); +}; diff --git a/gitnexus/test/integration/antigravity-hook-e2e.test.ts b/gitnexus/test/integration/antigravity-hook-e2e.test.ts index b6122b95d..fc19d2f30 100644 --- a/gitnexus/test/integration/antigravity-hook-e2e.test.ts +++ b/gitnexus/test/integration/antigravity-hook-e2e.test.ts @@ -23,7 +23,7 @@ import path from 'path'; import { cleanupTempDir, cleanupTempDirSync } from '../helpers/test-db.js'; import os from 'os'; import { - runHook, + runHook as spawnHook, parseHookOutput, createGitNexusPathEntry, createHookToolDir, @@ -37,6 +37,7 @@ let tempHome: string; let installedHook: string; let tmpDir: string; let gitNexusDir: string; +let registryHome: string; const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; @@ -76,6 +77,7 @@ beforeAll(async () => { 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1', 'resolve-analyze-cmd.cjs', + 'registry-query.cjs', ]) { const helperPath = path.join(path.dirname(installedHook), helper); if (!fs.existsSync(helperPath)) { @@ -90,6 +92,19 @@ beforeAll(async () => { initGitRepo(tmpDir, { name: 'Test', email: 'test@test.com' }); fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello'); commitAll(tmpDir, 'init'); + + registryHome = path.join(tempHome, 'gitnexus-home'); + fs.mkdirSync(registryHome, { recursive: true }); + fs.writeFileSync( + path.join(registryHome, 'registry.json'), + JSON.stringify([ + { + name: 'antigravity-e2e', + path: tmpDir, + storagePath: gitNexusDir, + }, + ]), + ); }); afterAll(async () => { @@ -99,6 +114,18 @@ afterAll(async () => { if (tmpDir) cleanupTempDirSync(tmpDir); }); +function runHook( + hookPath: string, + input: Record, + cwd?: string, + options: { env?: NodeJS.ProcessEnv } = {}, +) { + return spawnHook(hookPath, input, cwd, { + ...options, + env: { ...(options.env ?? process.env), GITNEXUS_HOME: registryHome }, + }); +} + describe('antigravity hook adapter e2e', () => { describe('AfterTool — stale-index hint after git mutations', () => { // #1913: by default the hint reaches the agent via additionalContext (stdout diff --git a/gitnexus/test/integration/augmentation.test.ts b/gitnexus/test/integration/augmentation.test.ts index 32a3447e0..460df12a6 100644 --- a/gitnexus/test/integration/augmentation.test.ts +++ b/gitnexus/test/integration/augmentation.test.ts @@ -57,6 +57,16 @@ vi.mock('../../src/storage/repo-manager.js', () => ({ listRegisteredRepos: vi.fn(), })); +vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requireRegisteredStoragePath: vi.fn( + async (entry: { storagePath: string }) => entry.storagePath, + ), + }; +}); + let augment: (pattern: string, cwd?: string) => Promise; let augmentNoFts: (pattern: string, cwd?: string) => Promise; @@ -109,6 +119,17 @@ withTestLbugDB( expect(typeof result).toBe('string'); }); + it('silently degrades when the registered storage path fails validation', async () => { + const { requireRegisteredStoragePath } = + await import('../../src/storage/storage-resolver.js'); + const resolverMock = vi.mocked(requireRegisteredStoragePath); + resolverMock.mockRejectedValueOnce(new Error('foreign storage')); + + const result = await augment('login', handle.dbPath); + + expect(result).toBe(''); + }); + // ─── Negative-safety: fallback must stay gated on !ftsAvailable ─── // // When FTS is available but happens to return zero BM25 hits, the @@ -164,7 +185,7 @@ withTestLbugDB( } }); - it('matches CWD at root level and repo in sub-directory (repo at /src, CWD at /)', async () => { + it('does not attach a nested registered checkout from a parent cwd (repo at /src, CWD at /)', async () => { const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js'); const rootPath = path.resolve('/'); const subDir = path.join(rootPath, 'src'); @@ -181,8 +202,7 @@ withTestLbugDB( try { const result = await augment('login', rootPath); - expect(result.length).toBeGreaterThan(0); - expect(result).toContain('[GitNexus]'); + expect(result).toBe(''); } finally { (listRegisteredRepos as ReturnType).mockResolvedValue([ { @@ -226,7 +246,7 @@ withTestLbugDB( } }); - it('matches Windows sub-directory repo and drive-root CWD (repo at C:\\src, CWD at C:\\)', async () => { + it('does not attach a nested Windows checkout from a drive-root cwd (repo at C:\\src, CWD at C:\\)', async () => { const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js'); (listRegisteredRepos as ReturnType).mockResolvedValue([ @@ -241,7 +261,7 @@ withTestLbugDB( try { const result = await augment('login', 'C:\\'); - expect(result.length).toBeGreaterThan(0); + expect(result).toBe(''); } finally { (listRegisteredRepos as ReturnType).mockResolvedValue([ { diff --git a/gitnexus/test/integration/context-resource-staleness.test.ts b/gitnexus/test/integration/context-resource-staleness.test.ts index 537e674a2..ab94fcab8 100644 --- a/gitnexus/test/integration/context-resource-staleness.test.ts +++ b/gitnexus/test/integration/context-resource-staleness.test.ts @@ -4,7 +4,7 @@ * End-to-end flow with real git and real registry/meta I/O. */ import { execFileSync } from 'child_process'; -import { writeFileSync } from 'fs'; +import { mkdirSync, writeFileSync } from 'fs'; import path from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTempDir } from '../helpers/test-db.js'; @@ -41,6 +41,10 @@ async function seedIndexedRepo( meta: RepoMeta, repoName: string = 'test-repo', ): Promise { + // Registry validation accepts only an owned storage directory that contains + // the index path. The context resource itself reads metadata only, so a + // minimal placeholder directory is sufficient for this fixture. + mkdirSync(path.join(storagePath, 'lbug'), { recursive: true }); await saveMeta(storagePath, meta); await registerRepo(repoPath, meta, { name: repoName }); } diff --git a/gitnexus/test/integration/external-storage-content-retention.test.ts b/gitnexus/test/integration/external-storage-content-retention.test.ts new file mode 100644 index 000000000..6fe510913 --- /dev/null +++ b/gitnexus/test/integration/external-storage-content-retention.test.ts @@ -0,0 +1,228 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { runFullAnalysis } from '../../src/core/run-analyze.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { statusCommand } from '../../src/cli/status.js'; +import { getStoragePaths, loadMeta } from '../../src/storage/repo-manager.js'; +import { + STORAGE_PATH_ENV, + STORAGE_ROOT_ENV, + storagePathFromRoot, +} from '../../src/storage/storage-resolver.js'; + +const savedStoragePath = process.env[STORAGE_PATH_ENV]; +const savedStorageRoot = process.env[STORAGE_ROOT_ENV]; +const savedRetention = process.env.GITNEXUS_CONTENT_RETENTION; +const savedHome = process.env.GITNEXUS_HOME; +const temporaryPaths: string[] = []; + +const makeTempDir = async (prefix: string): Promise => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + temporaryPaths.push(directory); + return directory; +}; + +const restoreEnvironment = () => { + if (savedStoragePath === undefined) delete process.env[STORAGE_PATH_ENV]; + else process.env[STORAGE_PATH_ENV] = savedStoragePath; + if (savedStorageRoot === undefined) delete process.env[STORAGE_ROOT_ENV]; + else process.env[STORAGE_ROOT_ENV] = savedStorageRoot; + if (savedRetention === undefined) delete process.env.GITNEXUS_CONTENT_RETENTION; + else process.env.GITNEXUS_CONTENT_RETENTION = savedRetention; + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; +}; + +afterEach(async () => { + restoreEnvironment(); + await Promise.all( + temporaryPaths.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })), + ); +}); + +const recursiveSize = async (target: string): Promise => { + const stat = await fs.lstat(target); + if (!stat.isDirectory()) return stat.size; + const entries = await fs.readdir(target); + return ( + await Promise.all(entries.map((entry) => recursiveSize(path.join(target, entry)))) + ).reduce((total, size) => total + size, 0); +}; + +const readGraph = async (lbugPath: string) => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + try { + return await adapter.withLbugDb( + lbugPath, + async () => { + const [nodes, edges, files, functions, basicBlocks, basicBlockCount] = await Promise.all([ + adapter.executeQuery('MATCH (n) RETURN count(n) AS count'), + adapter.executeQuery( + 'MATCH (source)-[r:CodeRelation]->(target) RETURN count(r) AS count', + ), + adapter.executeQuery( + "MATCH (n:File) WHERE n.filePath = 'src/fixture.ts' RETURN n.content AS content", + ), + adapter.executeQuery( + "MATCH (n:Function) WHERE n.name = 'retentionFixture' RETURN n.content AS content, n.description AS description", + ), + adapter.executeQuery('MATCH (n:BasicBlock) RETURN n.text AS text LIMIT 1'), + adapter.executeQuery('MATCH (n:BasicBlock) RETURN count(n) AS count'), + ]); + const count = (rows: any[]) => Number(rows[0]?.count ?? rows[0]?.[0] ?? 0); + return { + nodes: count(nodes), + edges: count(edges), + fileContent: files[0]?.content ?? files[0]?.[0], + functionContent: functions[0]?.content ?? functions[0]?.[0], + functionDescription: functions[0]?.description ?? functions[0]?.[1], + basicBlockText: basicBlocks[0]?.text ?? basicBlocks[0]?.[0], + basicBlockCount: count(basicBlockCount), + }; + }, + { readOnly: true }, + ); + } finally { + await adapter.closeLbug(); + } +}; + +describe('external storage and content retention', () => { + it('keeps the full index outside the checkout, rebuilds on retention changes, and remains queryable after checkout removal', async () => { + const repo = await makeTempDir('gitnexus-run-analyze-retention-repo-'); + const storageRoot = await makeTempDir('gitnexus-run-analyze-retention-storage-'); + const storage = storagePathFromRoot(storageRoot, repo); + const home = await makeTempDir('gitnexus-run-analyze-retention-home-'); + await fs.mkdir(path.join(repo, 'src')); + await fs.writeFile( + path.join(repo, 'src/fixture.ts'), + `/** retention fixture documentation */\nexport function retentionFixture(enabled = true) {\n const payload = '${'fullRetentionPayload '.repeat(20_000)}';\n if (enabled) return payload;\n return '';\n}\n`, + ); + process.env.GITNEXUS_HOME = home; + delete process.env[STORAGE_PATH_ENV]; + process.env[STORAGE_ROOT_ENV] = storageRoot; + process.env.GITNEXUS_CONTENT_RETENTION = 'full'; + + const logs: string[] = []; + const options = { + force: true, + skipGit: true, + skipAgentsMd: true, + skipSkills: true, + workerPoolSize: 1, + pdg: true, + streamPdgEmit: true, + registryName: 'retention-fixture', + }; + await runFullAnalysis(repo, options, { + onProgress: () => undefined, + onLog: (message) => logs.push(message), + }); + + const initialMeta = await loadMeta(storage); + const { lbugPath } = getStoragePaths(repo, undefined, storage); + const fullGraph = await readGraph(lbugPath); + const fullDatabaseSize = await recursiveSize(lbugPath); + expect(initialMeta).toMatchObject({ + repoPath: repo, + storagePath: storage, + contentRetention: 'full', + contentRetentionSchemaVersion: 1, + ftsProfile: 'full', + }); + await expect(fs.access(path.join(repo, '.gitnexus'))).rejects.toThrow(); + expect(fullGraph.fileContent).toContain('fullRetentionPayload'); + expect(fullGraph.functionContent).toContain('retentionFixture'); + expect(fullGraph.basicBlockCount).toBeGreaterThan(0); + + process.env.GITNEXUS_CONTENT_RETENTION = 'symbol'; + await runFullAnalysis( + repo, + { ...options, force: false }, + { + onProgress: () => undefined, + onLog: (message) => logs.push(message), + }, + ); + const symbolMeta = await loadMeta(storage); + const symbolGraph = await readGraph(lbugPath); + const symbolDatabaseSize = await recursiveSize(lbugPath); + expect(logs.join('\n')).toContain('forcing a full rebuild'); + expect(symbolMeta).toMatchObject({ + contentRetention: 'symbol', + contentRetentionSchemaVersion: 1, + ftsProfile: 'symbol-no-file-content', + }); + expect(symbolGraph).toMatchObject({ nodes: fullGraph.nodes, edges: fullGraph.edges }); + expect(symbolGraph.fileContent).toBeUndefined(); + expect(symbolGraph.functionContent).toContain('retentionFixture'); + expect(symbolGraph.basicBlockCount).toBe(fullGraph.basicBlockCount); + expect(symbolGraph.basicBlockText).toBeUndefined(); + expect(symbolDatabaseSize).toBeLessThan(fullDatabaseSize); + + process.env.GITNEXUS_CONTENT_RETENTION = 'none'; + await runFullAnalysis( + repo, + { ...options, force: false }, + { + onProgress: () => undefined, + onLog: (message) => logs.push(message), + }, + ); + const noneMeta = await loadMeta(storage); + expect(noneMeta).toMatchObject({ + contentRetention: 'none', + contentRetentionSchemaVersion: 1, + ftsProfile: 'name-only', + }); + + await fs.rm(repo, { recursive: true, force: true }); + const output: string[] = []; + const logSpy = vi + .spyOn(console, 'log') + .mockImplementation((value) => output.push(String(value))); + try { + await statusCommand({ repo: 'retention-fixture', json: true }); + } finally { + logSpy.mockRestore(); + } + expect(JSON.parse(output[0])).toMatchObject({ + storagePath: storage, + sourceAvailable: false, + status: 'source-unavailable', + index: { contentRetention: 'none' }, + }); + + const backend = new LocalBackend(); + try { + await backend.init(); + const context = await backend.callTool('context', { + name: 'retentionFixture', + repo: 'retention-fixture', + include_content: true, + }); + expect(context).toMatchObject({ + status: 'found', + contentAvailability: { + requested: true, + profile: 'none', + available: false, + scope: 'none', + }, + }); + expect(context.symbol.content).toBeUndefined(); + } finally { + await backend.disconnect(); + } + + const noneGraph = await readGraph(lbugPath); + expect(noneGraph).toMatchObject({ nodes: fullGraph.nodes, edges: fullGraph.edges }); + expect(noneGraph.fileContent).toBeUndefined(); + expect(noneGraph.functionContent).toBeUndefined(); + expect(noneGraph.functionDescription).toBeUndefined(); + expect(noneGraph.basicBlockCount).toBe(fullGraph.basicBlockCount); + expect(noneGraph.basicBlockText).toBeUndefined(); + }, 120_000); +}); diff --git a/gitnexus/test/integration/hooks-e2e.test.ts b/gitnexus/test/integration/hooks-e2e.test.ts index 55bb8a415..1298f0921 100644 --- a/gitnexus/test/integration/hooks-e2e.test.ts +++ b/gitnexus/test/integration/hooks-e2e.test.ts @@ -11,7 +11,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import { - runHook, + runHook as spawnHook, parseHookOutput, createGitNexusPathEntry, envWithPath, @@ -40,6 +40,7 @@ const HOOKS = [ let tmpDir: string; let gitNexusDir: string; +let hookHome: string; beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-e2e-')); @@ -52,12 +53,37 @@ beforeAll(() => { // Create a file and commit so HEAD exists fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello'); commitAll(tmpDir, 'init'); + + hookHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-e2e-home-')); + fs.writeFileSync( + path.join(hookHome, 'registry.json'), + JSON.stringify([ + { + name: 'hooks-e2e', + path: tmpDir, + storagePath: gitNexusDir, + }, + ]), + ); }); afterAll(() => { + fs.rmSync(hookHome, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); }); +function runHook( + hookPath: string, + input: Record, + cwd?: string, + options: { env?: NodeJS.ProcessEnv } = {}, +) { + return spawnHook(hookPath, input, cwd, { + ...options, + env: { ...(options.env ?? process.env), GITNEXUS_HOME: hookHome }, + }); +} + // ─── Tests ────────────────────────────────────────────────────────── describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => { diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index 052284a11..f3000cb48 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -5,9 +5,10 @@ * instance, verifying cypher, context, impact, and query tools work * end-to-end against seeded graph data with FTS indexes. */ +import fs from 'fs/promises'; import { describe, it, expect, beforeAll, vi } from 'vitest'; import { LocalBackend } from '../../src/mcp/local/local-backend.js'; -import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { listRegisteredRepos, saveMeta } from '../../src/storage/repo-manager.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { LOCAL_BACKEND_SEED_DATA, @@ -208,6 +209,97 @@ withTestLbugDB( expect(validate.content).toBe('function validate() {}'); }); + it('reports content capability for the default full profile', async () => { + const query = await backend.callTool('query', { query: 'login', include_content: true }); + const context = await backend.callTool('context', { name: 'login', include_content: true }); + + expect(query.contentAvailability).toEqual({ + requested: true, + profile: 'full', + available: true, + scope: 'full', + }); + expect(context.contentAvailability).toEqual(query.contentAvailability); + expect(context.symbol.content).toBe('function login() {}'); + }); + + it('does not disclose lingering source text when metadata says retention is none', async () => { + const storagePath = handle.tmpHandle.dbPath; + await saveMeta(storagePath, { + repoPath: '/test/repo', + storagePath, + lastCommit: 'abc123', + indexedAt: new Date().toISOString(), + contentRetention: 'none', + contentRetentionSchemaVersion: 1, + ftsProfile: 'name-only', + }); + try { + const query = await backend.callTool('query', { query: 'login', include_content: true }); + const context = await backend.callTool('context', { + name: 'login', + include_content: true, + }); + const login = (query.process_symbols ?? []).find( + (symbol: any) => symbol.id === 'func:login', + ); + + expect(query.contentAvailability).toEqual({ + requested: true, + profile: 'none', + available: false, + scope: 'none', + reason: 'Source-derived content is not retained by this index.', + }); + expect(login?.content).toBeUndefined(); + expect(context.contentAvailability).toEqual(query.contentAvailability); + expect(context.symbol.content).toBeUndefined(); + } finally { + await Promise.all([ + fs.rm(`${storagePath}/gitnexus.json`, { force: true }), + fs.rm(`${storagePath}/meta.json`, { force: true }), + ]); + } + }); + + it('does not disclose lingering source text when retention metadata is invalid', async () => { + const storagePath = handle.tmpHandle.dbPath; + await saveMeta(storagePath, { + repoPath: '/test/repo', + storagePath, + lastCommit: 'abc123', + indexedAt: new Date().toISOString(), + contentRetention: 'invalid' as never, + contentRetentionSchemaVersion: 1, + ftsProfile: 'name-only', + }); + try { + const query = await backend.callTool('query', { query: 'login', include_content: true }); + const context = await backend.callTool('context', { + name: 'login', + include_content: true, + }); + const login = (query.process_symbols ?? []).find( + (symbol: any) => symbol.id === 'func:login', + ); + + expect(query.contentAvailability).toMatchObject({ + requested: true, + profile: 'none', + available: false, + scope: 'none', + }); + expect(login?.content).toBeUndefined(); + expect(context.contentAvailability).toEqual(query.contentAvailability); + expect(context.symbol.content).toBeUndefined(); + } finally { + await Promise.all([ + fs.rm(`${storagePath}/gitnexus.json`, { force: true }), + fs.rm(`${storagePath}/meta.json`, { force: true }), + ]); + } + }); + // PR #222 port: a symbol in MULTIPLE processes is what fully exercises the // +1 positional shift in the batched STEP_IN_PROCESS aggregation — with a // single process row, `row.pid ?? row[1]` succeeds whether the shift is diff --git a/gitnexus/test/integration/resolvers/java-javac-local-types.test.ts b/gitnexus/test/integration/resolvers/java-javac-local-types.test.ts index 363fb49ce..151578f0b 100644 --- a/gitnexus/test/integration/resolvers/java-javac-local-types.test.ts +++ b/gitnexus/test/integration/resolvers/java-javac-local-types.test.ts @@ -5,55 +5,61 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { FIXTURES } from './helpers.js'; -const javacAvailable = spawnSync('javac', ['-version'], { stdio: 'ignore' }).status === 0; +const javacProbe = spawnSync('javac', ['-version'], { encoding: 'utf8' }); +const javacMajorVersion = `${javacProbe.stdout}\n${javacProbe.stderr}`.match(/javac\s+(\d+)/)?.[1]; +// Local records and enum declarations used by this fixture require Java 16+. +const javacSupportsLocalTypes = javacProbe.status === 0 && Number(javacMajorVersion) >= 16; describe('Java local-type names emitted by javac', () => { - it.runIf(javacAvailable)('matches the identities asserted by the resolver fixture', () => { - const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-local-types-')); - const output = path.join(temp, 'classes'); - mkdirSync(output); + it.runIf(javacSupportsLocalTypes)( + 'matches the identities asserted by the resolver fixture', + () => { + const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-local-types-')); + const output = path.join(temp, 'classes'); + mkdirSync(output); - try { - const sourceDir = path.join(FIXTURES, 'java-local-class-naming', 'src'); - const sources = readdirSync(sourceDir) - .filter((name) => name.endsWith('.java')) - .map((name) => path.join(sourceDir, name)); - execFileSync('javac', ['-d', output, ...sources]); + try { + const sourceDir = path.join(FIXTURES, 'java-local-class-naming', 'src'); + const sources = readdirSync(sourceDir) + .filter((name) => name.endsWith('.java')) + .map((name) => path.join(sourceDir, name)); + execFileSync('javac', ['-d', output, ...sources]); - expect(readdirSync(output).sort()).toEqual([ - 'Compact$1.class', - 'Compact$1Local.class', - 'Compact.class', - 'Outer$1.class', - 'Outer$1CtorHost$1Local.class', - 'Outer$1CtorHost.class', - 'Outer$1Cyclic.class', - 'Outer$1InstanceLocal.class', - 'Outer$1LambdaLocal.class', - 'Outer$1Local$1.class', - 'Outer$1Local.class', - 'Outer$1NestedHost$Member$1Local.class', - 'Outer$1NestedHost$Member.class', - 'Outer$1NestedHost.class', - 'Outer$1StaticLocal.class', - 'Outer$2.class', - 'Outer$2Local.class', - 'Outer$3$1Local.class', - 'Outer$3.class', - 'Outer$3Local.class', - 'Outer$4Local.class', - 'Outer$Cyclic.class', - 'Outer$MemberHost$1Local.class', - 'Outer$MemberHost.class', - 'Outer.class', - 'Types$1.class', - 'Types$1E.class', - 'Types$1I.class', - 'Types$1R.class', - 'Types.class', - ]); - } finally { - rmSync(temp, { recursive: true, force: true }); - } - }); + expect(readdirSync(output).sort()).toEqual([ + 'Compact$1.class', + 'Compact$1Local.class', + 'Compact.class', + 'Outer$1.class', + 'Outer$1CtorHost$1Local.class', + 'Outer$1CtorHost.class', + 'Outer$1Cyclic.class', + 'Outer$1InstanceLocal.class', + 'Outer$1LambdaLocal.class', + 'Outer$1Local$1.class', + 'Outer$1Local.class', + 'Outer$1NestedHost$Member$1Local.class', + 'Outer$1NestedHost$Member.class', + 'Outer$1NestedHost.class', + 'Outer$1StaticLocal.class', + 'Outer$2.class', + 'Outer$2Local.class', + 'Outer$3$1Local.class', + 'Outer$3.class', + 'Outer$3Local.class', + 'Outer$4Local.class', + 'Outer$Cyclic.class', + 'Outer$MemberHost$1Local.class', + 'Outer$MemberHost.class', + 'Outer.class', + 'Types$1.class', + 'Types$1E.class', + 'Types$1I.class', + 'Types$1R.class', + 'Types.class', + ]); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }, + ); }); diff --git a/gitnexus/test/integration/run-analyze-adopt-failure.test.ts b/gitnexus/test/integration/run-analyze-adopt-failure.test.ts index facb76024..d055ceb5a 100644 --- a/gitnexus/test/integration/run-analyze-adopt-failure.test.ts +++ b/gitnexus/test/integration/run-analyze-adopt-failure.test.ts @@ -77,7 +77,7 @@ describe('end-of-run adopt is best-effort (#2364 F5)', () => { // The run resolved (no throw), the adopt was attempted and its failure // surfaced as a warning… expect(result.alreadyUpToDate).toBeFalsy(); - expect(rmCtx.adoptMock).toHaveBeenCalledWith(repo, 'main'); + expect(rmCtx.adoptMock).toHaveBeenCalledWith(repo, 'main', path.join(repo, '.gitnexus')); expect(logs.some((m) => m.includes('could not sync the workspace branch label'))).toBe(true); // …and registration had already completed before the label sync. const entries = await listRegisteredRepos(); diff --git a/gitnexus/test/integration/server-repo-freshness.test.ts b/gitnexus/test/integration/server-repo-freshness.test.ts index 6a058b565..7a808b5ea 100644 --- a/gitnexus/test/integration/server-repo-freshness.test.ts +++ b/gitnexus/test/integration/server-repo-freshness.test.ts @@ -99,11 +99,12 @@ describeBlock('repo routes expose branch and freshness (real server)', () => { // `storagePath` is gone, so a fixture without it is silently dropped and the // route legitimately returns nothing (found while writing this test). const storagePath = path.join(repoPath, '.gitnexus'); - fs.mkdirSync(storagePath, { recursive: true }); + fs.mkdirSync(path.join(storagePath, 'lbug'), { recursive: true }); fs.writeFileSync( path.join(storagePath, 'gitnexus.json'), JSON.stringify({ repoPath, + storagePath, indexedAt: INDEXED_AT, lastCommit: firstCommit, branch: 'main', diff --git a/gitnexus/test/unit/analyze-api.test.ts b/gitnexus/test/unit/analyze-api.test.ts index c11762e06..302d62674 100644 --- a/gitnexus/test/unit/analyze-api.test.ts +++ b/gitnexus/test/unit/analyze-api.test.ts @@ -642,7 +642,7 @@ describe('POST /api/embed route wiring (#2790)', () => { // `stats.embeddings`, and the next CLI run's preserve-or-wipe decision // hangs on it. expect(region).toContain('const measuredEmbeddings = await countPersistedEmbeddings();'); - expect(region).toContain('await saveMeta(entry.storagePath, embeddingMeta);'); + expect(region).toContain('await saveMeta(storagePath, embeddingMeta);'); // Ordering, without brittle character spans: flush → measure → decide → // write. Counting before the flush would describe rows still in the WAL. const flushed = region.lastIndexOf('await flushWAL();'); @@ -689,3 +689,22 @@ describe('POST /api/embed route wiring (#2790)', () => { expect(source).not.toMatch(/p\.phase === 'ready' \? 'complete'/); }); }); + +describe('HTTP repo catalog validation', () => { + const readSource = () => + fs.readFile(path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'), 'utf-8'); + + it('lists and resolves repos with validate: true, and maps StorageRequirementError', async () => { + const source = await readSource(); + expect(source).toMatch(/const repos = await listRegisteredRepos\(\{\s*validate:\s*true\s*\}\)/); + expect(source).toMatch( + /const freshRepos = await listRegisteredRepos\(\{\s*validate:\s*options\.validateStorage !== false,\s*\}\)/, + ); + expect(source).toMatch( + /app\.get\('\/api\/repos'[\s\S]*listRegisteredRepos\(\{\s*validate:\s*true\s*\}\)/, + ); + expect(source).toMatch(/sendStorageRequirementHttp\(err, res\)/); + expect(source).toMatch(/storageRequirementToHttp\(err\)/); + expect(source).toMatch(/code: 'index-unavailable'/); + }); +}); diff --git a/gitnexus/test/unit/analyze-launch-branch-settle.test.ts b/gitnexus/test/unit/analyze-launch-branch-settle.test.ts index 0fa3dc6a4..b1a5a064a 100644 --- a/gitnexus/test/unit/analyze-launch-branch-settle.test.ts +++ b/gitnexus/test/unit/analyze-launch-branch-settle.test.ts @@ -45,11 +45,15 @@ vi.mock('child_process', async () => { }); vi.mock('../../src/storage/repo-manager.js', () => ({ - canonicalizePath: (p: string) => p, - getStoragePath: () => H.STORAGE_PATH, INDEX_METADATA_FILE: H.METADATA_FILE, - listRegisteredRepos: async () => [{ path: H.REPO_PATH, storagePath: H.STORAGE_PATH }], - registryPathEquals: (a: string, b: string) => a === b, +})); + +vi.mock('../../src/storage/storage-resolver.js', () => ({ + ANALYZE_STORAGE_REQUIREMENTS: { allowedStates: ['missing', 'empty', 'owned'] }, + ANALYZE_FORCE_STORAGE_REQUIREMENTS: { + allowedStates: ['missing', 'empty', 'owned', 'unowned', 'foreign'], + }, + requireStoragePath: async () => H.STORAGE_PATH, })); vi.mock('node:fs', async () => { @@ -115,12 +119,12 @@ describe('finalization gate follows the placement the run chose', () => { let backendInit: Mock<() => Promise>; let closeDbHandle: Mock<() => Promise>; - const launcher = () => + const launcher = (extras?: { releaseRepoLock?: () => void }) => createLaunchAnalysisWorker({ jobManager, backend: { init: backendInit }, acquireRepoLock: () => null, - releaseRepoLock: () => {}, + releaseRepoLock: extras?.releaseRepoLock ?? (() => {}), closeDbHandle, }); @@ -134,6 +138,7 @@ describe('finalization gate follows the placement the run chose', () => { }); afterEach(() => { + vi.useRealTimers(); jobManager.dispose(); vi.restoreAllMocks(); forkMock.mockReset(); @@ -145,7 +150,7 @@ describe('finalization gate follows the placement the run chose', () => { H.settledDir = path.join(H.STORAGE_PATH, BRANCHES_DIR, branchSlug(BRANCH)); const job = jobManager.createJob({ repoPath: REPO_PATH, branch: BRANCH }); - launcher()(job, REPO_PATH, { branch: BRANCH }); + await launcher()(job, REPO_PATH, { branch: BRANCH }); child.emit('message', completeMessage(false)); @@ -159,7 +164,7 @@ describe('finalization gate follows the placement the run chose', () => { H.settledDir = H.STORAGE_PATH; const job = jobManager.createJob({ repoPath: REPO_PATH }); - launcher()(job, REPO_PATH, {}); + await launcher()(job, REPO_PATH, {}); child.emit('message', completeMessage(true)); @@ -173,7 +178,7 @@ describe('finalization gate follows the placement the run chose', () => { H.settledDir = H.STORAGE_PATH; const job = jobManager.createJob({ repoPath: REPO_PATH, branch: BRANCH }); - launcher()(job, REPO_PATH, { branch: BRANCH }); + await launcher()(job, REPO_PATH, { branch: BRANCH }); child.emit('message', completeMessage(true)); @@ -186,9 +191,10 @@ describe('finalization gate follows the placement the run chose', () => { // No directory looks freshly written. Without the alreadyUpToDate skip the // mtime gate would hold the analyze slot for the full 60s settle timeout. H.settledDir = ''; + const releaseRepoLock = vi.fn(); const job = jobManager.createJob({ repoPath: REPO_PATH }); - launcher()(job, REPO_PATH, {}); + await launcher({ releaseRepoLock })(job, REPO_PATH, {}); child.emit('message', completeMessage(true, { alreadyUpToDate: true })); child.emit('exit', 0); @@ -200,6 +206,8 @@ describe('finalization gate follows the placement the run chose', () => { expect(backendInit).toHaveBeenCalledTimes(1); expect(forkMock).toHaveBeenCalledTimes(1); expect(jobManager.getJob(job.id)?.retryCount).toBe(0); + // Short path: skip the mtime wait, then drop the lock once it finishes. + expect(releaseRepoLock).toHaveBeenCalledTimes(1); }); it('does not fork a retry when the worker exits 0 after reporting complete', async () => { @@ -209,7 +217,7 @@ describe('finalization gate follows the placement the run chose', () => { H.settledDir = path.join(H.STORAGE_PATH, BRANCHES_DIR, branchSlug(BRANCH)); const job = jobManager.createJob({ repoPath: REPO_PATH, branch: BRANCH }); - launcher()(job, REPO_PATH, { branch: BRANCH }); + await launcher()(job, REPO_PATH, { branch: BRANCH }); child.emit('message', completeMessage(false)); child.emit('exit', 0); @@ -221,12 +229,65 @@ describe('finalization gate follows the placement the run chose', () => { expect(jobManager.getJob(job.id)?.retryCount).toBe(0); }); + it('fails and does not publish when the settle gate times out', async () => { + vi.useFakeTimers(); + H.settledDir = ''; + const releaseRepoLock = vi.fn(); + + const job = jobManager.createJob({ repoPath: REPO_PATH }); + await launcher({ releaseRepoLock })(job, REPO_PATH, {}); + child.emit('message', completeMessage(true)); + + expect(releaseRepoLock).not.toHaveBeenCalled(); + expect(backendInit).not.toHaveBeenCalled(); + expect(jobManager.getJob(job.id)?.status).toBe('analyzing'); + + // Must match FINALIZE_SETTLE_TIMEOUT_MS + one poll in analyze-launch.ts. + await vi.advanceTimersByTimeAsync(61_000); + + const done = jobManager.getJob(job.id); + expect(done?.status).toBe('failed'); + expect(done?.error).toMatch(/finalization not visible after timeout/i); + expect(backendInit).not.toHaveBeenCalled(); + expect(closeDbHandle).not.toHaveBeenCalled(); + expect(releaseRepoLock).toHaveBeenCalledTimes(1); + }); + + it('holds the write lock until settle resolves, then releases once after publish', async () => { + vi.useFakeTimers(); + H.settledDir = ''; + const order: string[] = []; + const releaseRepoLock = vi.fn(() => { + order.push('releaseRepoLock'); + }); + backendInit = vi.fn(async () => { + order.push('backend.init'); + return true; + }); + + const job = jobManager.createJob({ repoPath: REPO_PATH }); + await launcher({ releaseRepoLock })(job, REPO_PATH, {}); + child.emit('message', completeMessage(true)); + + expect(releaseRepoLock).not.toHaveBeenCalled(); + expect(backendInit).not.toHaveBeenCalled(); + expect(jobManager.getJob(job.id)?.status).toBe('analyzing'); + + H.settledDir = H.STORAGE_PATH; + await vi.advanceTimersByTimeAsync(200); + + expect(jobManager.getJob(job.id)?.status).toBe('complete'); + expect(backendInit).toHaveBeenCalledTimes(1); + expect(releaseRepoLock).toHaveBeenCalledTimes(1); + expect(order).toEqual(['backend.init', 'releaseRepoLock']); + }); + it('still treats an exit with no terminal IPC as a crash worth retrying', async () => { // The guard must not swallow real crashes: no `complete`/`error` was sent. H.settledDir = H.STORAGE_PATH; const job = jobManager.createJob({ repoPath: REPO_PATH }); - launcher()(job, REPO_PATH, {}); + await launcher()(job, REPO_PATH, {}); child.emit('exit', 1); diff --git a/gitnexus/test/unit/analyze-launch-collapse.test.ts b/gitnexus/test/unit/analyze-launch-collapse.test.ts index 226cde9cc..233a2807a 100644 --- a/gitnexus/test/unit/analyze-launch-collapse.test.ts +++ b/gitnexus/test/unit/analyze-launch-collapse.test.ts @@ -23,12 +23,18 @@ import { EventEmitter } from 'node:events'; // `vi.mock` factories are hoisted above every top-level `const`, and this file // imports the module under test statically — so anything a factory closes over // must be hoisted with it. -const H = vi.hoisted(() => ({ - forkMock: vi.fn(), - STORAGE_PATH: '/tmp/gitnexus-test-storage', - REPO_PATH: '/tmp/gitnexus-test-repo', - METADATA_FILE: 'gitnexus.json', -})); +const H = vi.hoisted(() => { + const STORAGE_PATH = '/tmp/gitnexus-test-storage'; + return { + forkMock: vi.fn(), + STORAGE_PATH, + REPO_PATH: '/tmp/gitnexus-test-repo', + METADATA_FILE: 'gitnexus.json', + // When false, the finalization gate sees no fresh index (timeout / lock-hold tests). + settleOk: true, + requireStoragePath: vi.fn(async () => STORAGE_PATH), + }; +}); const { forkMock, REPO_PATH } = H; vi.mock('child_process', async () => { @@ -36,25 +42,34 @@ vi.mock('child_process', async () => { return { ...actual, fork: H.forkMock }; }); -// The launcher's finalization gate (`waitForSettledIndex`) probes the registry -// and the filesystem. Pin both so the gate settles on its FIRST poll — the gate -// itself is not under test here and its 200ms poll would otherwise put a real -// timer between the worker message and the assertions. +// The launcher's finalization gate (`waitForSettledIndex`) probes the +// ownership-validated storage path. Pin the filesystem so the gate settles on +// its FIRST poll — the gate itself is not under test here and its 200ms poll +// would otherwise put a real timer between the worker message and the assertions. vi.mock('../../src/storage/repo-manager.js', () => ({ - canonicalizePath: (p: string) => p, - getStoragePath: () => H.STORAGE_PATH, INDEX_METADATA_FILE: H.METADATA_FILE, - listRegisteredRepos: async () => [{ path: H.REPO_PATH, storagePath: H.STORAGE_PATH }], - registryPathEquals: (a: string, b: string) => a === b, +})); + +vi.mock('../../src/storage/storage-resolver.js', () => ({ + ANALYZE_STORAGE_REQUIREMENTS: { allowedStates: ['missing', 'empty', 'owned'] }, + ANALYZE_FORCE_STORAGE_REQUIREMENTS: { + allowedStates: ['missing', 'empty', 'owned', 'unowned', 'foreign'], + }, + requireStoragePath: H.requireStoragePath, })); vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); return { ...actual, - // Both index files were (re)written far in the future relative to jobStartMs… - statSync: () => ({ mtimeMs: Number.MAX_SAFE_INTEGER }), - // …and no WAL/shadow/checkpoint sidecar remains. + statSync: () => { + if (!H.settleOk) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + // Both index files were (re)written far in the future relative to jobStartMs. + return { mtimeMs: Number.MAX_SAFE_INTEGER }; + }, + // No WAL/shadow/checkpoint sidecar remains when the gate is allowed to settle. existsSync: () => false, }; }); @@ -76,6 +91,7 @@ const completeMessage = (graphWriteCollapsed?: { expected: number; persisted: nu const result = { repoName: REPO_NAME, repoPath: REPO_PATH, + storagePath: H.STORAGE_PATH, stats: { files: 10, nodes: 100, edges: 500 }, ...(graphWriteCollapsed ? { graphWriteCollapsed } : {}), } satisfies Partial as AnalyzeResult; @@ -109,12 +125,14 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () jobManager, backend: { init: backendInit }, acquireRepoLock: () => null, - releaseRepoLock: () => {}, + releaseRepoLock: () => { + calls.push('releaseRepoLock'); + }, closeDbHandle, }); const job = jobManager.createJob({ repoPath: REPO_PATH }); - launch(job, REPO_PATH, {}); + await launch(job, REPO_PATH, {}); child.emit('message', msg); await vi.waitFor(() => expect(calls).toContain('updateJob:terminal')); @@ -123,6 +141,8 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () beforeEach(() => { calls = []; + H.settleOk = true; + H.requireStoragePath.mockClear(); jobManager = new JobManager(); child = makeChild(); forkMock.mockImplementation(() => child); @@ -149,7 +169,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () }); }); - it('forwards the Spring Actuator snapshot path to the worker', () => { + it('forwards the Spring Actuator snapshot path to the worker', async () => { const launch = createLaunchAnalysisWorker({ jobManager, backend: { init: backendInit }, @@ -159,7 +179,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () }); const job = jobManager.createJob({ repoPath: REPO_PATH }); - launch(job, REPO_PATH, { springActuatorPath: 'runtime/actuator' }); + await launch(job, REPO_PATH, { springActuatorPath: 'runtime/actuator' }); expect(child.send).toHaveBeenCalledWith( expect.objectContaining({ @@ -170,7 +190,29 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () ); }); - it('forwards the index-branch selector to the worker', () => { + it('uses the force storage set only when launch options request force', async () => { + const launch = createLaunchAnalysisWorker({ + jobManager, + backend: { init: backendInit }, + acquireRepoLock: () => null, + releaseRepoLock: () => {}, + closeDbHandle, + }); + + const ordinary = jobManager.createJob({ repoPath: REPO_PATH }); + await launch(ordinary, REPO_PATH, {}); + expect(H.requireStoragePath).toHaveBeenLastCalledWith(REPO_PATH, { + allowedStates: ['missing', 'empty', 'owned'], + }); + + const forced = jobManager.createJob({ repoPath: REPO_PATH }); + await launch(forced, REPO_PATH, { force: true }); + expect(H.requireStoragePath).toHaveBeenLastCalledWith(REPO_PATH, { + allowedStates: ['missing', 'empty', 'owned', 'unowned', 'foreign'], + }); + }); + + it('forwards the index-branch selector to the worker', async () => { const launch = createLaunchAnalysisWorker({ jobManager, backend: { init: backendInit }, @@ -180,7 +222,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () }); const job = jobManager.createJob({ repoPath: REPO_PATH }); - launch(job, REPO_PATH, { branch: 'development' }); + await launch(job, REPO_PATH, { branch: 'development' }); // `StartMessage.options` is typed as `AnalyzeOptions`, so this key IS // `AnalyzeOptions.branch` — the field `resolveWriteTarget` reads to choose @@ -195,7 +237,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () ); }); - it('omits branch entirely when the caller did not select one', () => { + it('omits branch entirely when the caller did not select one', async () => { const launch = createLaunchAnalysisWorker({ jobManager, backend: { init: backendInit }, @@ -205,7 +247,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () }); const job = jobManager.createJob({ repoPath: REPO_PATH }); - launch(job, REPO_PATH, {}); + await launch(job, REPO_PATH, {}); // Not merely undefined: absent. `AnalyzeOptions.branch === undefined` is the // documented signal for "target the flat workspace slot", so sending the key @@ -215,9 +257,11 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () }); afterEach(() => { + vi.useRealTimers(); jobManager.dispose(); vi.restoreAllMocks(); forkMock.mockReset(); + H.settleOk = true; }); it('does not publish the index — backend.init() is never called for a collapsed run', async () => { @@ -232,6 +276,8 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () // is not publication. expect(closeDbHandle).toHaveBeenCalledTimes(1); expect(calls.indexOf('closeDbHandle')).toBeLessThan(calls.indexOf('updateJob:failed')); + // Lock is held through the collapse decision and dropped afterwards, once. + expect(calls.indexOf('updateJob:failed')).toBeLessThan(calls.indexOf('releaseRepoLock')); }); it('marks the collapsed run failed and still reports repoName', async () => { @@ -261,6 +307,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () 'backend.init', 'updateJob:complete', 'updateJob:terminal', + 'releaseRepoLock', ]); }); @@ -278,4 +325,67 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', () const job = await runWorker(healthy); expect(job?.status).toBe('complete'); }); + + it('fails and does not publish when index finalization never becomes visible', async () => { + vi.useFakeTimers(); + H.settleOk = false; + const releaseRepoLock = vi.fn(() => { + calls.push('releaseRepoLock'); + }); + const launch = createLaunchAnalysisWorker({ + jobManager, + backend: { init: backendInit }, + acquireRepoLock: () => null, + releaseRepoLock, + closeDbHandle, + }); + const job = jobManager.createJob({ repoPath: REPO_PATH }); + await launch(job, REPO_PATH, {}); + child.emit('message', completeMessage()); + + expect(releaseRepoLock).not.toHaveBeenCalled(); + expect(backendInit).not.toHaveBeenCalled(); + expect(jobManager.getJob(job.id)?.status).toBe('analyzing'); + + // Must match FINALIZE_SETTLE_TIMEOUT_MS + one poll in analyze-launch.ts. + await vi.advanceTimersByTimeAsync(61_000); + + const done = jobManager.getJob(job.id); + expect(done?.status).toBe('failed'); + expect(done?.error).toMatch(/finalization not visible after timeout/i); + expect(backendInit).not.toHaveBeenCalled(); + expect(closeDbHandle).not.toHaveBeenCalled(); + expect(releaseRepoLock).toHaveBeenCalledTimes(1); + }); + + it('holds the write lock until settle resolves, then releases once after publish', async () => { + vi.useFakeTimers(); + H.settleOk = false; + const releaseRepoLock = vi.fn(() => { + calls.push('releaseRepoLock'); + }); + const launch = createLaunchAnalysisWorker({ + jobManager, + backend: { init: backendInit }, + acquireRepoLock: () => null, + releaseRepoLock, + closeDbHandle, + }); + const job = jobManager.createJob({ repoPath: REPO_PATH }); + await launch(job, REPO_PATH, {}); + child.emit('message', completeMessage()); + + // First poll failed; the gate is sleeping. Lock must still be held. + expect(releaseRepoLock).not.toHaveBeenCalled(); + expect(backendInit).not.toHaveBeenCalled(); + expect(jobManager.getJob(job.id)?.status).toBe('analyzing'); + + H.settleOk = true; + await vi.advanceTimersByTimeAsync(200); + + expect(jobManager.getJob(job.id)?.status).toBe('complete'); + expect(backendInit).toHaveBeenCalledTimes(1); + expect(releaseRepoLock).toHaveBeenCalledTimes(1); + expect(calls.indexOf('backend.init')).toBeLessThan(calls.indexOf('releaseRepoLock')); + }); }); diff --git a/gitnexus/test/unit/analyze-worker-core.test.ts b/gitnexus/test/unit/analyze-worker-core.test.ts index ed4f268ff..2b09108b5 100644 --- a/gitnexus/test/unit/analyze-worker-core.test.ts +++ b/gitnexus/test/unit/analyze-worker-core.test.ts @@ -25,6 +25,7 @@ import { IndexLockTimeoutError, type LockRecord } from '../../src/storage/index- const baseResult: AnalyzeResult = { repoName: 'repo', repoPath: '/repo', + storagePath: '/repo/.gitnexus', stats: {}, alreadyUpToDate: false, ftsRepairedOnly: false, @@ -77,6 +78,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => { const completes = send.mock.calls.filter((c) => c[0].type === 'complete'); expect(completes).toHaveLength(1); + expect(okFinalize).toHaveBeenCalledWith('/repo', '/repo/.gitnexus'); }); it('threads the pre-import runner receipt into runFullAnalysis', async () => { diff --git a/gitnexus/test/unit/analyze-worker-ipc.test.ts b/gitnexus/test/unit/analyze-worker-ipc.test.ts index 5d4738bee..b99a857b4 100644 --- a/gitnexus/test/unit/analyze-worker-ipc.test.ts +++ b/gitnexus/test/unit/analyze-worker-ipc.test.ts @@ -31,6 +31,7 @@ function hostileResult(): AnalyzeResult { return { repoName: 'demo', repoPath: '/repos/demo', + storagePath: '/repos/demo/.gitnexus', stats: { files: 3, nodes: 1, edges: 0 }, alreadyUpToDate: false, ftsSkipped: true, @@ -85,6 +86,7 @@ describe('#2112: analyze-worker IPC projection', () => { const result: AnalyzeResult = { repoName: 'demo', repoPath: '/r', + storagePath: '/r/.gitnexus', stats: { nodes: 50, edges: 1 }, pipelineResult: { graph, diff --git a/gitnexus/test/unit/analyzer-identity-in-process-guards.test.ts b/gitnexus/test/unit/analyzer-identity-in-process-guards.test.ts index 7595fe24a..96200b0ca 100644 --- a/gitnexus/test/unit/analyzer-identity-in-process-guards.test.ts +++ b/gitnexus/test/unit/analyzer-identity-in-process-guards.test.ts @@ -5,7 +5,7 @@ * that signal. Mutation still fail-closes on both paths. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { writeFileSync } from 'node:fs'; @@ -177,7 +177,7 @@ describe('analyzer identity in-process cache guards (#3092)', () => { const tree = await seedWideBuildTree(fixture.dbPath); const cacheDirectory = path.join(fixture.dbPath, 'identity-cache'); resolveAnalyzerRunnerIdentity(pathToFileURL(tree.modulePath).href, { cacheDirectory }); - fsCtx.unwritableExact.add(tree.packageRoot); + fsCtx.unwritableExact.add(await realpath(tree.packageRoot)); spawnCtx.spawnSync.mockClear(); _clearAnalyzerIdentityProcessCacheForTests(); resolveAnalyzerRunnerIdentity(pathToFileURL(tree.modulePath).href, { cacheDirectory }); @@ -198,7 +198,9 @@ describe('analyzer identity in-process cache guards (#3092)', () => { fsCtx.wOkProbes.length = 0; _clearAnalyzerIdentityProcessCacheForTests(); resolveAnalyzerRunnerIdentity(pathToFileURL(tree.modulePath).href, { cacheDirectory }); - expect(fsCtx.wOkProbes).toEqual(expect.arrayContaining([tree.packageRoot, tree.sourceRoot])); + expect(fsCtx.wOkProbes).toEqual( + expect.arrayContaining([await realpath(tree.packageRoot), await realpath(tree.sourceRoot)]), + ); expect(fsCtx.wOkProbes).not.toContain(cacheDirectory); expect(cacheGuardSpawnCount()).toBeGreaterThan(0); } finally { diff --git a/gitnexus/test/unit/analyzer-identity.test.ts b/gitnexus/test/unit/analyzer-identity.test.ts index 0c98c9096..981da0459 100644 --- a/gitnexus/test/unit/analyzer-identity.test.ts +++ b/gitnexus/test/unit/analyzer-identity.test.ts @@ -1,6 +1,15 @@ import { createHash } from 'node:crypto'; import { writeFileSync } from 'node:fs'; -import { link, mkdir, readFile, readdir, symlink, unlink, writeFile } from 'node:fs/promises'; +import { + link, + mkdir, + readFile, + readdir, + realpath, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; import { performance } from 'node:perf_hooks'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -34,6 +43,10 @@ describe('analyzer runner identity', () => { await writeFile(path.join(fixture.dbPath, 'package-lock.json'), '{"lockfileVersion":3}\n'); await writeFile(modulePath, 'export const analyzer = 1;\n'); const cacheDirectory = path.join(fixture.dbPath, 'identity-cache'); + const resolvedModulePath = await realpath(modulePath); + const resolvedSourceRoot = await realpath(sourceRoot); + const resolvedManifestPath = await realpath(path.join(fixture.dbPath, 'package.json')); + const resolvedLockfilePath = await realpath(path.join(fixture.dbPath, 'package-lock.json')); const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, { cacheDirectory, @@ -50,18 +63,18 @@ describe('analyzer runner identity', () => { libc: expect.any(String), }, invokedArtifact: { - path: modulePath, + path: resolvedModulePath, digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), }, build: { kind: 'source', - rootPath: sourceRoot, + rootPath: resolvedSourceRoot, canonicalization: 'gitnexus-analyzer-build-v2', digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), }, dependencyRuntime: { - manifestPath: path.join(fixture.dbPath, 'package.json'), - lockfilePath: path.join(fixture.dbPath, 'package-lock.json'), + manifestPath: resolvedManifestPath, + lockfilePath: resolvedLockfilePath, canonicalization: 'gitnexus-analyzer-dependency-runtime-v4', packageCount: 1, artifactCount: 0, @@ -312,7 +325,7 @@ describe('analyzer runner identity', () => { return bytes; }; - process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = protectedCache.dbPath; + process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = await realpath(protectedCache.dbPath); _clearAnalyzerIdentityProcessCacheForTests(); expect(hashedBytes()).toBeGreaterThanOrEqual(64 * 1024); _clearAnalyzerIdentityProcessCacheForTests(); @@ -602,7 +615,7 @@ describe('analyzer runner identity', () => { const withLock = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, { cacheDirectory, }); - expect(withLock.dependencyRuntime.lockfilePath).toBe(ancestorLock); + expect(withLock.dependencyRuntime.lockfilePath).toBe(await realpath(ancestorLock)); expect(withLock.dependencyRuntime.digest).not.toBe(withoutLock.dependencyRuntime.digest); } finally { await fixture.cleanup(); @@ -1095,7 +1108,9 @@ describe('analyzer runner identity', () => { const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, { cacheDirectory, }); - expect(first.dependencyRuntime.lockfilePath).toBe(lockLink); + expect(first.dependencyRuntime.lockfilePath).toBe( + path.join(await realpath(path.dirname(lockLink)), path.basename(lockLink)), + ); await writeFile(lockTarget, '{"lockfileVersion":4,"changed":true}\n'); const targetChanged = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, { diff --git a/gitnexus/test/unit/api-file-route.test.ts b/gitnexus/test/unit/api-file-route.test.ts index 71713ff8b..904bc0c4a 100644 --- a/gitnexus/test/unit/api-file-route.test.ts +++ b/gitnexus/test/unit/api-file-route.test.ts @@ -24,7 +24,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import path from 'node:path'; import fs from 'node:fs/promises'; import os from 'node:os'; -import { handleFileRequest } from '../../src/server/api.js'; +import { handleFileRequest, type SourceAvailability } from '../../src/server/api.js'; let tmpRoot: string; @@ -42,7 +42,10 @@ afterAll(async () => { // Minimal express-shaped mock that captures status() / json() calls in a // shape compatible with the handler's expected interface. Returns the // final status (default 200 for naked res.json) and JSON body. -const invoke = async (query: Record): Promise<{ status: number; body: any }> => { +const invoke = async ( + query: Record, + availability?: SourceAvailability, +): Promise<{ status: number; body: any }> => { let capturedStatus = 200; let capturedBody: any = undefined; const res = { @@ -54,7 +57,7 @@ const invoke = async (query: Record): Promise<{ status: number; capturedBody = body; }, }; - await handleFileRequest({ query }, res, tmpRoot); + await handleFileRequest({ query }, res, tmpRoot, availability); return { status: capturedStatus, body: capturedBody }; }; @@ -71,6 +74,15 @@ describe('handleFileRequest — security wiring', () => { expect(body.content).toBe('nested\n'); }); + it('returns 410 when the selected retention profile cannot provide full source', async () => { + const { status, body } = await invoke( + { path: 'hello.txt' }, + { available: false, reason: 'content-retention' }, + ); + expect(status).toBe(410); + expect(body).toMatchObject({ code: 'source-unavailable', reason: 'content-retention' }); + }); + it('returns 400 when path is missing', async () => { const { status, body } = await invoke({}); expect(status).toBe(400); diff --git a/gitnexus/test/unit/api-fts-mode.test.ts b/gitnexus/test/unit/api-fts-mode.test.ts index 119dfb7bd..85f36f77b 100644 --- a/gitnexus/test/unit/api-fts-mode.test.ts +++ b/gitnexus/test/unit/api-fts-mode.test.ts @@ -1,5 +1,7 @@ import express from 'express'; import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -16,6 +18,10 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ loadMeta: mocks.loadMeta, listRegisteredRepos: mocks.listRegisteredRepos, })); +vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireRegisteredStoragePath: vi.fn(async (entry: { storagePath: string }) => entry.storagePath), +})); vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ withLbugDb: mocks.withLbugDb, executeQuery: vi.fn(async () => []), @@ -68,11 +74,13 @@ vi.mock('../../src/server/analyze-job.js', () => ({ import { createServer } from '../../src/server/api.js'; import { FTS_DISABLED_MESSAGE } from '../../src/core/search/fts-policy.js'; +const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fts-mode-fixture-')); const entry = { name: 'fts-mode-fixture', - path: path.resolve('fts-mode-fixture'), - storagePath: path.resolve('fts-mode-fixture/.gitnexus'), + path: fixtureRoot, + storagePath: path.join(fixtureRoot, '.gitnexus'), }; +fs.mkdirSync(entry.storagePath, { recursive: true }); let app: express.Express; const events = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'] as const; const originalListeners = new Map(events.map((event) => [event, process.listeners(event)])); @@ -102,6 +110,7 @@ afterAll(() => { } } vi.unstubAllEnvs(); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); }); beforeEach(() => { @@ -199,7 +208,8 @@ describe('serve uses one metadata-derived FTS mode on every DB-open path', () => } } expect(mocks.withLbugDb).toHaveBeenCalledTimes(sequence.length); - expect(mocks.loadMeta).toHaveBeenCalledTimes(sequence.length); + // Grep also loads metadata for getSourceAvailability before the FTS session. + expect(mocks.loadMeta).toHaveBeenCalledTimes(sequence.length + 1); for (const [dbPath, , options] of mocks.withLbugDb.mock.calls) { expect(dbPath).toBe(path.join(entry.storagePath, 'lbug')); expect(options).toEqual({ readOnly: true, ...(skip ? { skipFts: true } : {}) }); @@ -240,3 +250,12 @@ describe('serve uses one metadata-derived FTS mode on every DB-open path', () => }, ); }); + +describe('GET /api/repos catalog validation', () => { + it('lists registered repos with validate: true', async () => { + mocks.loadMeta.mockResolvedValue({}); + await invoke('/api/repos'); + expect(mocks.listRegisteredRepos).toHaveBeenCalledWith({ validate: true }); + expect(mocks.listRegisteredRepos).toHaveBeenCalledTimes(1); + }); +}); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 3daa804a1..78c786004 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -292,6 +292,14 @@ describe('LocalBackend.init', () => { await backend.init(); expect(listRegisteredRepos).toHaveBeenCalledWith({ validate: true }); }); + + it('does not delete legacy Kuzu files while initializing a read backend', async () => { + setupSingleRepo(); + + await backend.init(); + + expect(cleanupOldKuzuFiles).not.toHaveBeenCalled(); + }); }); describe('LocalBackend.countRepos', () => { @@ -1187,13 +1195,22 @@ describe('LocalBackend.callTool', () => { expect(result.error).toContain('Either "name" or "uid"'); }); - it('context tool returns not-found for missing symbol', async () => { + it('context tool returns content availability with a missing symbol', async () => { (executeParameterized as any).mockResolvedValue([]); - const result = await backend.callTool('context', { name: 'doesNotExist' }); + const result = await backend.callTool('context', { + name: 'doesNotExist', + include_content: true, + }); expect(result.error).toContain('not found'); + expect(result.contentAvailability).toEqual({ + requested: true, + profile: 'full', + available: true, + scope: 'full', + }); }); - it('context tool returns disambiguation for multiple matches', async () => { + it('context tool returns content availability with ambiguous matches', async () => { (executeParameterized as any).mockResolvedValue([ { id: 'func:main:1', @@ -1212,9 +1229,15 @@ describe('LocalBackend.callTool', () => { endLine: 5, }, ]); - const result = await backend.callTool('context', { name: 'main' }); + const result = await backend.callTool('context', { name: 'main', include_content: true }); expect(result.status).toBe('ambiguous'); expect(result.candidates).toHaveLength(2); + expect(result.contentAvailability).toEqual({ + requested: true, + profile: 'full', + available: true, + scope: 'full', + }); // #470: every candidate carries a relevance score in [0, 1] and the list // is sorted descending by score (with deterministic tiebreakers). diff --git a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts index 541dfff95..0e4730a00 100644 --- a/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts +++ b/gitnexus/test/unit/canonicalize-path-long-path-prefix.test.ts @@ -145,6 +145,7 @@ describe('canonicalizePath vs the `\\\\?\\` long-path prefix (#2667)', () => { // Pinned here because "complete the fix by stripping here too" is the tempting // follow-up refactor, and it would widen what the recursive delete accepts. describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => { + const itOnWindows = process.platform === 'win32' ? it : it.skip; const base: Omit = { name: 'repo', path: '\\\\?\\D:\\Projects\\repo', @@ -152,16 +153,16 @@ describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => { lastCommit: 'deadbee', }; - it('accepts an entry whose path and storagePath share the prefix', () => { - expect(() => + itOnWindows('accepts an entry whose path and storagePath share the prefix', async () => { + await expect( assertSafeStoragePath({ ...base, storagePath: '\\\\?\\D:\\Projects\\repo\\.gitnexus' }), - ).not.toThrow(); + ).resolves.toBeUndefined(); }); - it('rejects a mixed-form entry instead of deleting through it', () => { - expect(() => + itOnWindows('rejects a mixed-form entry instead of deleting through it', async () => { + await expect( assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }), - ).toThrow(); + ).rejects.toThrow(); }); }); diff --git a/gitnexus/test/unit/clean-command-ownership.test.ts b/gitnexus/test/unit/clean-command-ownership.test.ts new file mode 100644 index 000000000..22f4cca4f --- /dev/null +++ b/gitnexus/test/unit/clean-command-ownership.test.ts @@ -0,0 +1,119 @@ +/** + * Regression for PR #3060: ordinary `clean --force` must not trust a + * registry entry that redirects repository A to repository B's external index. + * + * This deliberately drives the real clean command, resolver, registry reader, + * and filesystem. Guard-only tests cannot catch a future bypass in clean.ts. + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanCommand } from '../../src/cli/clean.js'; +import { createTempDir, type TestDBHandle } from '../helpers/test-db.js'; +import { initGitRepo } from '../helpers/temp-git-repo.js'; + +describe('cleanCommand external storage ownership', () => { + let fixture: TestDBHandle; + let previousGitNexusHome: string | undefined; + let repoA: string; + let repoB: string; + let storageB: string; + let registryPath: string; + + beforeEach(async () => { + fixture = await createTempDir(); + previousGitNexusHome = process.env.GITNEXUS_HOME; + + const home = path.join(fixture.dbPath, 'home'); + repoA = path.join(fixture.dbPath, 'repo-a'); + repoB = path.join(fixture.dbPath, 'repo-b'); + storageB = path.join(fixture.dbPath, 'external-index-b'); + registryPath = path.join(home, 'registry.json'); + + await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(repoA), fs.mkdir(repoB)]); + initGitRepo(repoA); + initGitRepo(repoB); + + await fs.mkdir(storageB); + const metadata = { + repoPath: repoB, + storagePath: storageB, + lastCommit: 'b-indexed-commit', + indexedAt: '2026-09-05T00:00:00.000Z', + }; + await Promise.all([ + fs.writeFile(path.join(storageB, 'gitnexus.json'), JSON.stringify(metadata)), + fs.writeFile(path.join(storageB, 'meta.json'), JSON.stringify(metadata)), + fs.writeFile(path.join(storageB, 'ownership-sentinel'), 'must survive\n'), + ]); + + // Deliberately corrupted registry: repository A names B's valid index. + await fs.writeFile( + registryPath, + JSON.stringify([ + { + name: 'repo-a', + path: repoA, + storagePath: storageB, + lastCommit: 'a-indexed-commit', + indexedAt: '2026-09-05T00:00:00.000Z', + }, + ]), + ); + + process.env.GITNEXUS_HOME = home; + vi.spyOn(process, 'cwd').mockReturnValue(repoA); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (previousGitNexusHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = previousGitNexusHome; + await fixture.cleanup(); + }); + + it('deletes a foreign repository-local .gitnexus on clean --all --force', async () => { + const localStorage = path.join(repoA, '.gitnexus'); + await fs.mkdir(localStorage, { recursive: true }); + const metadata = { + repoPath: repoB, + storagePath: localStorage, + lastCommit: 'foreign-local-commit', + indexedAt: '2026-09-05T00:00:00.000Z', + }; + await fs.writeFile(path.join(localStorage, 'gitnexus.json'), JSON.stringify(metadata)); + await fs.writeFile(path.join(localStorage, 'ownership-sentinel'), 'local-foreign\n'); + await fs.writeFile( + registryPath, + JSON.stringify([ + { + name: 'repo-a', + path: repoA, + storagePath: localStorage, + lastCommit: 'a-indexed-commit', + indexedAt: '2026-09-05T00:00:00.000Z', + }, + ]), + ); + + await cleanCommand({ force: true, all: true }); + + await expect(fs.access(localStorage)).rejects.toBeTruthy(); + expect(JSON.parse(await fs.readFile(registryPath, 'utf-8'))).toEqual([]); + }); + + it('preserves a foreign external index and registry entry on ordinary clean --force', async () => { + await cleanCommand({ force: true }); + + await expect(fs.access(storageB)).resolves.toBeUndefined(); + await expect(fs.access(path.join(storageB, 'gitnexus.json'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(storageB, 'meta.json'))).resolves.toBeUndefined(); + await expect(fs.readFile(path.join(storageB, 'ownership-sentinel'), 'utf-8')).resolves.toBe( + 'must survive\n', + ); + + const [remainingEntry] = JSON.parse(await fs.readFile(registryPath, 'utf-8')); + expect(remainingEntry).toMatchObject({ path: repoA, storagePath: storageB }); + }); +}); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index 51f187ab9..c0d6caf9f 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -192,12 +192,31 @@ describe('CLI help surface', () => { expect(result.status).toBe(0); expect(result.stdout).toContain('环境变量:'); + expect(result.stdout).toContain('GITNEXUS_STORAGE_PATH=/absolute/index'); + expect(result.stdout).toContain('完整外部索引目录'); + expect(result.stdout).toContain('GITNEXUS_STORAGE_ROOT=/absolute/root'); + expect(result.stdout).toContain('外部索引根目录'); + expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full'); + expect(result.stdout).toContain('源码文本保留策略'); expect(result.stdout).toContain('当参数和对应环境变量同时提供时,参数优先。'); expect(result.stdout).toContain('提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。'); expect(result.stdout).not.toContain('Environment variables:'); expect(result.stdout).not.toContain('Flags override the corresponding env vars'); }); + it('analyze help documents the external storage root layout', () => { + const result = runHelp('analyze'); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('GITNEXUS_STORAGE_PATH=/absolute/index'); + expect(result.stdout).toContain('Complete external index directory'); + expect(result.stdout).toContain('GITNEXUS_STORAGE_ROOT=/absolute/root'); + expect(result.stdout).toContain('External index root'); + expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full'); + expect(result.stdout).toContain('Source-text retention profile'); + expect(result.stdout).toContain('-/'); + }); + it('query help keeps advanced search options without importing analyze deps', () => { const result = runHelp('query'); diff --git a/gitnexus/test/unit/content-retention.test.ts b/gitnexus/test/unit/content-retention.test.ts new file mode 100644 index 000000000..bbf2e41d0 --- /dev/null +++ b/gitnexus/test/unit/content-retention.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + applyContentRetention, + contentRetentionFromEnvironment, + contentRetentionFromMeta, + contentRetentionMismatch, + ftsProfileForContentRetention, +} from '../../src/core/content-retention.js'; +import { getFtsIndexes } from '../../src/core/search/fts-schema.js'; +import { buildTestGraph } from '../helpers/test-graph.js'; + +const savedRetention = process.env.GITNEXUS_CONTENT_RETENTION; + +afterEach(() => { + if (savedRetention === undefined) delete process.env.GITNEXUS_CONTENT_RETENTION; + else process.env.GITNEXUS_CONTENT_RETENTION = savedRetention; +}); + +const contentGraph = () => + buildTestGraph([ + { + id: 'File:src/index.ts', + label: 'File', + name: 'index.ts', + filePath: 'src/index.ts', + extra: { content: 'const retainedFileText = true;' }, + }, + { + id: 'Function:src/index.ts:run:1', + label: 'Function', + name: 'run', + filePath: 'src/index.ts', + startLine: 1, + endLine: 3, + extra: { + content: 'function run() { return retainedSymbolText; }', + description: 'source comment', + }, + }, + { + id: 'BasicBlock:src/index.ts:run:1:0', + label: 'BasicBlock', + name: 'block', + filePath: 'src/index.ts', + startLine: 1, + endLine: 1, + extra: { text: 'return retainedBlockText;', description: 'block annotation' }, + }, + ]); + +describe('content retention profiles', () => { + it('uses full when the environment is absent or blank and rejects explicit invalid values', () => { + delete process.env.GITNEXUS_CONTENT_RETENTION; + expect(contentRetentionFromEnvironment()).toBe('full'); + process.env.GITNEXUS_CONTENT_RETENTION = ' '; + expect(contentRetentionFromEnvironment()).toBe('full'); + process.env.GITNEXUS_CONTENT_RETENTION = 'archive'; + expect(() => contentRetentionFromEnvironment()).toThrow(/GITNEXUS_CONTENT_RETENTION/); + }); + + it('keeps every existing text field in the full profile', () => { + const graph = contentGraph(); + applyContentRetention(graph, 'full'); + + expect(graph.getNode('File:src/index.ts')?.properties.content).toContain('retainedFileText'); + expect(graph.getNode('Function:src/index.ts:run:1')?.properties.content).toContain( + 'retainedSymbolText', + ); + expect(graph.getNode('BasicBlock:src/index.ts:run:1:0')?.properties.text).toContain( + 'retainedBlockText', + ); + }); + + it('removes file text but preserves symbol spans in the symbol profile', () => { + const graph = contentGraph(); + applyContentRetention(graph, 'symbol'); + + expect(graph.getNode('File:src/index.ts')?.properties.content).toBeUndefined(); + expect(graph.getNode('Function:src/index.ts:run:1')?.properties.content).toContain( + 'retainedSymbolText', + ); + expect(graph.getNode('Function:src/index.ts:run:1')?.properties.description).toBe( + 'source comment', + ); + expect(graph.getNode('BasicBlock:src/index.ts:run:1:0')?.properties.text).toBeUndefined(); + }); + + it('removes every source-derived text field in the none profile', () => { + const graph = contentGraph(); + applyContentRetention(graph, 'none'); + + for (const node of graph.nodes) { + expect(node.properties.content).toBeUndefined(); + expect(node.properties.description).toBeUndefined(); + } + expect(graph.getNode('BasicBlock:src/index.ts:run:1:0')?.properties.text).toBeUndefined(); + }); + + it('treats legacy metadata as full and forces a rebuild for changed retention metadata', () => { + expect(contentRetentionFromMeta({})).toBe('full'); + expect(contentRetentionFromMeta({ contentRetention: 'corrupt' } as never)).toBe('none'); + expect(contentRetentionMismatch({}, 'full')).toBe(false); + expect(contentRetentionMismatch({}, 'symbol')).toBe(true); + expect(contentRetentionMismatch({ contentRetention: 'full' }, 'full')).toBe(true); + expect(contentRetentionMismatch({ contentRetention: 'symbol' }, 'symbol')).toBe(true); + expect( + contentRetentionMismatch( + { + contentRetention: 'symbol', + contentRetentionSchemaVersion: 1, + ftsProfile: 'symbol-no-file-content', + }, + 'symbol', + ), + ).toBe(false); + expect( + contentRetentionMismatch( + { + contentRetention: 'symbol', + contentRetentionSchemaVersion: 2, + ftsProfile: 'symbol-no-file-content', + }, + 'symbol', + ), + ).toBe(true); + expect( + contentRetentionMismatch( + { + contentRetention: 'symbol', + contentRetentionSchemaVersion: 1, + }, + 'symbol', + ), + ).toBe(true); + }); + + it('selects FTS columns that never require discarded text', () => { + expect(ftsProfileForContentRetention('full')).toBe('full'); + expect(ftsProfileForContentRetention('symbol')).toBe('symbol-no-file-content'); + expect(ftsProfileForContentRetention('none')).toBe('name-only'); + expect(getFtsIndexes('full').find((index) => index.table === 'File')?.properties).toEqual([ + 'name', + 'content', + ]); + expect( + getFtsIndexes('symbol-no-file-content').find((index) => index.table === 'File')?.properties, + ).toEqual(['name']); + expect(getFtsIndexes('name-only').every((index) => index.properties.length === 1)).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts index dc9bc423b..4fe58eafa 100644 --- a/gitnexus/test/unit/cursor-hook.test.ts +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -6,7 +6,7 @@ * * Covers: * - extractPattern: pattern extraction from Grep/Read/Shell tool inputs - * - findGitNexusDir: .gitnexus directory discovery (shared with Claude hook) + * - findRegisteredRepo: registry-backed repository discovery * - cwd validation: rejects relative paths * - shell injection: verifies no `shell: true` in spawnSync calls * - cross-platform: Windows .cmd extension handling @@ -23,7 +23,7 @@ import { createRequire } from 'module'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import { runHook } from '../utils/hook-test-helpers.js'; +import { runHook as spawnHook } from '../utils/hook-test-helpers.js'; import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js'; // ─── Path to the Cursor hook + manifest ───────────────────────────── @@ -82,6 +82,7 @@ let tmpDir: string; // deliberately has no .gitnexus so unrelated early-exit tests stay cheap. let guardTmpDir: string; let guardGitNexusDir: string; +let hookHome: string; beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-test-')); @@ -93,13 +94,38 @@ beforeAll(() => { initGitRepo(guardTmpDir, { name: 'Test', email: 'test@test.com' }); fs.writeFileSync(path.join(guardTmpDir, 'dummy.txt'), 'hello'); commitAll(guardTmpDir, 'init'); + + hookHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-home-')); + fs.writeFileSync( + path.join(hookHome, 'registry.json'), + JSON.stringify([ + { + name: 'cursor-guard', + path: guardTmpDir, + storagePath: guardGitNexusDir, + }, + ]), + ); }); afterAll(() => { + fs.rmSync(hookHome, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(guardTmpDir, { recursive: true, force: true }); }); +function runHook( + hookPath: string, + input: Record, + cwd?: string, + options: { env?: NodeJS.ProcessEnv } = {}, +) { + return spawnHook(hookPath, input, cwd, { + ...options, + env: { ...(options.env ?? process.env), GITNEXUS_HOME: hookHome }, + }); +} + // ─── Manifest + hook file presence ─────────────────────────────────── describe('Cursor integration files', () => { @@ -208,17 +234,25 @@ describe('Cursor hook source regressions', () => { expect(source).toMatch(/'augment',\s*'--',\s*pattern/); }); - it('gates on a non-global .gitnexus directory before invoking the CLI', () => { - expect(source).toContain('findGitNexusDir'); - expect(source).toContain('isGlobalRegistryDir'); - }); - - it('isGlobalRegistryDir recognizes gitnexus.json as well as legacy meta.json', () => { - expect(source).toContain('gitnexus.json'); + it('gates on a registry entry before invoking the CLI', () => { + expect(source).toContain('resolveHookRepo'); + expect(source).toContain('registry-query.cjs'); }); it('handles linked git worktrees via git rev-parse --git-common-dir', () => { - expect(source).toContain('--git-common-dir'); + const resolver = fs.readFileSync( + path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'registry-query.cjs', + ), + 'utf-8', + ); + expect(resolver).toContain('--git-common-dir'); }); }); diff --git a/gitnexus/test/unit/fts-indexes.test.ts b/gitnexus/test/unit/fts-indexes.test.ts index 915abc5db..84a9dd0ee 100644 --- a/gitnexus/test/unit/fts-indexes.test.ts +++ b/gitnexus/test/unit/fts-indexes.test.ts @@ -35,7 +35,7 @@ const { initialiseSearchFTSStemmer, missingSearchFTSIndexTables, } = await import('../../src/core/search/fts-indexes.js'); -const { FTS_INDEXES } = await import('../../src/core/search/fts-schema.js'); +const { FTS_INDEXES, getFtsIndexes } = await import('../../src/core/search/fts-schema.js'); const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js'); /** SHOW_INDEXES rows covering every configured FTS index's expected properties. */ @@ -76,6 +76,16 @@ describe('createSearchFTSIndexes', () => { ]); }); + it('applies the table filter within the selected content-retention profile', async () => { + await createSearchFTSIndexes({ + indexes: getFtsIndexes('name-only'), + tables: new Set(['Function']), + }); + + expect(calls).toEqual(['drop:Function.function_fts', 'create:Function.function_fts:porter']); + expect(createFTSIndex).toHaveBeenCalledWith('Function', 'function_fts', ['name'], 'porter'); + }); + it('invokes onIndexStart/onIndexReady once per index', async () => { const started: string[] = []; const ready: string[] = []; diff --git a/gitnexus/test/unit/group/bridge-meta-swap-window.test.ts b/gitnexus/test/unit/group/bridge-meta-swap-window.test.ts index 5ad4a4258..a1ab45817 100644 --- a/gitnexus/test/unit/group/bridge-meta-swap-window.test.ts +++ b/gitnexus/test/unit/group/bridge-meta-swap-window.test.ts @@ -185,6 +185,7 @@ describe('bridgeMetaMatchesFile with a half-written stamp', () => { afterEach(async () => { renameMock.mode = 'none'; + await closeAllCachedBridges(); await fsp.rm(groupDir, { recursive: true, force: true }); }); @@ -246,6 +247,14 @@ describe('bridgeMetaMatchesFile with a half-written stamp', () => { await seedStamped(); const meta = await readBridgeMeta(groupDir); await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true); + // Distinct filesystem mtimes must not collapse: rounding would treat + // T and T+0.25 as the same stamp and wave a same-size swap through. + await expect( + bridgeMetaMatchesFile(groupDir, { + ...meta, + bridgeMtimeMs: (meta.bridgeMtimeMs as number) + 0.25, + }), + ).resolves.toBe(false); }); }); diff --git a/gitnexus/test/unit/group/sync-registry-identity.test.ts b/gitnexus/test/unit/group/sync-registry-identity.test.ts index 309a2c0fd..fb63a1386 100644 --- a/gitnexus/test/unit/group/sync-registry-identity.test.ts +++ b/gitnexus/test/unit/group/sync-registry-identity.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs/promises'; -import { mkdirSync } from 'node:fs'; +import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { syncGroup } from '../../../src/core/group/sync.js'; import { RegistryAmbiguousTargetError } from '../../../src/storage/repo-manager.js'; @@ -49,11 +49,22 @@ const row = ( indexedAt: string; lastCommit: string; } => { - mkdirSync(path.join(tmpHome, 'repos', clone), { recursive: true }); + const repoPath = path.join(tmpHome, 'repos', clone); + const storagePath = path.join(repoPath, '.gitnexus'); + mkdirSync(path.join(storagePath, 'lbug'), { recursive: true }); + writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ + repoPath, + storagePath, + indexedAt: '2026-01-01T00:00:00.000Z', + lastCommit: 'abc123', + }), + ); return { name, - path: path.join(tmpHome, 'repos', clone), - storagePath: path.join(tmpHome, 'repos', clone, '.gitnexus'), + path: repoPath, + storagePath, indexedAt: '2026-01-01T00:00:00.000Z', lastCommit: 'abc123', }; @@ -121,6 +132,23 @@ describe('syncGroup registry name identity', () => { }); }); + it('treats a registry member with foreign storage metadata as unreadable', async () => { + const known = row(tmpHome.dbPath, 'backend-repo', 'backend'); + await fs.writeFile( + path.join(known.storagePath, 'gitnexus.json'), + JSON.stringify({ repoPath: path.join(tmpHome.dbPath, 'repos', 'other') }), + ); + await fs.writeFile(registryPath, JSON.stringify([known])); + + const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { + skipWrite: true, + }); + + expect(result.missingRepos).toEqual([]); + expect(result.unreadableRepos).toEqual(['app/backend']); + expect(initLbugMock).not.toHaveBeenCalled(); + }); + it('treats mixed missing and ambiguous names as a terminal ambiguity with no write', async () => { const a = row(tmpHome.dbPath, 'demo-api', 'clone-a'); const b = row(tmpHome.dbPath, 'demo-api', 'clone-b'); diff --git a/gitnexus/test/unit/group/sync-unreadable-repos.test.ts b/gitnexus/test/unit/group/sync-unreadable-repos.test.ts index 63fea7bde..440c67fe4 100644 --- a/gitnexus/test/unit/group/sync-unreadable-repos.test.ts +++ b/gitnexus/test/unit/group/sync-unreadable-repos.test.ts @@ -75,6 +75,20 @@ vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => { }; }); +// This file focuses on failures from `initLbug` and extraction after a member +// has resolved. Keep its deliberately synthetic registry paths from being +// intercepted by the production storage inspection; the real registry-backed +// storage gate is covered by sync-registry-identity.test.ts. +vi.mock('../../../src/storage/storage-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + requireRegisteredStoragePath: vi.fn( + async (entry: { storagePath: string }) => entry.storagePath, + ), + }; +}); + /** * Armed by the bridge-write-failure suite at the bottom of this file, `null` * everywhere else. There is no filesystem shape that makes the real writer fail diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index 54e709233..5d6c74a11 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -6,7 +6,7 @@ * * Covers: * - extractPattern: pattern extraction from Grep/Glob/Bash tool inputs - * - findGitNexusDir: .gitnexus directory discovery + * - findRegisteredRepo: registry-backed repository discovery * - handlePostToolUse: staleness detection after git mutations * - cwd validation: rejects relative paths (defense-in-depth) * - shell injection: verifies no shell: true in spawnSync calls @@ -19,12 +19,13 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'child_process'; +import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { - runHook, + runHook as spawnHook, parseHookOutput, createHookToolDir, createFakeProcRoot, @@ -35,6 +36,14 @@ import { commitAll, initGitRepo, type GitIdentity } from '../helpers/temp-git-re // ─── Paths to both hook variants ──────────────────────────────────── const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs'); +const CJS_REGISTRY_QUERY = path.resolve( + __dirname, + '..', + '..', + 'hooks', + 'claude', + 'registry-query.cjs', +); const CJS_HOOK_LOCK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-lock.cjs'); const RESOLVE_CJS = path.resolve( __dirname, @@ -140,7 +149,7 @@ function resolveHostGuardForReapingTests(): string | null { return hostGuardMemo; } -// ─── Test fixtures: temporary .gitnexus directory ─────────────────── +// ─── Test fixtures: temporary indexed repository ──────────────────── function writeSelfTestingGuardWithMarkers( guardPath: string, @@ -172,6 +181,8 @@ process.exit(child.status ?? 0); let tmpDir: string; let gitNexusDir: string; +let hookHome: string; +const originalGitNexusHome = process.env.GITNEXUS_HOME; const HOOK_TEST_IDENTITY: GitIdentity = { name: 'Test', email: 'test@test.com' }; @@ -184,9 +195,25 @@ beforeAll(() => { initGitRepo(tmpDir, HOOK_TEST_IDENTITY); fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello'); commitAll(tmpDir, 'init'); + + hookHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + fs.writeFileSync( + path.join(hookHome, 'registry.json'), + JSON.stringify([ + { + name: 'hook-test', + path: tmpDir, + storagePath: gitNexusDir, + }, + ]), + ); + process.env.GITNEXUS_HOME = hookHome; }); afterAll(() => { + if (originalGitNexusHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = originalGitNexusHome; + fs.rmSync(hookHome, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -219,16 +246,70 @@ function initRepoWithCommit(dir: string) { } function createGlobalRegistry(homeDir: string, marker: 'both' | 'registry' | 'repos' = 'both') { - const registryDir = path.join(homeDir, '.gitnexus'); - fs.mkdirSync(registryDir, { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); if (marker === 'both' || marker === 'repos') { - fs.mkdirSync(path.join(registryDir, 'repos'), { recursive: true }); + fs.mkdirSync(path.join(homeDir, 'repos'), { recursive: true }); } if (marker === 'both' || marker === 'registry') { - fs.writeFileSync(path.join(registryDir, 'registry.json'), JSON.stringify({ repos: [] })); + fs.writeFileSync(path.join(homeDir, 'registry.json'), JSON.stringify([])); } } +function writeHookRegistry( + homeDir: string, + entries: Array<{ + name: string; + path: string; + storagePath?: string; + branches?: Array<{ branch: string; indexedAt?: string; lastCommit?: string }>; + }>, +) { + fs.mkdirSync(homeDir, { recursive: true }); + fs.writeFileSync(path.join(homeDir, 'registry.json'), JSON.stringify(entries)); +} + +function loadRegistryQuery() { + return createRequire(import.meta.url)(CJS_REGISTRY_QUERY) as { + findRegisteredRepo: (cwd: string) => { + path: string; + storagePath: string; + lbugPath: string; + metadata: { lastCommit?: string } | null; + } | null; + findLocalOwnedRepo: (cwd: string) => { + path: string; + storagePath: string; + lbugPath: string; + metadata: { lastCommit?: string } | null; + } | null; + resolveHookRepo: (cwd: string) => { + path: string; + storagePath: string; + lbugPath: string; + metadata: { lastCommit?: string } | null; + } | null; + }; +} + +function findRegisteredRepoForTest(cwd: string) { + return loadRegistryQuery().findRegisteredRepo(cwd); +} + +function runHook( + hookPath: string, + input: Record, + cwd?: string, + options: { env?: NodeJS.ProcessEnv; registryHome?: string } = {}, +) { + const { env, registryHome = hookHome } = options; + if (registryHome === hookHome) { + writeHookRegistry(hookHome, [{ name: 'hook-test', path: tmpDir, storagePath: gitNexusDir }]); + } + return spawnHook(hookPath, input, cwd, { + env: { ...(env ?? process.env), GITNEXUS_HOME: registryHome }, + }); +} + // createHookToolDir / hookEnv live in ../utils/hook-test-helpers so the antigravity // e2e suite can reuse the same DB-owner-probe fakes. @@ -304,6 +385,10 @@ describe('windowsHide regression', () => { // Hook-layer files. Adding a new hook file MUST be reflected here. const HOOK_FILES: Array = [ ['gitnexus/hooks/claude/gitnexus-hook.cjs', CJS_HOOK], + [ + 'gitnexus/hooks/claude/registry-query.cjs', + path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'registry-query.cjs'), + ], ['gitnexus/hooks/claude/resolve-analyze-cmd.cjs', RESOLVE_CJS], ['gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs', RESOLVE_PLUGIN_CJS], [ @@ -315,6 +400,30 @@ describe('windowsHide regression', () => { path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-db-lock-probe.cjs'), ], ['gitnexus-claude-plugin/hooks/gitnexus-hook.js', PLUGIN_HOOK], + [ + 'gitnexus-claude-plugin/hooks/registry-query.cjs', + path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-claude-plugin', + 'hooks', + 'registry-query.cjs', + ), + ], + [ + 'gitnexus-cursor-integration/hooks/registry-query.cjs', + path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'registry-query.cjs', + ), + ], [ 'gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs', path.resolve( @@ -966,6 +1075,28 @@ describe('Cross-platform DB lock probe (source)', () => { // ─── Source: hook slot must gate the DB-owner probe (#2163) ────────── +describe('PreToolUse source order: tool guard before registry scan', () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: tool guard appears before resolveHookRepo in handlePreToolUse`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + const start = source.indexOf('function handlePreToolUse'); + const end = source.indexOf('function handlePostToolUse'); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + const preBody = source.slice(start, end); + const grepGuardIdx = preBody.indexOf("toolName !== 'Grep'"); + const extractIdx = preBody.indexOf('extractPattern('); + const resolveIdx = preBody.indexOf('resolveHookRepo('); + expect(grepGuardIdx).toBeGreaterThanOrEqual(0); + expect(extractIdx).toBeGreaterThan(grepGuardIdx); + expect(resolveIdx).toBeGreaterThan(extractIdx); + }); + } +}); + describe('Hook slot gates the DB-owner probe (source order, #2163)', () => { const ANTIGRAVITY_HOOK = path.resolve( __dirname, @@ -1007,6 +1138,19 @@ describe('Hook slot gates the DB-owner probe (source order, #2163)', () => { expect(probeIdx).toBeGreaterThan(acquireIdx); }); } + + it('Antigravity: extractPattern appears before resolveHookRepo', () => { + const source = fs.readFileSync(ANTIGRAVITY_HOOK, 'utf-8'); + const start = source.indexOf('function buildAfterToolContext'); + const end = source.indexOf('function buildMcpQueryHint'); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + const body = source.slice(start, end); + const extractIdx = body.indexOf('extractPattern('); + const resolveIdx = body.indexOf('resolveHookRepo('); + expect(extractIdx).toBeGreaterThanOrEqual(0); + expect(resolveIdx).toBeGreaterThan(extractIdx); + }); }); // ─── Behavior: slot-gated probe + wrapper-reaped orphans (#2163) ───── @@ -1129,6 +1273,7 @@ describe.skipIf(process.platform === 'win32')( 'hook-lock.cjs', 'hook-db-lock-probe.cjs', 'resolve-analyze-cmd.cjs', + 'registry-query.cjs', 'win-rm-list-json.ps1', ]) { fs.copyFileSync(path.join(claudeHooksDir, helper), path.join(stageDir, helper)); @@ -1460,6 +1605,7 @@ describe.skipIf(process.platform !== 'linux')( 'hook-lock.cjs', 'hook-db-lock-probe.cjs', 'resolve-analyze-cmd.cjs', + 'registry-query.cjs', ]) { fs.copyFileSync(path.join(hookSrcDir, f), path.join(stagedDir, f)); } @@ -1751,6 +1897,17 @@ describe('Cursor hook slot-skip diagnostic (source, #2163 follow-up)', () => { const before = source.slice(Math.max(0, idx - 600), idx); expect(before).toContain('process.env.GITNEXUS_DEBUG'); }); + + it('extracts a pattern before any registry scan', () => { + const source = fs.readFileSync(CURSOR_HOOK, 'utf-8'); + const start = source.indexOf('function main()'); + expect(start).toBeGreaterThanOrEqual(0); + const body = source.slice(start); + const extractIdx = body.indexOf('extractPattern('); + const resolveIdx = body.indexOf('resolveHookRepo('); + expect(extractIdx).toBeGreaterThanOrEqual(0); + expect(resolveIdx).toBeGreaterThan(extractIdx); + }); }); // ─── Integration: PreToolUse augmentation filtering (#1492) ───────── @@ -2184,7 +2341,12 @@ describe.skipIf(SKIP_LSOF_PATH)( cwd: tmpDir, }, undefined, - { env: hookEnv(binDir) }, + { + // Owner classification is the subject here. Timeout-wrapper + // containment has dedicated coverage and can race these fake + // macOS process-table fixtures. + env: { ...hookEnv(binDir), GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled' }, + }, ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); @@ -2669,7 +2831,12 @@ describe.skipIf(SKIP_LSOF_PATH)( cwd: tmpDir, }, undefined, - { env: hookEnv(binDir) }, + { + // Owner classification is the subject here. Timeout-wrapper + // containment has dedicated coverage and can race these fake + // macOS process-table fixtures. + env: { ...hookEnv(binDir), GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled' }, + }, ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); @@ -3041,7 +3208,7 @@ describe('PostToolUse staleness detection with gitnexus.json (integration)', () } }); - it(`${label}: falls back to meta.json when gitnexus.json is corrupt`, () => { + it(`${label}: treats a corrupt gitnexus.json as stale instead of trusting meta.json`, () => { const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json'); const metaJsonPath = path.join(gitNexusDir, 'meta.json'); const head = getHeadCommit(); @@ -3057,8 +3224,9 @@ describe('PostToolUse staleness detection with gitnexus.json (integration)', () cwd: tmpDir, }); - // meta.json's lastCommit matches HEAD, so a correct fallback stays silent. - expect(result.stdout.trim()).toBe(''); + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('last indexed: never'); } finally { fs.rmSync(gitnexusJsonPath, { force: true }); fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} })); @@ -3112,13 +3280,18 @@ describe('Global registry lookup', () => { fs.mkdirSync(repoDir, { recursive: true }); initRepoWithCommit(repoDir); - const result = runHook(hookPath, { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git commit -m "test"' }, - tool_output: { exit_code: 0 }, - cwd: repoDir, - }); + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: repoDir, + }, + repoDir, + { registryHome: homeDir }, + ); expect(result.stdout.trim()).toBe(''); } finally { @@ -3134,12 +3307,17 @@ describe('Global registry lookup', () => { fs.mkdirSync(repoDir, { recursive: true }); initRepoWithCommit(repoDir); - const result = runHook(hookPath, { - hook_event_name: 'PreToolUse', - tool_name: 'Grep', - tool_input: { pattern: 'validateUser' }, - cwd: repoDir, - }); + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: repoDir, + }, + repoDir, + { registryHome: homeDir }, + ); expect(result.stdout.trim()).toBe(''); } finally { @@ -3151,21 +3329,30 @@ describe('Global registry lookup', () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-')); const repoDir = path.join(homeDir, 'work', 'indexed-repo'); try { - createGlobalRegistry(homeDir); - fs.mkdirSync(path.join(repoDir, '.gitnexus'), { recursive: true }); + const storagePath = path.join(homeDir, 'indexes', 'indexed-repo'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.mkdirSync(storagePath, { recursive: true }); initRepoWithCommit(repoDir); fs.writeFileSync( - path.join(repoDir, '.gitnexus', 'meta.json'), - JSON.stringify({ lastCommit: 'oldcommit', stats: {} }), + path.join(storagePath, 'meta.json'), + JSON.stringify({ repoPath: repoDir, storagePath, lastCommit: 'oldcommit', stats: {} }), ); + writeHookRegistry(homeDir, [{ name: 'indexed-repo', path: repoDir, storagePath }]); - const result = runHook(hookPath, { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git commit -m "test"' }, - tool_output: { exit_code: 0 }, - cwd: repoDir, - }); + expect(fs.existsSync(path.join(repoDir, '.gitnexus'))).toBe(false); + + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: repoDir, + }, + repoDir, + { registryHome: homeDir }, + ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); @@ -3175,6 +3362,83 @@ describe('Global registry lookup', () => { } }); + it(`${label}: does not touch foreign external storage from a registry entry`, () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-')); + const repoDir = path.join(homeDir, 'work', 'indexed-repo'); + const storagePath = path.join(homeDir, 'indexes', 'foreign-index'); + try { + fs.mkdirSync(repoDir, { recursive: true }); + fs.mkdirSync(storagePath, { recursive: true }); + initRepoWithCommit(repoDir); + fs.writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ + repoPath: path.join(homeDir, 'work', 'other-repo'), + storagePath, + lastCommit: 'oldcommit', + stats: {}, + }), + ); + writeHookRegistry(homeDir, [{ name: 'indexed-repo', path: repoDir, storagePath }]); + + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: repoDir, + }, + repoDir, + { registryHome: homeDir }, + ); + + expect(result.stdout.trim()).toBe(''); + expect(fs.existsSync(path.join(storagePath, '.hook-locks'))).toBe(false); + expect(fs.existsSync(path.join(storagePath, '.mcp-hint-shown'))).toBe(false); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it(`${label}: does not touch foreign repository-local storage from a registry entry`, () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-')); + const repoDir = path.join(homeDir, 'work', 'indexed-repo'); + const storagePath = path.join(repoDir, '.gitnexus'); + try { + fs.mkdirSync(storagePath, { recursive: true }); + initRepoWithCommit(repoDir); + fs.writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ + repoPath: path.join(homeDir, 'work', 'other-repo'), + storagePath, + lastCommit: 'oldcommit', + stats: {}, + }), + ); + writeHookRegistry(homeDir, [{ name: 'indexed-repo', path: repoDir, storagePath }]); + + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: repoDir, + }, + repoDir, + { registryHome: homeDir }, + ); + + expect(result.stdout.trim()).toBe(''); + expect(fs.existsSync(path.join(storagePath, '.hook-locks'))).toBe(false); + expect(fs.existsSync(path.join(storagePath, '.mcp-hint-shown'))).toBe(false); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + for (const marker of ['registry', 'repos'] as const) { it(`${label}: PostToolUse skips global registry with only ${marker} marker`, () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-')); @@ -3184,13 +3448,18 @@ describe('Global registry lookup', () => { fs.mkdirSync(repoDir, { recursive: true }); initRepoWithCommit(repoDir); - const result = runHook(hookPath, { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git commit -m "test"' }, - tool_output: { exit_code: 0 }, - cwd: repoDir, - }); + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: repoDir, + }, + repoDir, + { registryHome: homeDir }, + ); expect(result.stdout.trim()).toBe(''); } finally { @@ -3201,6 +3470,474 @@ describe('Global registry lookup', () => { } }); +describe('Hook registry resolver compatibility', () => { + const canonicalPath = (value: string) => fs.realpathSync.native(path.resolve(value)); + + const withRegistryHome = (homeDir: string, action: () => void) => { + const previous = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = homeDir; + try { + action(); + } finally { + if (previous === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = previous; + } + }; + + it('resolves a registered non-Git directory from a nested working directory', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'non-git-repo'); + const nestedDir = path.join(repoDir, 'src', 'nested'); + const storagePath = path.join(homeDir, 'indexes', 'non-git-repo'); + try { + fs.mkdirSync(nestedDir, { recursive: true }); + fs.mkdirSync(storagePath, { recursive: true }); + fs.writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ repoPath: repoDir, storagePath, lastCommit: 'oldcommit', stats: {} }), + ); + writeHookRegistry(homeDir, [{ name: 'non-git-repo', path: repoDir, storagePath }]); + + withRegistryHome(homeDir, () => { + expect(findRegisteredRepoForTest(nestedDir)).toMatchObject({ + path: repoDir, + storagePath, + lbugPath: path.join(storagePath, 'lbug'), + }); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it('derives the repository-local slot for a legacy registry row without storagePath', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'legacy-repo'); + const storagePath = path.join(repoDir, '.gitnexus'); + try { + fs.mkdirSync(storagePath, { recursive: true }); + initRepoWithCommit(repoDir); + fs.writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ repoPath: repoDir, lastCommit: 'oldcommit', stats: {} }), + ); + writeHookRegistry(homeDir, [{ name: 'legacy-repo', path: repoDir }]); + + withRegistryHome(homeDir, () => { + expect(findRegisteredRepoForTest(repoDir)).toMatchObject({ + path: repoDir, + storagePath, + lbugPath: path.join(storagePath, 'lbug'), + }); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it('uses the pinned branch database for the checked-out indexed branch', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'branch-repo'); + const storagePath = path.join(homeDir, 'indexes', 'branch-repo'); + const branch = 'feature/x'; + const branchSlug = `feature_x-${createHash('sha256').update(branch).digest('hex').slice(0, 8)}`; + try { + fs.mkdirSync(repoDir, { recursive: true }); + fs.mkdirSync(storagePath, { recursive: true }); + initRepoWithCommit(repoDir); + runGit(repoDir, ['checkout', '-b', branch]); + fs.writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ repoPath: repoDir, storagePath, lastCommit: 'oldcommit', stats: {} }), + ); + writeHookRegistry(homeDir, [ + { + name: 'branch-repo', + path: repoDir, + storagePath, + branches: [{ branch }], + }, + ]); + + withRegistryHome(homeDir, () => { + expect(findRegisteredRepoForTest(repoDir)).toMatchObject({ + lbugPath: path.join(storagePath, 'branches', branchSlug, 'lbug'), + }); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it('reads metadata from the pinned branch slot, not the flat index', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'branch-meta-repo'); + const storagePath = path.join(homeDir, 'indexes', 'branch-meta-repo'); + const branch = 'feature/y'; + const branchSlug = `feature_y-${createHash('sha256').update(branch).digest('hex').slice(0, 8)}`; + const branchDir = path.join(storagePath, 'branches', branchSlug); + try { + fs.mkdirSync(repoDir, { recursive: true }); + fs.mkdirSync(branchDir, { recursive: true }); + initRepoWithCommit(repoDir); + runGit(repoDir, ['checkout', '-b', branch]); + fs.writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ + repoPath: repoDir, + storagePath, + lastCommit: 'flat-commit', + stats: {}, + }), + ); + fs.writeFileSync( + path.join(branchDir, 'gitnexus.json'), + JSON.stringify({ + repoPath: repoDir, + storagePath, + lastCommit: 'branch-commit', + stats: {}, + }), + ); + writeHookRegistry(homeDir, [ + { + name: 'branch-meta-repo', + path: repoDir, + storagePath, + branches: [{ branch }], + }, + ]); + + withRegistryHome(homeDir, () => { + expect(findRegisteredRepoForTest(repoDir)).toMatchObject({ + lbugPath: path.join(branchDir, 'lbug'), + metadata: { lastCommit: 'branch-commit' }, + }); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + 'maps a Windows-reserved branch name through the CLI slug rules', + () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'reserved-branch-repo'); + const storagePath = path.join(homeDir, 'indexes', 'reserved-branch-repo'); + const branch = 'CON'; + const branchSlug = `unknown-${createHash('sha256').update(branch).digest('hex').slice(0, 8)}`; + try { + fs.mkdirSync(repoDir, { recursive: true }); + fs.mkdirSync(storagePath, { recursive: true }); + initRepoWithCommit(repoDir); + runGit(repoDir, ['checkout', '-b', branch]); + fs.writeFileSync( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ repoPath: repoDir, storagePath, lastCommit: 'oldcommit', stats: {} }), + ); + writeHookRegistry(homeDir, [ + { + name: 'reserved-branch-repo', + path: repoDir, + storagePath, + branches: [{ branch }], + }, + ]); + + withRegistryHome(homeDir, () => { + expect(findRegisteredRepoForTest(repoDir)).toMatchObject({ + lbugPath: path.join(storagePath, 'branches', branchSlug, 'lbug'), + }); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }, + ); + + it('selects the longest matching registered path for a nested checkout', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const parentDir = path.join(homeDir, 'parent-repo'); + const nestedDir = path.join(parentDir, 'nested-repo'); + const parentStorage = path.join(homeDir, 'indexes', 'parent-repo'); + const nestedStorage = path.join(homeDir, 'indexes', 'nested-repo'); + try { + fs.mkdirSync(nestedDir, { recursive: true }); + fs.mkdirSync(parentStorage, { recursive: true }); + fs.mkdirSync(nestedStorage, { recursive: true }); + fs.writeFileSync( + path.join(parentStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: parentDir, + storagePath: parentStorage, + lastCommit: 'parent', + stats: {}, + }), + ); + fs.writeFileSync( + path.join(nestedStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: nestedDir, + storagePath: nestedStorage, + lastCommit: 'nested', + stats: {}, + }), + ); + writeHookRegistry(homeDir, [ + { name: 'parent-repo', path: parentDir, storagePath: parentStorage }, + { name: 'nested-repo', path: nestedDir, storagePath: nestedStorage }, + ]); + + withRegistryHome(homeDir, () => { + expect(findRegisteredRepoForTest(nestedDir)).toMatchObject({ + path: nestedDir, + storagePath: nestedStorage, + }); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it('selects a skip-git subdirectory index over the parent git worktree', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'git-repo'); + const skipGitDir = path.join(repoDir, 'packages', 'isolated'); + const cwdDir = path.join(skipGitDir, 'src'); + const parentStorage = path.join(homeDir, 'indexes', 'git-repo'); + const skipGitStorage = path.join(homeDir, 'indexes', 'isolated'); + try { + fs.mkdirSync(cwdDir, { recursive: true }); + fs.mkdirSync(parentStorage, { recursive: true }); + fs.mkdirSync(skipGitStorage, { recursive: true }); + initRepoWithCommit(repoDir); + fs.writeFileSync( + path.join(parentStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: repoDir, + storagePath: parentStorage, + lastCommit: 'parent', + stats: {}, + }), + ); + fs.writeFileSync( + path.join(skipGitStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: skipGitDir, + storagePath: skipGitStorage, + lastCommit: 'skip-git', + stats: {}, + }), + ); + writeHookRegistry(homeDir, [ + { name: 'git-repo', path: repoDir, storagePath: parentStorage }, + { name: 'isolated', path: skipGitDir, storagePath: skipGitStorage }, + ]); + + withRegistryHome(homeDir, () => { + expect(findRegisteredRepoForTest(cwdDir)).toMatchObject({ + path: skipGitDir, + storagePath: skipGitStorage, + }); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it('uses GITNEXUS_STORAGE_PATH when it is a non-empty absolute path', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'repo'); + const registeredStorage = path.join(homeDir, 'indexes', 'registered'); + const overrideStorage = path.join(homeDir, 'indexes', 'override'); + const previousPath = process.env.GITNEXUS_STORAGE_PATH; + const previousRoot = process.env.GITNEXUS_STORAGE_ROOT; + try { + fs.mkdirSync(repoDir, { recursive: true }); + fs.mkdirSync(registeredStorage, { recursive: true }); + fs.mkdirSync(overrideStorage, { recursive: true }); + initRepoWithCommit(repoDir); + fs.writeFileSync( + path.join(registeredStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: repoDir, + storagePath: registeredStorage, + lastCommit: 'registered', + stats: {}, + }), + ); + fs.writeFileSync( + path.join(overrideStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: repoDir, + storagePath: overrideStorage, + lastCommit: 'override', + stats: {}, + }), + ); + writeHookRegistry(homeDir, [{ name: 'repo', path: repoDir, storagePath: registeredStorage }]); + delete process.env.GITNEXUS_STORAGE_ROOT; + process.env.GITNEXUS_STORAGE_PATH = overrideStorage; + + withRegistryHome(homeDir, () => { + expect(findRegisteredRepoForTest(repoDir)).toMatchObject({ + path: repoDir, + storagePath: path.resolve(overrideStorage), + }); + }); + } finally { + if (previousPath === undefined) delete process.env.GITNEXUS_STORAGE_PATH; + else process.env.GITNEXUS_STORAGE_PATH = previousPath; + if (previousRoot === undefined) delete process.env.GITNEXUS_STORAGE_ROOT; + else process.env.GITNEXUS_STORAGE_ROOT = previousRoot; + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it('prefers a registered external slot over leftover local .gitnexus', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'repo'); + const localStorage = path.join(repoDir, '.gitnexus'); + const registeredStorage = path.join(homeDir, 'indexes', 'registered'); + try { + fs.mkdirSync(localStorage, { recursive: true }); + fs.mkdirSync(registeredStorage, { recursive: true }); + initRepoWithCommit(repoDir); + fs.writeFileSync( + path.join(localStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: repoDir, + storagePath: localStorage, + lastCommit: 'leftover', + stats: {}, + }), + ); + fs.writeFileSync( + path.join(registeredStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: repoDir, + storagePath: registeredStorage, + lastCommit: 'registered', + stats: {}, + }), + ); + writeHookRegistry(homeDir, [{ name: 'repo', path: repoDir, storagePath: registeredStorage }]); + + withRegistryHome(homeDir, () => { + const query = loadRegistryQuery(); + expect(query.findLocalOwnedRepo(repoDir)).toMatchObject({ + path: canonicalPath(repoDir), + storagePath: canonicalPath(localStorage), + }); + // Registry rows keep the written path; do not realpath them the way + // findLocalOwnedRepo does after walking the filesystem. + expect(query.resolveHookRepo(repoDir)).toMatchObject({ + path: repoDir, + storagePath: registeredStorage, + }); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it('points findLocalOwnedRepo at the current-branch slot when that index exists', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'repo'); + const localStorage = path.join(repoDir, '.gitnexus'); + const branch = 'feature/slot'; + const slug = `${branch.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_')}-${createHash('sha256').update(branch).digest('hex').slice(0, 8)}`; + const branchDir = path.join(localStorage, 'branches', slug); + try { + fs.mkdirSync(localStorage, { recursive: true }); + fs.mkdirSync(branchDir, { recursive: true }); + initRepoWithCommit(repoDir); + spawnSync('git', ['checkout', '-q', '-b', branch], { + cwd: repoDir, + stdio: 'pipe', + windowsHide: true, + }); + fs.writeFileSync( + path.join(localStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: repoDir, + storagePath: localStorage, + lastCommit: 'flat', + stats: {}, + }), + ); + fs.writeFileSync( + path.join(branchDir, 'gitnexus.json'), + JSON.stringify({ + repoPath: repoDir, + storagePath: localStorage, + lastCommit: 'branch', + stats: {}, + }), + ); + + withRegistryHome(homeDir, () => { + expect(loadRegistryQuery().findLocalOwnedRepo(repoDir)).toMatchObject({ + path: canonicalPath(repoDir), + storagePath: canonicalPath(localStorage), + lbugPath: path.join(canonicalPath(branchDir), 'lbug'), + metadata: expect.objectContaining({ lastCommit: 'branch' }), + }); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it('does not adopt a parent checkout .gitnexus from a nested git worktree', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const outerDir = path.join(homeDir, 'outer'); + const nestedDir = path.join(outerDir, 'nested'); + const outerStorage = path.join(outerDir, '.gitnexus'); + try { + fs.mkdirSync(outerStorage, { recursive: true }); + fs.mkdirSync(nestedDir, { recursive: true }); + initRepoWithCommit(outerDir); + initRepoWithCommit(nestedDir); + fs.writeFileSync( + path.join(outerStorage, 'gitnexus.json'), + JSON.stringify({ + repoPath: outerDir, + storagePath: outerStorage, + lastCommit: 'outer', + stats: {}, + }), + ); + + withRegistryHome(homeDir, () => { + expect(loadRegistryQuery().findLocalOwnedRepo(nestedDir)).toBeNull(); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + it('skips a registry row whose path contains a NUL', () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-home-')); + const repoDir = path.join(homeDir, 'repo'); + try { + fs.mkdirSync(repoDir, { recursive: true }); + initRepoWithCommit(repoDir); + writeHookRegistry(homeDir, [ + { name: 'poison', path: `${repoDir}\0evil`, storagePath: path.join(homeDir, 'idx') }, + ]); + + withRegistryHome(homeDir, () => { + expect(() => findRegisteredRepoForTest(repoDir)).not.toThrow(); + expect(findRegisteredRepoForTest(repoDir)).toBeNull(); + }); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); +}); + // ─── Integration: linked-worktree resolution (#1224) ─────────────── describe('Linked git worktree resolution', () => { @@ -3216,6 +3953,7 @@ describe('Linked git worktree resolution', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-')); const mainRepo = path.join(root, 'main-repo'); const worktreePath = path.join(root, 'main-repo-worktrees', 'feat'); + const registryHome = path.join(root, 'registry-home'); try { fs.mkdirSync(mainRepo, { recursive: true }); initRepoWithCommit(mainRepo); @@ -3228,18 +3966,26 @@ describe('Linked git worktree resolution', () => { // Create the linked worktree on a new branch. fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]); + writeHookRegistry(registryHome, [ + { name: 'main-repo', path: mainRepo, storagePath: path.join(mainRepo, '.gitnexus') }, + ]); // Sanity: walking up from the worktree never reaches `.gitnexus`. expect(fs.existsSync(path.join(worktreePath, '.gitnexus'))).toBe(false); expect(fs.existsSync(path.join(path.dirname(worktreePath), '.gitnexus'))).toBe(false); - const result = runHook(hookPath, { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git commit -m "test"' }, - tool_output: { exit_code: 0 }, - cwd: worktreePath, - }); + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: worktreePath, + }, + worktreePath, + { registryHome }, + ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); @@ -3253,6 +3999,7 @@ describe('Linked git worktree resolution', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-')); const mainRepo = path.join(root, 'main-repo'); const worktreePath = path.join(root, 'main-repo-worktrees', 'feat'); + const registryHome = path.join(root, 'registry-home'); try { fs.mkdirSync(mainRepo, { recursive: true }); initRepoWithCommit(mainRepo); @@ -3260,14 +4007,20 @@ describe('Linked git worktree resolution', () => { fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]); + writeHookRegistry(registryHome, []); - const result = runHook(hookPath, { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git commit -m "test"' }, - tool_output: { exit_code: 0 }, - cwd: worktreePath, - }); + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: worktreePath, + }, + worktreePath, + { registryHome }, + ); expect(result.stdout.trim()).toBe(''); } finally { @@ -3392,36 +4145,58 @@ describe('PostToolUse with missing/corrupt meta.json', () => { // ─── Drift guard: every shipped hook must know about gitnexus.json ── // This repo has hit the "N mirrored copies silently drift" failure mode // twice for skills (#2356/#2360/#2362) — this test is the same class of -// guardrail for the four hook copies. +// guardrail for the three adapters that read index metadata directly. describe('Hook metadata-filename drift guard', () => { - const ANTIGRAVITY_HOOK = path.resolve( - __dirname, - '..', - '..', - 'hooks', - 'antigravity', - 'gitnexus-antigravity-hook.cjs', - ); - const CURSOR_HOOK = path.resolve( - __dirname, - '..', - '..', - '..', - 'gitnexus-cursor-integration', - 'hooks', - 'gitnexus-hook.cjs', - ); - - for (const [label, hookPath] of [ - ['CJS (claude)', CJS_HOOK], - ['Plugin', PLUGIN_HOOK], - ['Antigravity', ANTIGRAVITY_HOOK], - ['Cursor', CURSOR_HOOK], + for (const [label, resolverPath] of [ + ['CJS (claude)', path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'registry-query.cjs')], + [ + 'Plugin', + path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-claude-plugin', + 'hooks', + 'registry-query.cjs', + ), + ], + // The Antigravity installer copies this canonical helper beside its adapter. + ['Antigravity', path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'registry-query.cjs')], ] as const) { - it(`${label}: source references gitnexus.json, not only meta.json`, () => { - const source = fs.readFileSync(hookPath, 'utf-8'); + it(`${label}: registry resolver references gitnexus.json, not only meta.json`, () => { + const source = fs.readFileSync(resolverPath, 'utf-8'); expect(source).toContain('gitnexus.json'); }); } }); + +describe('Hook registry resolver drift guard', () => { + const resolverPaths = [ + path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'registry-query.cjs'), + path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-claude-plugin', + 'hooks', + 'registry-query.cjs', + ), + path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'registry-query.cjs', + ), + ]; + + it('keeps all shipped registry-query helpers byte-identical', () => { + const [canonical, ...copies] = resolverPaths.map((file) => fs.readFileSync(file, 'utf-8')); + for (const copy of copies) expect(copy).toBe(canonical); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 1cc9da08a..feedc664f 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -279,6 +279,10 @@ describe('PARSE_CACHE_VERSION', () => { it('pins SCHEMA_BUMP to 99 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190)', () => { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(99); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); + // The PREVIOUS version must fail the reuse gate, not merely differ from the + // current one — a hardcoded number outside the conflict hunk rebases cleanly + // while being wrong, which is exactly how the 37/38 exact clashes landed. + // Every nearby historical or in-flight value is rejected. for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts index a3a32dd23..6fcb6a7ca 100644 --- a/gitnexus/test/unit/index-repo-command.test.ts +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -1,13 +1,37 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; +const { StorageRequirementError } = vi.hoisted(() => { + class StorageRequirementError extends Error { + constructor( + public inspection: { + repoPath: string; + storagePath: string; + state: string; + hasCodeIndexDB: boolean; + }, + public requirements: { allowedStates: readonly string[]; requireCodeIndexDB?: boolean }, + ) { + super('storage requirement failed'); + this.name = 'StorageRequirementError'; + } + } + return { StorageRequirementError }; +}); + const mockAccess = vi.fn(); const mockGetStoragePaths = vi.fn(); const mockLoadMeta = vi.fn(); +const mockSaveMeta = vi.fn(); const mockRegisterRepo = vi.fn(); const mockEnsureGitNexusIgnored = vi.fn(); const mockGetGitRoot = vi.fn(); const mockIsGitRepo = vi.fn(); +const mockRequireStoragePath = vi.fn(); +const mockGetIndexStorageRequirements = vi.fn((force: boolean) => ({ + allowedStates: force ? ['owned', 'unowned', 'foreign'] : ['owned'], + requireCodeIndexDB: true, +})); vi.mock('fs/promises', () => ({ default: { @@ -19,6 +43,7 @@ vi.mock('../../src/storage/repo-manager.js', () => ({ getStoragePaths: mockGetStoragePaths, INDEX_METADATA_FILE: 'gitnexus.json', loadMeta: mockLoadMeta, + saveMeta: mockSaveMeta, registerRepo: mockRegisterRepo, ensureGitNexusIgnored: mockEnsureGitNexusIgnored, })); @@ -33,15 +58,38 @@ vi.mock('../../src/storage/git.js', () => ({ getRemoteUrl: vi.fn().mockReturnValue(undefined), })); +vi.mock('../../src/storage/storage-resolver.js', () => ({ + getIndexStorageRequirements: mockGetIndexStorageRequirements, + requireStoragePath: mockRequireStoragePath, + StorageRequirementError, +})); + describe('indexCommand', () => { const resolvedRepo = path.resolve('/repo'); const resolvedOutside = path.resolve('/outside/path'); + const indexRequirements = { allowedStates: ['owned'] as const, requireCodeIndexDB: true }; + const storageFailure = ( + state: 'empty' | 'owned' | 'unowned', + hasCodeIndexDB: boolean, + ): StorageRequirementError => + new StorageRequirementError( + { + repoPath: resolvedRepo, + storagePath: `${resolvedRepo}/.gitnexus`, + state, + hasCodeIndexDB, + }, + indexRequirements, + ); beforeEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); process.exitCode = undefined; + mockRequireStoragePath.mockReset(); + mockRequireStoragePath.mockResolvedValue(`${resolvedRepo}/.gitnexus`); + mockGetStoragePaths.mockImplementation((repoPath: string) => ({ storagePath: `${repoPath}/.gitnexus`, lbugPath: `${repoPath}/.gitnexus/lbug`, @@ -53,6 +101,7 @@ describe('indexCommand', () => { indexedAt: '2026-03-20T00:00:00.000Z', stats: { nodes: 10, edges: 20 }, }); + mockSaveMeta.mockResolvedValue(undefined); mockAccess.mockResolvedValue(undefined); mockEnsureGitNexusIgnored.mockResolvedValue(undefined); mockGetGitRoot.mockReturnValue(resolvedRepo); @@ -73,10 +122,7 @@ describe('indexCommand', () => { it('fails when no metadata or LadybugDB index exists', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mockAccess.mockImplementation(async (targetPath: string) => { - if (targetPath.includes('/.gitnexus/')) throw new Error(`missing ${targetPath}`); - return undefined; - }); + mockRequireStoragePath.mockRejectedValueOnce(storageFailure('empty', false)); const { indexCommand } = await import('../../src/cli/index-repo.js'); await indexCommand(['/repo']); @@ -90,10 +136,7 @@ describe('indexCommand', () => { it('fails when lbug database does not exist', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mockAccess.mockImplementation(async (targetPath: string) => { - if (targetPath === `${resolvedRepo}/.gitnexus/lbug`) throw new Error('missing lbug'); - return undefined; - }); + mockRequireStoragePath.mockRejectedValueOnce(storageFailure('owned', false)); const { indexCommand } = await import('../../src/cli/index-repo.js'); await indexCommand(['/repo']); @@ -105,6 +148,7 @@ describe('indexCommand', () => { it('fails when meta.json is missing and --force is not set', async () => { mockLoadMeta.mockResolvedValue(null); + mockRequireStoragePath.mockRejectedValueOnce(storageFailure('unowned', true)); const { indexCommand } = await import('../../src/cli/index-repo.js'); await indexCommand(['/repo']); @@ -120,12 +164,49 @@ describe('indexCommand', () => { await indexCommand(['/repo'], { force: true }); expect(mockRegisterRepo).toHaveBeenCalledTimes(1); + expect(mockSaveMeta).toHaveBeenCalledWith( + `${resolvedRepo}/.gitnexus`, + expect.objectContaining({ repoPath: resolvedRepo, lastCommit: '' }), + ); expect(mockRegisterRepo).toHaveBeenCalledWith( resolvedRepo, expect.objectContaining({ repoPath: resolvedRepo, lastCommit: '', }), + { storagePath: `${resolvedRepo}/.gitnexus` }, + ); + expect(process.exitCode).toBeUndefined(); + }); + + it('rewrites foreign local gitnexus.json ownership on --force adopt', async () => { + const foreignRepo = path.resolve('/other-repo'); + mockLoadMeta.mockResolvedValue({ + repoPath: foreignRepo, + storagePath: `${foreignRepo}/.gitnexus`, + lastCommit: 'abc123', + indexedAt: '2026-03-20T00:00:00.000Z', + stats: { nodes: 10, edges: 20 }, + }); + + const { indexCommand } = await import('../../src/cli/index-repo.js'); + await indexCommand(['/repo'], { force: true }); + + expect(mockSaveMeta).toHaveBeenCalledWith( + `${resolvedRepo}/.gitnexus`, + expect.objectContaining({ + repoPath: resolvedRepo, + storagePath: `${resolvedRepo}/.gitnexus`, + lastCommit: 'abc123', + }), + ); + expect(mockRegisterRepo).toHaveBeenCalledWith( + resolvedRepo, + expect.objectContaining({ + repoPath: resolvedRepo, + storagePath: `${resolvedRepo}/.gitnexus`, + }), + { storagePath: `${resolvedRepo}/.gitnexus` }, ); expect(process.exitCode).toBeUndefined(); }); @@ -142,23 +223,24 @@ describe('indexCommand', () => { await indexCommand(['/repo'], { force: true }); expect(mockRegisterRepo).toHaveBeenCalledTimes(1); + expect(mockSaveMeta).toHaveBeenCalledWith( + `${resolvedRepo}/.gitnexus`, + expect.objectContaining({ repoPath: resolvedRepo, lastCommit: '' }), + ); expect(mockRegisterRepo).toHaveBeenCalledWith( resolvedRepo, expect.objectContaining({ repoPath: resolvedRepo, lastCommit: '', }), + { storagePath: `${resolvedRepo}/.gitnexus` }, ); expect(process.exitCode).toBeUndefined(); }); it('fails without --force when LadybugDB exists but metadata is missing', async () => { mockLoadMeta.mockResolvedValue(null); - mockAccess.mockImplementation(async (targetPath: string) => { - if (targetPath === `${resolvedRepo}/.gitnexus/lbug`) return undefined; - if (targetPath.includes('/.gitnexus/')) throw new Error(`missing ${targetPath}`); - return undefined; - }); + mockRequireStoragePath.mockRejectedValueOnce(storageFailure('unowned', true)); const { indexCommand } = await import('../../src/cli/index-repo.js'); await indexCommand(['/repo']); @@ -175,9 +257,13 @@ describe('indexCommand', () => { expect(mockRegisterRepo).toHaveBeenCalledWith( resolvedRepo, expect.objectContaining({ repoPath: resolvedRepo }), + { storagePath: `${resolvedRepo}/.gitnexus` }, ); expect(mockEnsureGitNexusIgnored).toHaveBeenCalledTimes(1); - expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(resolvedRepo); + expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith( + resolvedRepo, + `${resolvedRepo}/.gitnexus`, + ); expect(process.exitCode).toBeUndefined(); }); @@ -211,8 +297,12 @@ describe('indexCommand', () => { expect(mockRegisterRepo).toHaveBeenCalledWith( resolvedRepo, expect.objectContaining({ repoPath: resolvedRepo }), + { storagePath: `${resolvedRepo}/.gitnexus` }, + ); + expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith( + resolvedRepo, + `${resolvedRepo}/.gitnexus`, ); - expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(resolvedRepo); expect(process.exitCode).toBeUndefined(); }); diff --git a/gitnexus/test/unit/list-status-branch.test.ts b/gitnexus/test/unit/list-status-branch.test.ts index 2f0368614..ab143d6cc 100644 --- a/gitnexus/test/unit/list-status-branch.test.ts +++ b/gitnexus/test/unit/list-status-branch.test.ts @@ -6,6 +6,9 @@ * is unchanged, and `status` reflects the checked-out branch. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; const { runnerIdentity } = vi.hoisted(() => ({ runnerIdentity: { @@ -39,18 +42,21 @@ const { runnerIdentity } = vi.hoisted(() => ({ vi.mock('../../src/storage/repo-manager.js', () => ({ listRegisteredRepos: vi.fn(), - findRepo: vi.fn(), - getStoragePaths: vi.fn((repoPath: string, branch?: string) => ({ - storagePath: `${repoPath}/.gitnexus`, + getStoragePaths: vi.fn((repoPath: string, branch?: string, resolvedStoragePath?: string) => ({ + storagePath: resolvedStoragePath ?? `${repoPath}/.gitnexus`, lbugPath: branch - ? `${repoPath}/.gitnexus/branches/${branch}/lbug` - : `${repoPath}/.gitnexus/lbug`, + ? `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/branches/${branch}/lbug` + : `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/lbug`, metaPath: branch - ? `${repoPath}/.gitnexus/branches/${branch}/meta.json` - : `${repoPath}/.gitnexus/meta.json`, + ? `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/branches/${branch}/meta.json` + : `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/meta.json`, })), loadMeta: vi.fn(), hasKuzuIndex: vi.fn().mockResolvedValue(false), + readRegistryStrict: vi.fn(), + resolveRegistryEntry: vi.fn(), + RegistryNotFoundError: class RegistryNotFoundError extends Error {}, + RegistryAmbiguousTargetError: class RegistryAmbiguousTargetError extends Error {}, })); vi.mock('../../src/core/analyzer-identity.js', () => ({ @@ -60,19 +66,36 @@ vi.mock('../../src/core/analyzer-identity.js', () => ({ ), })); +vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'), + requireRegisteredStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'), +})); + vi.mock('../../src/storage/git.js', () => ({ isGitRepo: vi.fn().mockReturnValue(true), getCurrentCommit: vi.fn().mockReturnValue('headsha0'), getCurrentBranch: vi.fn().mockReturnValue('main'), - getGitRoot: vi.fn((p: string) => p), + getGitRoot: vi.fn().mockReturnValue('/repo'), isWorkingTreeDirty: vi.fn().mockReturnValue(false), listWorkingTreeDirtyPaths: vi.fn().mockReturnValue([]), })); import { listCommand } from '../../src/cli/list.js'; import { statusCommand } from '../../src/cli/status.js'; -import { listRegisteredRepos, findRepo, loadMeta } from '../../src/storage/repo-manager.js'; +import { + listRegisteredRepos, + loadMeta, + readRegistryStrict, + resolveRegistryEntry, +} from '../../src/storage/repo-manager.js'; import { getCurrentBranch, getCurrentCommit, isWorkingTreeDirty } from '../../src/storage/git.js'; +import { + requireRegisteredStoragePath, + requireStoragePath, + STATUS_STORAGE_REQUIREMENTS, + StorageRequirementError, +} from '../../src/storage/storage-resolver.js'; let logSpy: ReturnType; const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); @@ -80,6 +103,14 @@ const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); beforeEach(() => { vi.clearAllMocks(); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + (loadMeta as any).mockResolvedValue({ + repoPath: '/repo', + lastCommit: 'headsha0', + indexedAt: '2026-06-10T12:00:00.000Z', + branch: 'main', + runnerIdentity, + scopeExtractionReceipt: 1 as const, + }); }); describe('list branch rendering (#2106)', () => { @@ -142,8 +173,108 @@ describe('status branch rendering (#2106)', () => { }, }; + it.each([false, true])('uses the strict registry reader for status --repo%s', async (json) => { + const corrupt = new Error('registry is corrupt: entry 0 is malformed'); + vi.mocked(readRegistryStrict).mockRejectedValueOnce(corrupt); + + await expect(statusCommand({ repo: 'demo', json })).rejects.toBe(corrupt); + expect(resolveRegistryEntry).not.toHaveBeenCalled(); + }); + + it('resolves the Git root before checking the default storage path', async () => { + (loadMeta as any).mockResolvedValue(baseRepo.meta); + + await statusCommand({ json: true }); + + expect(requireStoragePath).toHaveBeenCalledWith('/repo', STATUS_STORAGE_REQUIREMENTS); + }); + + it('checks the exact registry storage path for status --repo', async () => { + const entry = { path: '/repo', storagePath: '/external/repo-slot' }; + (readRegistryStrict as any).mockResolvedValue([entry]); + (resolveRegistryEntry as any).mockReturnValue(entry); + (requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath); + (loadMeta as any).mockResolvedValue({ ...baseRepo.meta, repoPath: entry.path }); + + await statusCommand({ repo: 'repo', json: true }); + + expect(requireRegisteredStoragePath).toHaveBeenCalledWith(entry, STATUS_STORAGE_REQUIREMENTS); + expect(JSON.parse(output())).toMatchObject({ storagePath: entry.storagePath }); + }); + + it.each(['symbol', 'none'] as const)( + 'status --repo reports source-unavailable when checkout exists but contentRetention is %s', + async (contentRetention) => { + const checkout = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-status-src-')); + try { + const entry = { path: checkout, storagePath: '/external/repo-slot' }; + (readRegistryStrict as any).mockResolvedValue([entry]); + (resolveRegistryEntry as any).mockReturnValue(entry); + (requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath); + (loadMeta as any).mockResolvedValue({ + ...baseRepo.meta, + repoPath: entry.path, + contentRetention, + }); + + await statusCommand({ repo: 'repo', json: true }); + + expect(JSON.parse(output())).toMatchObject({ + sourceAvailable: false, + status: 'source-unavailable', + index: { contentRetention }, + }); + } finally { + await fs.rm(checkout, { recursive: true, force: true }); + } + }, + ); + + it('status --repo reports sourceAvailable when checkout exists and retention is full', async () => { + const checkout = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-status-full-')); + try { + const entry = { path: checkout, storagePath: '/external/repo-slot' }; + (readRegistryStrict as any).mockResolvedValue([entry]); + (resolveRegistryEntry as any).mockReturnValue(entry); + (requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath); + (loadMeta as any).mockResolvedValue({ + ...baseRepo.meta, + repoPath: entry.path, + contentRetention: 'full', + }); + + await statusCommand({ repo: 'repo', json: true }); + + expect(JSON.parse(output())).toMatchObject({ + sourceAvailable: true, + status: 'registered', + }); + } finally { + await fs.rm(checkout, { recursive: true, force: true }); + } + }); + + it('rejects a foreign registered storage path instead of treating it as an index', async () => { + const entry = { path: '/repo', storagePath: '/external/repo-slot' }; + const inspection = { + repoPath: entry.path, + storagePath: entry.storagePath, + state: 'foreign' as const, + hasCodeIndexDB: true, + }; + (readRegistryStrict as any).mockResolvedValue([entry]); + (resolveRegistryEntry as any).mockReturnValue(entry); + (requireRegisteredStoragePath as any).mockRejectedValue( + new StorageRequirementError(inspection, STATUS_STORAGE_REQUIREMENTS), + ); + + await expect(statusCommand({ repo: 'repo', json: true })).rejects.toBeInstanceOf( + StorageRequirementError, + ); + }); + it('renders indexed and current typed runner receipts for exact comparison', async () => { - (findRepo as any).mockResolvedValue(baseRepo); + (loadMeta as any).mockResolvedValue(baseRepo.meta); (getCurrentBranch as any).mockReturnValue('main'); (getCurrentCommit as any).mockReturnValue('headsha0'); @@ -154,7 +285,7 @@ describe('status branch rendering (#2106)', () => { }); it('renders stable machine-readable provenance with --json', async () => { - (findRepo as any).mockResolvedValue(baseRepo); + (loadMeta as any).mockResolvedValue(baseRepo.meta); (getCurrentBranch as any).mockReturnValue('main'); (getCurrentCommit as any).mockReturnValue('headsha0'); @@ -170,7 +301,7 @@ describe('status branch rendering (#2106)', () => { }); it('reports a dirty working tree as stale in --json even when the commit matches', async () => { - (findRepo as any).mockResolvedValue(baseRepo); + (loadMeta as any).mockResolvedValue(baseRepo.meta); (getCurrentBranch as any).mockReturnValue('main'); (getCurrentCommit as any).mockReturnValue('headsha0'); (isWorkingTreeDirty as any).mockReturnValueOnce(true); @@ -184,7 +315,7 @@ describe('status branch rendering (#2106)', () => { }); it('reports a dirty working tree as stale in the human output at a matching commit', async () => { - (findRepo as any).mockResolvedValue(baseRepo); + (loadMeta as any).mockResolvedValue(baseRepo.meta); (getCurrentBranch as any).mockReturnValue('main'); (getCurrentCommit as any).mockReturnValue('headsha0'); (isWorkingTreeDirty as any).mockReturnValueOnce(true); @@ -194,20 +325,17 @@ describe('status branch rendering (#2106)', () => { }); it('never certifies dirty or checkpointed metadata and reports stable incomplete reasons', async () => { - (findRepo as any).mockResolvedValue({ - ...baseRepo, - meta: { - ...baseRepo.meta, - incrementalInProgress: { startedAt: 1, toWriteCount: 2 }, - embeddingCheckpoint: { - at: '2026-07-18T00:00:00.000Z', - nodesProcessed: 1, - totalNodes: 2, - chunksProcessed: 1, - model: 'fixture', - dimensions: 3, - provider: 'local', - }, + (loadMeta as any).mockResolvedValue({ + ...baseRepo.meta, + incrementalInProgress: { startedAt: 1, toWriteCount: 2 }, + embeddingCheckpoint: { + at: '2026-07-18T00:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'fixture', + dimensions: 3, + provider: 'local', }, }); (getCurrentBranch as any).mockReturnValue('main'); @@ -224,12 +352,9 @@ describe('status branch rendering (#2106)', () => { }); it('treats an older runner receipt schema as stale at the same commit', async () => { - (findRepo as any).mockResolvedValue({ - ...baseRepo, - meta: { - ...baseRepo.meta, - runnerIdentity: { ...runnerIdentity, schemaVersion: 1 }, - }, + (loadMeta as any).mockResolvedValue({ + ...baseRepo.meta, + runnerIdentity: { ...runnerIdentity, schemaVersion: 1 }, }); (getCurrentBranch as any).mockReturnValue('main'); (getCurrentCommit as any).mockReturnValue('headsha0'); @@ -242,7 +367,7 @@ describe('status branch rendering (#2106)', () => { }); it('shows the current branch and up-to-date on the primary', async () => { - (findRepo as any).mockResolvedValue(baseRepo); + (loadMeta as any).mockResolvedValue(baseRepo.meta); (getCurrentBranch as any).mockReturnValue('main'); (getCurrentCommit as any).mockReturnValue('headsha0'); @@ -253,10 +378,9 @@ describe('status branch rendering (#2106)', () => { }); it('falls through to the workspace index when the branch has no pinned index (#2354)', async () => { - (findRepo as any).mockResolvedValue(baseRepo); + (loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce(null); // feature/y has no pinned index (getCurrentBranch as any).mockReturnValue('feature/y'); (getCurrentCommit as any).mockReturnValue('headsha9'); - (loadMeta as any).mockResolvedValue(null); // feature/y has no pinned index await statusCommand(); const out = output(); @@ -268,10 +392,9 @@ describe('status branch rendering (#2106)', () => { }); it('same-commit branch flip reports up-to-date against the workspace index (#2354)', async () => { - (findRepo as any).mockResolvedValue(baseRepo); + (loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce(null); // feature/y has no pinned index (getCurrentBranch as any).mockReturnValue('feature/y'); (getCurrentCommit as any).mockReturnValue('headsha0'); // same commit as flat meta - (loadMeta as any).mockResolvedValue(null); // feature/y has no pinned index await statusCommand(); const out = output(); @@ -280,10 +403,7 @@ describe('status branch rendering (#2106)', () => { }); it('compares against the branch index when the current branch has one', async () => { - (findRepo as any).mockResolvedValue(baseRepo); - (getCurrentBranch as any).mockReturnValue('feature/z'); - (getCurrentCommit as any).mockReturnValue('zzzzsha0'); - (loadMeta as any).mockResolvedValue({ + (loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce({ repoPath: '/repo', lastCommit: 'zzzzsha0', indexedAt: '2026-06-10T14:00:00.000Z', @@ -291,6 +411,8 @@ describe('status branch rendering (#2106)', () => { runnerIdentity, scopeExtractionReceipt: 1, }); + (getCurrentBranch as any).mockReturnValue('feature/z'); + (getCurrentCommit as any).mockReturnValue('zzzzsha0'); await statusCommand(); const out = output(); @@ -299,7 +421,7 @@ describe('status branch rendering (#2106)', () => { }); it('shows detached HEAD and compares against the flat index', async () => { - (findRepo as any).mockResolvedValue(baseRepo); + (loadMeta as any).mockResolvedValue(baseRepo.meta); (getCurrentBranch as any).mockReturnValue(null); // detached (getCurrentCommit as any).mockReturnValue('headsha0'); @@ -310,15 +432,14 @@ describe('status branch rendering (#2106)', () => { }); it('reports stale when the branch index is behind the branch tip', async () => { - (findRepo as any).mockResolvedValue(baseRepo); - (getCurrentBranch as any).mockReturnValue('feature/z'); - (getCurrentCommit as any).mockReturnValue('newsha99'); // moved past the index - (loadMeta as any).mockResolvedValue({ + (loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce({ repoPath: '/repo', lastCommit: 'oldsha00', indexedAt: '2026-06-10T14:00:00.000Z', branch: 'feature/z', }); + (getCurrentBranch as any).mockReturnValue('feature/z'); + (getCurrentCommit as any).mockReturnValue('newsha99'); // moved past the index await statusCommand(); const out = output(); diff --git a/gitnexus/test/unit/publish.test.ts b/gitnexus/test/unit/publish.test.ts index 2ba767594..fdbccbb47 100644 --- a/gitnexus/test/unit/publish.test.ts +++ b/gitnexus/test/unit/publish.test.ts @@ -118,8 +118,9 @@ describe('publishCommand (no-token no-op)', () => { beforeEach(async () => { vi.resetModules(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-test-')); - // Simulate an existing index so hasIndex() returns true. + // Simulate an existing owned index with a LadybugDB directory. await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true }); + await fs.mkdir(path.join(tempDir, '.gitnexus', 'lbug'), { recursive: true }); await fs.writeFile( path.join(tempDir, '.gitnexus', 'meta.json'), JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }), @@ -186,6 +187,7 @@ describe('publishCommand response branches (MEDIUM 5)', () => { vi.resetModules(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-resp-')); await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true }); + await fs.mkdir(path.join(tempDir, '.gitnexus', 'lbug'), { recursive: true }); await fs.writeFile( path.join(tempDir, '.gitnexus', 'meta.json'), JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }), diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index e8f5d831b..bfb2865df 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -246,6 +246,15 @@ describe('production routes — rate-limit middleware wiring', () => { expect(apiSource).toMatch(/app\.delete\('\/api\/repo',\s*createRouteLimiter\(/); }); + it('DELETE /api/repo resolves the registry row without pruning unusable storage', () => { + expect(apiSource).toMatch( + /resolveRepo\(repoName,\s*false,\s*undefined,\s*\{\s*validateStorage:\s*false\s*\}\)/, + ); + expect(apiSource).toMatch( + /listRegisteredRepos\(\{\s*validate:\s*options\.validateStorage !== false,\s*\}\)/, + ); + }); + it('GET /api/repo is wired with createRouteLimiter', () => { expect(apiSource).toMatch(/app\.get\('\/api\/repo',\s*createRouteLimiter\(/); }); diff --git a/gitnexus/test/unit/remove-command.test.ts b/gitnexus/test/unit/remove-command.test.ts index a30027c83..51f4d07bc 100644 --- a/gitnexus/test/unit/remove-command.test.ts +++ b/gitnexus/test/unit/remove-command.test.ts @@ -13,7 +13,7 @@ import path from 'node:path'; const mockRm = vi.fn(); const mockReadRegistry = vi.fn(); const mockResolveRegistryEntry = vi.fn(); -const mockAssertSafeStoragePath = vi.fn(); +const mockRequireDeletableStoragePath = vi.fn(); const mockUnregisterRepo = vi.fn(); vi.mock('fs/promises', () => ({ @@ -25,11 +25,14 @@ vi.mock('fs/promises', () => ({ vi.mock('../../src/storage/repo-manager.js', () => ({ readRegistry: mockReadRegistry, resolveRegistryEntry: mockResolveRegistryEntry, - assertSafeStoragePath: mockAssertSafeStoragePath, unregisterRepo: mockUnregisterRepo, RegistryNotFoundError: class RegistryNotFoundError extends Error {}, RegistryAmbiguousTargetError: class RegistryAmbiguousTargetError extends Error {}, - UnsafeStoragePathError: class UnsafeStoragePathError extends Error {}, +})); + +vi.mock('../../src/storage/storage-resolver.js', () => ({ + requireDeletableStoragePath: mockRequireDeletableStoragePath, + StorageDeletionError: class StorageDeletionError extends Error {}, })); describe('removeCommand', () => { @@ -47,7 +50,7 @@ describe('removeCommand', () => { mockReadRegistry.mockResolvedValue([entry]); mockResolveRegistryEntry.mockReturnValue(entry); - mockAssertSafeStoragePath.mockReturnValue(undefined); + mockRequireDeletableStoragePath.mockResolvedValue(entry.storagePath); mockRm.mockResolvedValue(undefined); mockUnregisterRepo.mockResolvedValue(undefined); }); diff --git a/gitnexus/test/unit/repo-manager-finalize-invariant.test.ts b/gitnexus/test/unit/repo-manager-finalize-invariant.test.ts index 9bdbf4813..95af85887 100644 --- a/gitnexus/test/unit/repo-manager-finalize-invariant.test.ts +++ b/gitnexus/test/unit/repo-manager-finalize-invariant.test.ts @@ -26,11 +26,18 @@ import { type RepoMeta, } from '../../src/storage/repo-manager.js'; import { createTempDir } from '../helpers/test-db.js'; +import { + STORAGE_PATH_ENV, + STORAGE_ROOT_ENV, + storagePathFromRoot, +} from '../../src/storage/storage-resolver.js'; describe('assertAnalysisFinalized (#1169)', () => { let tmpHome: Awaited>; let tmpRepo: Awaited>; let savedGitnexusHome: string | undefined; + let savedStoragePath: string | undefined; + let savedStorageRoot: string | undefined; const meta: RepoMeta = { repoPath: '', @@ -43,12 +50,20 @@ describe('assertAnalysisFinalized (#1169)', () => { tmpHome = await createTempDir('gn-1169-home-'); tmpRepo = await createTempDir('gn-1169-repo-'); savedGitnexusHome = process.env.GITNEXUS_HOME; + savedStoragePath = process.env[STORAGE_PATH_ENV]; + savedStorageRoot = process.env[STORAGE_ROOT_ENV]; process.env.GITNEXUS_HOME = tmpHome.dbPath; + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; }); afterEach(async () => { if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedGitnexusHome; + if (savedStoragePath === undefined) delete process.env[STORAGE_PATH_ENV]; + else process.env[STORAGE_PATH_ENV] = savedStoragePath; + if (savedStorageRoot === undefined) delete process.env[STORAGE_ROOT_ENV]; + else process.env[STORAGE_ROOT_ENV] = savedStorageRoot; await tmpHome.cleanup(); await tmpRepo.cleanup(); }); @@ -84,6 +99,19 @@ describe('assertAnalysisFinalized (#1169)', () => { } }); + it('rejects a corrupt registry as unreadable, not as a missing registry-entry', async () => { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, meta); + await fs.writeFile(path.join(tmpHome.dbPath, 'registry.json'), '{"truncated":'); + + await expect(assertAnalysisFinalized(tmpRepo.dbPath)).rejects.toThrow( + /corrupt|not valid JSON/i, + ); + await expect(assertAnalysisFinalized(tmpRepo.dbPath)).rejects.not.toBeInstanceOf( + AnalysisNotFinalizedError, + ); + }); + it('throws missing="registry-entry" when meta.json exists but the registry was not updated', async () => { // Half-finalized state — meta.json was written but registerRepo // failed or was skipped. Surface this as a hard failure so the @@ -117,6 +145,23 @@ describe('assertAnalysisFinalized (#1169)', () => { await expect(assertAnalysisFinalized(tmpRepo.dbPath)).resolves.toBeUndefined(); }); + it('checks the analysis-selected external slot even when the environment changes afterward', async () => { + const storageRoot = path.join(tmpHome.dbPath, 'external-indexes'); + const storagePath = storagePathFromRoot(storageRoot, tmpRepo.dbPath); + const externalMeta = { ...meta, repoPath: tmpRepo.dbPath, storagePath }; + await saveMeta(storagePath, externalMeta); + await registerRepo(tmpRepo.dbPath, externalMeta, { storagePath }); + + // Simulate a parent process changing configuration after the worker has + // completed the analysis but before its finalization assertion runs. + process.env[STORAGE_ROOT_ENV] = path.join(tmpHome.dbPath, 'different-indexes'); + + await expect(assertAnalysisFinalized(tmpRepo.dbPath, storagePath)).resolves.toBeUndefined(); + await expect(assertAnalysisFinalized(tmpRepo.dbPath)).rejects.toBeInstanceOf( + AnalysisNotFinalizedError, + ); + }); + it('matches registry entries case-insensitively on Windows so 8.3 short-name paths still finalize', async () => { // The registry comparison applies canonicalizePath + Windows // case-insensitivity. If the analyze caller passes the path in a diff --git a/gitnexus/test/unit/repo-manager-reconcile.test.ts b/gitnexus/test/unit/repo-manager-reconcile.test.ts index 4a56298bc..c0b647ea7 100644 --- a/gitnexus/test/unit/repo-manager-reconcile.test.ts +++ b/gitnexus/test/unit/repo-manager-reconcile.test.ts @@ -3,8 +3,8 @@ * * The gitnexus.json / meta.json dual-file contract: * - saveMeta writes BOTH files (primary must succeed, mirror best-effort) - * - reconcileMetadataFiles converges the two on every analyze: fresher - * `indexedAt` wins, written to both, nothing ever deleted + * - reconcileMetadataFiles converges the two on every analyze: a valid + * gitnexus.json wins; legacy is used only when primary is absent * - loadMeta prefers gitnexus.json, falls back to the mirror only when the * primary is provably absent (ENOENT/ENOTDIR) * @@ -23,6 +23,11 @@ import { reconcileMetadataFiles, type RepoMeta, } from '../../src/storage/repo-manager.js'; +import { + STORAGE_PATH_ENV, + STORAGE_ROOT_ENV, + storagePathFromRoot, +} from '../../src/storage/storage-resolver.js'; import { createTempDir } from '../helpers/test-db.js'; const metaAt = (indexedAt: string, lastCommit: string, extra?: Partial): RepoMeta => ({ @@ -38,15 +43,25 @@ const readJson = async (dir: string, filename: string): Promise => describe('reconcileMetadataFiles', () => { let tmpRepo: Awaited>; let storagePath: string; + let savedStoragePath: string | undefined; + let savedStorageRoot: string | undefined; beforeEach(async () => { tmpRepo = await createTempDir('gitnexus-reconcile-suite-'); + savedStoragePath = process.env[STORAGE_PATH_ENV]; + savedStorageRoot = process.env[STORAGE_ROOT_ENV]; + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; storagePath = getStoragePaths(tmpRepo.dbPath).storagePath; await fs.mkdir(storagePath, { recursive: true }); }); afterEach(async () => { vi.restoreAllMocks(); + if (savedStoragePath === undefined) delete process.env[STORAGE_PATH_ENV]; + else process.env[STORAGE_PATH_ENV] = savedStoragePath; + if (savedStorageRoot === undefined) delete process.env[STORAGE_ROOT_ENV]; + else process.env[STORAGE_ROOT_ENV] = savedStorageRoot; await tmpRepo.cleanup(); }); @@ -87,12 +102,12 @@ describe('reconcileMetadataFiles', () => { expect(primary).toMatchObject({ incrementalInProgress: true, lastCommit: 'crashed-run' }); }); - it('mixed branch states converge in one call (legacy-only / converged / stale-primary)', async () => { + it('mixed branch states converge in one call (legacy-only / converged / primary-authoritative)', async () => { const branches = path.join(storagePath, 'branches'); const legacyOnly = path.join(branches, 'legacy-only'); const converged = path.join(branches, 'converged'); - const stalePrimary = path.join(branches, 'stale-primary'); - for (const dir of [legacyOnly, converged, stalePrimary]) { + const primaryAuthoritative = path.join(branches, 'primary-authoritative'); + for (const dir of [legacyOnly, converged, primaryAuthoritative]) { await fs.mkdir(dir, { recursive: true }); } @@ -105,12 +120,12 @@ describe('reconcileMetadataFiles', () => { await saveMeta(converged, convergedMeta); // writes both, already in sync await fs.writeFile( - path.join(stalePrimary, 'gitnexus.json'), - JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'sp-stale')), + path.join(primaryAuthoritative, 'gitnexus.json'), + JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'primary-commit')), ); await fs.writeFile( - path.join(stalePrimary, 'meta.json'), - JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'sp-fresh')), + path.join(primaryAuthoritative, 'meta.json'), + JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'legacy-newer-but-not-authoritative')), ); // Flat slot: nothing — stays empty and untouched. @@ -120,11 +135,11 @@ describe('reconcileMetadataFiles', () => { lastCommit: 'lo-commit', }); await expect(readJson(converged, 'gitnexus.json')).resolves.toEqual(convergedMeta); - await expect(readJson(stalePrimary, 'gitnexus.json')).resolves.toMatchObject({ - lastCommit: 'sp-fresh', + await expect(readJson(primaryAuthoritative, 'gitnexus.json')).resolves.toMatchObject({ + lastCommit: 'primary-commit', }); - await expect(readJson(stalePrimary, 'meta.json')).resolves.toMatchObject({ - lastCommit: 'sp-fresh', + await expect(readJson(primaryAuthoritative, 'meta.json')).resolves.toMatchObject({ + lastCommit: 'primary-commit', }); // Flat slot stayed empty (reconcile fabricates nothing). await expect(fs.access(path.join(storagePath, 'gitnexus.json'))).rejects.toThrow(); @@ -149,7 +164,7 @@ describe('reconcileMetadataFiles', () => { ); }); - it('both files corrupt: no throw, no fabricated content, a warning per corrupt file', async () => { + it('both files corrupt: no throw, no fabricated content, one primary warning', async () => { await fs.writeFile(path.join(storagePath, 'gitnexus.json'), '{ nope'); await fs.writeFile(path.join(storagePath, 'meta.json'), 'also nope {{{'); @@ -167,9 +182,7 @@ describe('reconcileMetadataFiles', () => { await expect(fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8')).resolves.toBe( 'also nope {{{', ); - expect( - cap.records().filter((r) => r.level === 40 && String(r.msg ?? '').includes('unreadable')), - ).toHaveLength(2); + expect(cap.records().filter((r) => r.level === 40)).toHaveLength(1); }); it('fresh directory (neither file) is a silent no-op', async () => { @@ -219,6 +232,46 @@ describe('reconcileMetadataFiles', () => { await expect(loadMeta(storagePath)).resolves.toEqual(legacy); }); + + it('does not overwrite a corrupt primary with otherwise valid legacy metadata', async () => { + await fs.writeFile(path.join(storagePath, 'gitnexus.json'), '{ invalid primary'); + await fs.writeFile( + path.join(storagePath, 'meta.json'), + JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'legacy-commit')), + ); + + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(false); + await expect(fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8')).resolves.toBe( + '{ invalid primary', + ); + await expect(readJson(storagePath, 'meta.json')).resolves.toMatchObject({ + lastCommit: 'legacy-commit', + }); + }); + + it("uses analyze's validated storage path instead of resolving it again", async () => { + const externalRoot = path.join(tmpRepo.dbPath, 'external-storage'); + const externalStoragePath = storagePathFromRoot(externalRoot, tmpRepo.dbPath); + const expected = metaAt('2026-06-01T00:00:00.000Z', 'validated-path'); + const redirected = metaAt('2026-06-01T00:00:00.000Z', 'redirected-path'); + const previous = process.env[STORAGE_ROOT_ENV]; + await fs.mkdir(externalStoragePath, { recursive: true }); + await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(expected)); + await fs.writeFile(path.join(externalStoragePath, 'meta.json'), JSON.stringify(redirected)); + process.env[STORAGE_ROOT_ENV] = externalRoot; + + try { + await expect(reconcileMetadataFiles(tmpRepo.dbPath, storagePath)).resolves.toBe(true); + } finally { + if (previous === undefined) delete process.env[STORAGE_ROOT_ENV]; + else process.env[STORAGE_ROOT_ENV] = previous; + } + + await expect(readJson(storagePath, 'gitnexus.json')).resolves.toMatchObject({ + lastCommit: 'validated-path', + }); + await expect(fs.access(path.join(externalStoragePath, 'gitnexus.json'))).rejects.toThrow(); + }); }); // ─── analyze entry point: a pre-rename repo ends with both files ───────── @@ -282,7 +335,12 @@ describe('runFullAnalysis metadata reconciliation (mocked pipeline)', () => { await fs.mkdir(storagePath, { recursive: true }); await fs.writeFile( path.join(storagePath, 'meta.json'), - JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'pre-rename-commit')), + JSON.stringify( + metaAt('2026-01-01T00:00:00.000Z', 'pre-rename-commit', { + repoPath: tmpRepo.dbPath, + storagePath, + }), + ), ); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); diff --git a/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts b/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts index 23241517d..8aa6917c3 100644 --- a/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts +++ b/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts @@ -214,13 +214,11 @@ describe('readRegistryStrict', () => { await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt'); }); - it('accepts a row whose unused `path` is blank, and syncs the repo it names', async () => { - // The counter-case that fixes the width of the rule. `path` is not what - // identifies a repo, so tightening it too would trade this fail-open for a - // fail-shut: one blank `path` anywhere in the MACHINE-WIDE registry would - // reject the whole file and break every group sync on the machine, - // including groups whose repos all resolve. Same principle as indexedAt / - // lastCommit — require only what the resolution path depends on. + it('accepts a legacy row whose `path` is blank but reports its group member unreadable', async () => { + // Strict registry parsing remains backward compatible with this legacy + // shape. Group loading now needs the source path for storage ownership, + // so only this member degrades to unreadable instead of invalidating the + // machine-wide registry for otherwise healthy consumers. const row = { ...resolvableRow(), path: ' ' }; await fs.writeFile(registryPath, JSON.stringify([row])); @@ -230,14 +228,10 @@ describe('readRegistryStrict', () => { skipWrite: true, }); - // Resolved: not reported missing, and the snapshot carries THIS row's - // registry metadata, which only a successful name match could supply. + // The registry name still resolves, but ownership cannot be established. expect(result.missingRepos).toEqual([]); - expect(result.unreadableRepos).toEqual([]); - expect(result.repoSnapshots['app/backend']).toEqual({ - indexedAt: '2026-01-01T00:00:00.000Z', - lastCommit: 'abc123', - }); + expect(result.unreadableRepos).toEqual(['app/backend']); + expect(result.repoSnapshots['app/backend']).toBeUndefined(); }); /** diff --git a/gitnexus/test/unit/repo-manager-registry-validation-race.test.ts b/gitnexus/test/unit/repo-manager-registry-validation-race.test.ts new file mode 100644 index 000000000..eec94d89c --- /dev/null +++ b/gitnexus/test/unit/repo-manager-registry-validation-race.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; + +const storageResolverCtx = vi.hoisted(() => ({ + inspectRegisteredStorage: vi.fn(), +})); + +vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({ + ...(await importOriginal()), + inspectRegisteredStorage: storageResolverCtx.inspectRegisteredStorage, +})); + +import { + listRegisteredRepos, + readRegistry, + registerRepo, + type RepoMeta, +} from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; + +describe('listRegisteredRepos validation persistence', () => { + let tmpHome: Awaited>; + let tmpRepo: Awaited>; + let savedGitnexusHome: string | undefined; + + beforeEach(async () => { + tmpHome = await createTempDir('gitnexus-registry-atomic-home-'); + tmpRepo = await createTempDir('gitnexus-registry-atomic-repo-a-'); + savedGitnexusHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = tmpHome.dbPath; + storageResolverCtx.inspectRegisteredStorage.mockReset(); + }); + + afterEach(async () => { + if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedGitnexusHome; + await tmpHome.cleanup(); + await tmpRepo.cleanup(); + }); + + it('does not prune a concurrent re-registration into a different storage slot', async () => { + const repoPath = tmpRepo.dbPath; + const staleStoragePath = path.join(tmpHome.dbPath, 'stale-slot'); + const replacementStoragePath = path.join(tmpHome.dbPath, 'replacement-slot'); + const registryPath = path.join(tmpHome.dbPath, 'registry.json'); + const meta: RepoMeta = { + repoPath, + storagePath: replacementStoragePath, + lastCommit: 'abc1234', + indexedAt: '2026-09-05T00:00:00.000Z', + stats: { files: 1, nodes: 1 }, + }; + await fs.writeFile( + registryPath, + JSON.stringify([ + { + name: 'repo', + path: repoPath, + storagePath: staleStoragePath, + indexedAt: meta.indexedAt, + lastCommit: meta.lastCommit, + }, + ]), + ); + + let inspectionStarted!: () => void; + let releaseInspection!: () => void; + const inspectionStartedPromise = new Promise((resolve) => { + inspectionStarted = resolve; + }); + const releaseInspectionPromise = new Promise((resolve) => { + releaseInspection = resolve; + }); + storageResolverCtx.inspectRegisteredStorage.mockImplementationOnce(async (entry: any) => { + inspectionStarted(); + await releaseInspectionPromise; + return { + repoPath: entry.path, + storagePath: entry.storagePath, + state: 'missing', + hasCodeIndexDB: false, + }; + }); + + const validatingRead = listRegisteredRepos({ validate: true }); + await inspectionStartedPromise; + await registerRepo(repoPath, meta, { name: 'repo', storagePath: replacementStoragePath }); + releaseInspection(); + + await expect(validatingRead).resolves.toEqual([]); + await expect(readRegistry()).resolves.toMatchObject([ + { path: repoPath, storagePath: replacementStoragePath }, + ]); + }); +}); diff --git a/gitnexus/test/unit/repo-manager-transient-error.test.ts b/gitnexus/test/unit/repo-manager-transient-error.test.ts index 2e04df550..1f59d6e39 100644 --- a/gitnexus/test/unit/repo-manager-transient-error.test.ts +++ b/gitnexus/test/unit/repo-manager-transient-error.test.ts @@ -21,6 +21,16 @@ const mockMeta: any = { stats: { files: 1, nodes: 1 }, }; +const materializeLegacyIndex = async (repoPath: string): Promise => { + const storagePath = path.join(repoPath, '.gitnexus'); + await fs.mkdir(path.join(storagePath, 'lbug'), { recursive: true }); + await fs.writeFile( + path.join(storagePath, 'meta.json'), + JSON.stringify({ ...mockMeta, repoPath }), + 'utf8', + ); +}; + /** * Read the persisted registry straight off disk so tests can assert what was * actually written — the original bug was about *persisting* the wrong list, @@ -48,6 +58,7 @@ describe('listRegisteredRepos({ validate: true }) — transient error safety (PR tmpRepo = await createTempDir('gitnexus-transient-repo-'); savedGitnexusHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; + await materializeLegacyIndex(tmpRepo.dbPath); }); afterEach(async () => { @@ -61,17 +72,11 @@ describe('listRegisteredRepos({ validate: true }) — transient error safety (PR it('ENOENT prunes the entry (index genuinely removed)', async () => { await registerRepo(tmpRepo.dbPath, mockMeta); - // registerRepo writes the registry entry but doesn't create .gitnexus/meta.json. - // That's done by analyze. Create it so the entry passes validation initially. - const metaPath = path.join(tmpRepo.dbPath, '.gitnexus', 'meta.json'); - await fs.mkdir(path.dirname(metaPath), { recursive: true }); - await fs.writeFile(metaPath, JSON.stringify(mockMeta)); - const before = await listRegisteredRepos({ validate: true }); expect(before).toHaveLength(1); // Delete meta.json to simulate genuinely removed index - await fs.unlink(metaPath); + await fs.rm(path.join(tmpRepo.dbPath, '.gitnexus'), { recursive: true, force: true }); const after = await listRegisteredRepos({ validate: true }); expect(after).toHaveLength(0); @@ -270,21 +275,22 @@ describe('listRegisteredRepos({ validate: true }) — transient error safety (PR try { const nameA = await registerRepo(tmpRepo.dbPath, mockMeta); const nameB = await registerRepo(tmpRepoB.dbPath, mockMeta); + await materializeLegacyIndex(tmpRepoB.dbPath); const before = await listRegisteredRepos(); expect(before).toHaveLength(2); + // Repo A is genuinely gone: drop both metadata files so inspection + // cannot treat a leftover `gitnexus.json` from `registerRepo` as owned. + await fs.rm(path.join(tmpRepo.dbPath, '.gitnexus', 'gitnexus.json'), { force: true }); + await fs.rm(path.join(tmpRepo.dbPath, '.gitnexus', 'meta.json'), { force: true }); + // Branch on each repo's distinct temp-dir segment — both meta.json paths // contain `.gitnexus`/`meta.json`, so matching those shared substrings - // alone would mis-route. repo A → ENOENT (prune), repo B → EIO (keep). + // alone would mis-route. repo B → EIO (keep). const originalAccess = fs.access; vi.spyOn(fs, 'access').mockImplementation(async (p, mode) => { const pStr = typeof p === 'string' ? p : p.toString(); - if (pStr.includes('meta.json') && pStr.includes(tmpRepo.dbPath)) { - const err = new Error('no such file') as NodeJS.ErrnoException; - err.code = 'ENOENT'; - throw err; - } if (pStr.includes('meta.json') && pStr.includes(tmpRepoB.dbPath)) { const err = new Error('input/output error') as NodeJS.ErrnoException; err.code = 'EIO'; @@ -309,4 +315,36 @@ describe('listRegisteredRepos({ validate: true }) — transient error safety (PR await tmpRepoB.cleanup(); } }); + + it('returns only owned entries with a LadybugDB directory', async () => { + const foreignRepo = await createTempDir('gitnexus-validation-foreign-'); + const missingDbRepo = await createTempDir('gitnexus-validation-no-db-'); + try { + await registerRepo(tmpRepo.dbPath, mockMeta, { name: 'owned' }); + await registerRepo(foreignRepo.dbPath, mockMeta, { name: 'foreign' }); + await registerRepo(missingDbRepo.dbPath, mockMeta, { name: 'no-db' }); + + const foreignStorage = path.join(foreignRepo.dbPath, '.gitnexus'); + await fs.mkdir(path.join(foreignStorage, 'lbug'), { recursive: true }); + await fs.writeFile( + path.join(foreignStorage, 'meta.json'), + JSON.stringify({ ...mockMeta, repoPath: tmpRepo.dbPath }), + 'utf8', + ); + await fs.mkdir(path.join(missingDbRepo.dbPath, '.gitnexus'), { recursive: true }); + await fs.writeFile( + path.join(missingDbRepo.dbPath, '.gitnexus', 'meta.json'), + JSON.stringify({ ...mockMeta, repoPath: missingDbRepo.dbPath }), + 'utf8', + ); + + const entries = await listRegisteredRepos({ validate: true }); + + expect(entries.map((entry) => entry.name)).toEqual(['owned']); + expect(await readRegistryFromDisk()).toHaveLength(3); + } finally { + await foreignRepo.cleanup(); + await missingDbRepo.cleanup(); + } + }); }); diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index 34a91206f..cdf1d6fa2 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -16,6 +16,9 @@ import { resolveBranchPlacement, saveMeta, loadMeta, + hasIndex, + loadRepo, + findRepo, reconcileMetadataFiles, AnalysisNotFinalizedError, INDEX_METADATA_FILE, @@ -102,6 +105,61 @@ describe('getStoragePaths', () => { }); }); +// ─── Index ownership lookup ───────────────────────────────────────── + +describe('index ownership lookup', () => { + let workspace: Awaited>; + + beforeEach(async () => { + workspace = await createTempDir('gitnexus-index-ownership-'); + }); + + afterEach(async () => { + await workspace.cleanup(); + }); + + it('does not resolve an indexed parent directory as a child Git repository', async () => { + const childRepo = path.join(workspace.dbPath, 'child-repo'); + const childSource = path.join(childRepo, 'src'); + const parentStorage = getStoragePaths(workspace.dbPath).storagePath; + await fs.mkdir(childSource, { recursive: true }); + execSync('git init', { cwd: childRepo, stdio: 'ignore' }); + await saveMeta(parentStorage, { + repoPath: workspace.dbPath, + storagePath: parentStorage, + lastCommit: '', + indexedAt: new Date(0).toISOString(), + }); + + await expect(findRepo(childSource)).resolves.toBeNull(); + }); + + it('rejects foreign metadata, while preserving metadata-only owned slots for clean', async () => { + const repoPath = path.join(workspace.dbPath, 'repo'); + const storagePath = getStoragePaths(repoPath).storagePath; + await fs.mkdir(repoPath, { recursive: true }); + await saveMeta(storagePath, { + repoPath: path.join(workspace.dbPath, 'other-repo'), + storagePath, + lastCommit: '', + indexedAt: new Date(0).toISOString(), + }); + + await expect(loadRepo(repoPath)).resolves.toBeNull(); + await expect(hasIndex(repoPath)).resolves.toBe(false); + + await saveMeta(storagePath, { + repoPath, + storagePath, + lastCommit: '', + indexedAt: new Date(0).toISOString(), + }); + + await expect(loadRepo(repoPath)).resolves.toMatchObject({ repoPath, storagePath }); + await expect(hasIndex(repoPath)).resolves.toBe(false); + }); +}); + // ─── branchSlug (#2106) ────────────────────────────────────────────── describe('branchSlug (#2106)', () => { @@ -411,10 +469,7 @@ describe('reconcileMetadataFiles stale-shadow regression', () => { indexedAt, }); - it('a FRESHER legacy meta.json wins over a stale gitnexus.json (both rewritten)', async () => { - // The reproduced PR #2363 bug: an older binary re-analyzes and writes only - // meta.json AFTER gitnexus.json exists; the one-shot existence gate then - // ignored the fresher state forever (stale lastCommit won, dirty flag lost). + it('a valid gitnexus.json wins over a newer legacy meta.json (both rewritten)', async () => { await fs.writeFile( path.join(storagePath, 'gitnexus.json'), JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'stale-commit')), @@ -432,8 +487,8 @@ describe('reconcileMetadataFiles stale-shadow regression', () => { const legacy = JSON.parse( await fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8'), ) as RepoMeta; - expect(primary.lastCommit).toBe('fresh-commit'); - expect(legacy.lastCommit).toBe('fresh-commit'); + expect(primary.lastCommit).toBe('stale-commit'); + expect(legacy.lastCommit).toBe('stale-commit'); }); it('bootstraps gitnexus.json from a legacy-only directory (pre-rename repo)', async () => { @@ -544,6 +599,15 @@ describe('ensureGitNexusIgnored (#1233)', () => { ).resolves.toBe('*\n'); }); + it('writes the ignore file to an explicitly selected external storage slot', async () => { + const storagePath = path.join(tmpRepo.dbPath, 'central-indexes', 'repo-slot'); + + await ensureGitNexusIgnored(tmpRepo.dbPath, storagePath); + + await expect(fs.readFile(path.join(storagePath, '.gitignore'), 'utf-8')).resolves.toBe('*\n'); + await expect(fs.access(path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'))).rejects.toThrow(); + }); + it('does not create or modify the repository root .gitignore', async () => { const rootGitignorePath = path.join(tmpRepo.dbPath, '.gitignore'); await fs.writeFile(rootGitignorePath, 'node_modules/\n'); @@ -841,6 +905,26 @@ describe('registerRepo name override + collision guard (#829)', () => { expect(entries[0].name).not.toBe(path.basename(tmpRepoA.dbPath)); }); + it('uses an explicitly selected storage slot only when metadata binds to it', async () => { + const storagePath = path.join(tmpHome.dbPath, 'central', 'repo-slot'); + const boundMeta = { ...meta, repoPath: tmpRepoA.dbPath, storagePath }; + + await registerRepo(tmpRepoA.dbPath, boundMeta, { storagePath }); + expect((await listRegisteredRepos())[0].storagePath).toBe(storagePath); + + await expect( + registerRepo( + tmpRepoA.dbPath, + { ...boundMeta, storagePath: `${storagePath}-other` }, + { storagePath }, + ), + ).rejects.toThrow('metadata storagePath does not match'); + + await expect( + registerRepo(tmpRepoA.dbPath, { ...meta, repoPath: tmpRepoA.dbPath }, { storagePath }), + ).rejects.toThrow('external storage metadata must bind storagePath'); + }); + it('preserves every concurrent registration', async () => { const repoPaths = Array.from({ length: 12 }, (_, index) => path.join(tmpRepoA.dbPath, `concurrent-${index}`), @@ -1262,6 +1346,10 @@ describe('registerRepo branch nesting (#2106)', () => { // real directory to remove. const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x'); await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222')); + await saveMeta(getStoragePaths(tmpRepo.dbPath).storagePath, { + ...metaFor('main', 'aaa1111'), + repoPath: tmpRepo.dbPath, + }); await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x'); @@ -1275,6 +1363,10 @@ describe('registerRepo branch nesting (#2106)', () => { await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); await registerRepo(tmpRepo.dbPath, metaFor('feature/y', 'ccc3333'), { branch: 'feature/y' }); + await saveMeta(getStoragePaths(tmpRepo.dbPath).storagePath, { + ...metaFor('main', 'aaa1111'), + repoPath: tmpRepo.dbPath, + }); await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x'); @@ -1297,6 +1389,28 @@ describe('registerRepo branch nesting (#2106)', () => { await expect(fs.access(path.dirname(metaPath))).resolves.toBeUndefined(); // dir survives }); + it('does not delete an explicitly selected external branch slot after ownership changes', async () => { + const storagePath = path.join(tmpHome.dbPath, 'central-indexes', 'repo-slot'); + const ownedMeta = { + ...metaFor('main', 'aaa1111'), + repoPath: tmpRepo.dbPath, + storagePath, + }; + await saveMeta(storagePath, ownedMeta); + await registerRepo(tmpRepo.dbPath, ownedMeta, { storagePath }); + const shadowDir = path.dirname( + getStoragePaths(tmpRepo.dbPath, 'feature/x', storagePath).metaPath, + ); + await fs.mkdir(shadowDir, { recursive: true }); + + await saveMeta(storagePath, { ...ownedMeta, repoPath: tmpHome.dbPath }); + + await expect(adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x', storagePath)).rejects.toThrow( + 'storage is not owned', + ); + await expect(fs.access(shadowDir)).resolves.toBeUndefined(); + }); + // ─── re-read-before-write merge (#2106 R9) ────────────────────────── it('a branch run preserves the freshest top-level fields (alias survives)', async () => { @@ -1763,9 +1877,8 @@ describe('resolveRegistryEntry backward-compat with non-canonical stored paths ( // Guard rail against destroying more than the `.gitnexus/` subfolder. // `~/.gitnexus/registry.json` is user-writable plain text, so a // corrupted or hand-edited entry could put storagePath anywhere. -// These tests use synthetic `RegistryEntry` fixtures (no disk I/O) -// because the guard is a pure string check — it must not depend on -// the paths existing. +// Repository-local paths remain pure string checks. External slots require +// metadata ownership proof before they may be recursively deleted. describe('assertSafeStoragePath (#1003)', () => { const prefix = process.platform === 'win32' ? 'D:\\' : '/tmp/'; @@ -1777,61 +1890,76 @@ describe('assertSafeStoragePath (#1003)', () => { lastCommit: 'deadbee', }; - it('accepts the canonical /.gitnexus storage path', () => { + it('accepts the canonical /.gitnexus storage path', async () => { const entry: RegistryEntry = { ...base, storagePath: path.join(repoPath, '.gitnexus'), }; - expect(() => assertSafeStoragePath(entry)).not.toThrow(); + await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined(); }); - it('rejects when storagePath equals the repo path itself (would delete the code)', () => { + it('rejects when storagePath equals the repo path itself (would delete the code)', async () => { const entry: RegistryEntry = { ...base, storagePath: repoPath, // catastrophic: rm the working tree }; - expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError); + await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); - it('rejects when storagePath is a parent of the repo path', () => { + it('rejects when storagePath is a parent of the repo path', async () => { const entry: RegistryEntry = { ...base, storagePath: path.dirname(repoPath), // also catastrophic }; - expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError); + await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); - it('rejects when storagePath is empty (path.resolve falls back to cwd)', () => { + it('rejects when storagePath is empty (path.resolve falls back to cwd)', async () => { const entry: RegistryEntry = { ...base, storagePath: '', // path.resolve('') === process.cwd() — would rm cwd }; - expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError); + await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); - it('rejects when storagePath points somewhere totally unrelated', () => { + it.each([null, 42])('rejects malformed storagePath %p with the safety error', async (value) => { + const entry = { + ...base, + storagePath: value, + } as unknown as RegistryEntry; + + try { + await assertSafeStoragePath(entry); + expect.unreachable('expected malformed storagePath to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(UnsafeStoragePathError); + expect((error as UnsafeStoragePathError).actualStoragePath).toBe(String(value)); + } + }); + + it('rejects when storagePath points somewhere totally unrelated', async () => { const entry: RegistryEntry = { ...base, storagePath: `${prefix}some${path.sep}other${path.sep}place`, }; - expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError); + await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); - it('rejects when storagePath is a sibling .gitnexus (right basename, wrong parent)', () => { + it('rejects when storagePath is a sibling .gitnexus (right basename, wrong parent)', async () => { const entry: RegistryEntry = { ...base, storagePath: path.join(`${prefix}different${path.sep}repo`, '.gitnexus'), }; - expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError); + await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); }); - it('UnsafeStoragePathError carries the original entry + expected + actual paths', () => { + it('UnsafeStoragePathError carries the original entry + expected + actual paths', async () => { const entry: RegistryEntry = { ...base, storagePath: `${prefix}evil${path.sep}path`, }; try { - assertSafeStoragePath(entry); + await assertSafeStoragePath(entry); } catch (e) { expect(e).toBeInstanceOf(UnsafeStoragePathError); const err = e as UnsafeStoragePathError; @@ -1846,14 +1974,56 @@ describe('assertSafeStoragePath (#1003)', () => { } }); - it('Windows: storagePath match is case-insensitive to match register/unregister semantics', () => { + it('Windows: storagePath match is case-insensitive to match register/unregister semantics', async () => { if (process.platform !== 'win32') return; const entry: RegistryEntry = { ...base, storagePath: path.join(repoPath.toUpperCase(), '.GITNEXUS'), }; // Should accept because Windows paths are case-insensitive. - expect(() => assertSafeStoragePath(entry)).not.toThrow(); + await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined(); + }); + + it('accepts an external slot only when its metadata binds it to the registry entry', async () => { + const repo = await createTempDir('gitnexus-external-repo-'); + const storage = await createTempDir('gitnexus-external-storage-'); + const entry: RegistryEntry = { + ...base, + path: repo.dbPath, + storagePath: storage.dbPath, + }; + try { + await saveMeta(storage.dbPath, { + repoPath: repo.dbPath, + storagePath: storage.dbPath, + lastCommit: 'deadbee', + indexedAt: new Date(0).toISOString(), + }); + await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined(); + } finally { + await Promise.all([repo.cleanup(), storage.cleanup()]); + } + }); + + it('rejects an external slot whose metadata belongs to another checkout', async () => { + const repo = await createTempDir('gitnexus-external-repo-'); + const storage = await createTempDir('gitnexus-external-storage-'); + const entry: RegistryEntry = { + ...base, + path: repo.dbPath, + storagePath: storage.dbPath, + }; + try { + await saveMeta(storage.dbPath, { + repoPath: path.join(repo.dbPath, 'other'), + storagePath: storage.dbPath, + lastCommit: 'deadbee', + indexedAt: new Date(0).toISOString(), + }); + await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError); + } finally { + await Promise.all([repo.cleanup(), storage.cleanup()]); + } }); }); diff --git a/gitnexus/test/unit/repo-projection.test.ts b/gitnexus/test/unit/repo-projection.test.ts index c2968a764..edc08b454 100644 --- a/gitnexus/test/unit/repo-projection.test.ts +++ b/gitnexus/test/unit/repo-projection.test.ts @@ -22,6 +22,8 @@ import type { StalenessInfo } from '../../src/core/git-staleness.js'; import type { RegistryEntry } from '../../src/storage/repo-manager.js'; import type { RepoMeta } from '../../src/storage/repo-meta.js'; +const FULL_SOURCE = { contentRetention: 'full' as const, sourceAvailable: true }; + const FRESH: StalenessInfo = { isStale: false, commitsBehind: 0 }; const BEHIND: StalenessInfo = { isStale: true, @@ -92,6 +94,7 @@ describe('projectRepoListEntry — GET /api/repos', () => { branches: [{ branch: 'test', indexedAt: '2026-09-08T11:00:00.000Z', lastCommit: 'abc123' }], }) as RegistryEntry, FRESH, + FULL_SOURCE, ); expect(out.branch).toBe('master'); expect(out.branches).toHaveLength(1); @@ -101,10 +104,11 @@ describe('projectRepoListEntry — GET /api/repos', () => { // A pinned analyze registers under its clone-directory name. Without // `branch`, these two are only tellable apart by parsing that slug — a // layout detail that is trimmed for long refs and absent for path entries. - const primary = projectRepoListEntry(entry({ branch: 'master' }), FRESH); + const primary = projectRepoListEntry(entry({ branch: 'master' }), FRESH, FULL_SOURCE); const pinned = projectRepoListEntry( entry({ name: 'Hello-World__test-9f86d081', branch: 'test' }), FRESH, + FULL_SOURCE, ); expect([primary.branch, pinned.branch]).toEqual(['master', 'test']); }); @@ -112,48 +116,62 @@ describe('projectRepoListEntry — GET /api/repos', () => { it('keeps every field the route returned before, unchanged', () => { // Additive only: an existing client must not notice this change. const e = entry(); - const out = projectRepoListEntry(e, FRESH); + const out = projectRepoListEntry(e, FRESH, FULL_SOURCE); expect(out).toMatchObject({ name: e.name, path: e.path, repoPath: e.path, + storagePath: e.storagePath, indexedAt: e.indexedAt, lastCommit: e.lastCommit, stats: e.stats, + contentRetention: 'full', + sourceAvailable: true, }); }); it('leaves branch undefined for a legacy entry that never recorded one', () => { - const out = projectRepoListEntry(entry(), FRESH); + const out = projectRepoListEntry(entry(), FRESH, FULL_SOURCE); expect(out.branch).toBeUndefined(); expect(out.branches).toBeUndefined(); }); it('carries staleness through for a behind index', () => { - expect(projectRepoListEntry(entry(), BEHIND).staleness).toEqual({ + expect(projectRepoListEntry(entry(), BEHIND, FULL_SOURCE).staleness).toEqual({ status: 'behind', commitsBehind: 3, hint: BEHIND.hint, }); }); + + it('exposes storagePath, contentRetention, and sourceAvailable', () => { + const e = entry(); + const out = projectRepoListEntry(e, FRESH, { + contentRetention: 'none', + sourceAvailable: false, + }); + expect(out.storagePath).toBe(e.storagePath); + expect(out.contentRetention).toBe('none'); + expect(out.sourceAvailable).toBe(false); + }); }); describe('projectRepoDetail — GET /api/repo', () => { it('returns lastCommit and branch, which the route used to drop', () => { - const out = projectRepoDetail(entry({ branch: 'master' }), null, FRESH); + const out = projectRepoDetail(entry({ branch: 'master' }), null, FRESH, FULL_SOURCE); expect(out.lastCommit).toBe(entry().lastCommit); expect(out.branch).toBe('master'); }); it('prefers on-disk metadata over the registry entry, as indexedAt already did', () => { - const out = projectRepoDetail(entry({ branch: 'master' }), meta(), FRESH); + const out = projectRepoDetail(entry({ branch: 'master' }), meta(), FRESH, FULL_SOURCE); expect(out.indexedAt).toBe('2026-09-08T12:00:00.000Z'); expect(out.lastCommit).toBe('ffffffffffffffffffffffffffffffffffffffff'); expect(out.branch).toBe('develop'); }); it('falls back to the entry when metadata cannot be read', () => { - const out = projectRepoDetail(entry({ branch: 'master' }), undefined, FRESH); + const out = projectRepoDetail(entry({ branch: 'master' }), undefined, FRESH, FULL_SOURCE); expect(out.indexedAt).toBe(entry().indexedAt); expect(out.lastCommit).toBe(entry().lastCommit); expect(out.branch).toBe('master'); @@ -161,7 +179,22 @@ describe('projectRepoDetail — GET /api/repo', () => { it('still returns an empty stats object rather than undefined', () => { // Pre-existing contract: the route returned `{}` when neither side had stats. - expect(projectRepoDetail(entry({ stats: undefined }), null, FRESH).stats).toEqual({}); + expect(projectRepoDetail(entry({ stats: undefined }), null, FRESH, FULL_SOURCE).stats).toEqual( + {}, + ); + }); + + it('exposes storagePath, contentRetention, and sourceAvailable', () => { + const e = entry(); + const out = projectRepoDetail(e, meta({ contentRetention: 'symbol' }), FRESH, { + contentRetention: 'symbol', + sourceAvailable: false, + }); + expect(out).toMatchObject({ + storagePath: e.storagePath, + contentRetention: 'symbol', + sourceAvailable: false, + }); }); }); @@ -171,7 +204,7 @@ describe('resolveLastCommit', () => { // commits-behind from another — a freshness number for a different index. const e = entry(); const m = meta(); - expect(resolveLastCommit(e, m)).toBe(projectRepoDetail(e, m, FRESH).lastCommit); + expect(resolveLastCommit(e, m)).toBe(projectRepoDetail(e, m, FRESH, FULL_SOURCE).lastCommit); }); it('falls back to the registry entry with no metadata', () => { diff --git a/gitnexus/test/unit/resources.test.ts b/gitnexus/test/unit/resources.test.ts index 2c9a8dcdc..4d50a610d 100644 --- a/gitnexus/test/unit/resources.test.ts +++ b/gitnexus/test/unit/resources.test.ts @@ -517,6 +517,9 @@ describe('context resource freshness after out-of-process analyze (#2438)', () = ); expect(result).toContain('index:'); expect(result).toContain('commit: "0123456789abcdef0123456789abcdef01234567"'); + expect(result).toContain('storage_path: "/tmp/test-repo/.gitnexus"'); + expect(result).toContain('content_retention: "full"'); + expect(result).toMatch(/source_available: (true|false)/); expect(result).toContain(`runner_identity: ${JSON.stringify(runnerIdentity)}`); expect(result).toContain('runner_identity_schema_status: "current"'); }); diff --git a/gitnexus/test/unit/run-analyze-adopt-failure.test.ts b/gitnexus/test/unit/run-analyze-adopt-failure.test.ts index 60c76ec4f..7ffb6e6a2 100644 --- a/gitnexus/test/unit/run-analyze-adopt-failure.test.ts +++ b/gitnexus/test/unit/run-analyze-adopt-failure.test.ts @@ -20,20 +20,25 @@ type RepoManagerModule = typeof import('../../src/storage/repo-manager.js'); const rmCtx = vi.hoisted(() => ({ adoptMock: vi.fn(), saveMetaMock: vi.fn(), + writableMock: vi.fn(), realAdopt: null as RepoManagerModule['adoptFlatBranchLabel'] | null, realSaveMeta: null as RepoManagerModule['saveMeta'] | null, + realEnsureWritable: null as RepoManagerModule['ensureStoragePathWritable'] | null, })); vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { const actual = await importOriginal(); rmCtx.realAdopt = actual.adoptFlatBranchLabel; rmCtx.realSaveMeta = actual.saveMeta; + rmCtx.realEnsureWritable = actual.ensureStoragePathWritable; rmCtx.adoptMock.mockImplementation(actual.adoptFlatBranchLabel); rmCtx.saveMetaMock.mockImplementation(actual.saveMeta); + rmCtx.writableMock.mockImplementation(actual.ensureStoragePathWritable); return { ...actual, adoptFlatBranchLabel: rmCtx.adoptMock, saveMeta: rmCtx.saveMetaMock, + ensureStoragePathWritable: rmCtx.writableMock, }; }); @@ -61,12 +66,17 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { process.env.GITNEXUS_HOME = tmpHome.dbPath; rmCtx.adoptMock.mockReset(); rmCtx.saveMetaMock.mockReset(); + rmCtx.writableMock.mockReset(); rmCtx.adoptMock.mockImplementation( (...args: Parameters) => rmCtx.realAdopt!(...args), ); rmCtx.saveMetaMock.mockImplementation((...args: Parameters) => rmCtx.realSaveMeta!(...args), ); + rmCtx.writableMock.mockImplementation( + (...args: Parameters) => + rmCtx.realEnsureWritable!(...args), + ); }); afterEach(async () => { @@ -170,4 +180,26 @@ describe('fast-path restamp failure modes (#2364 F3)', () => { expect(meta?.branch).toBe('main'); }, ); + + it('does not require writable storage for an already-up-to-date run', async () => { + await seedFlippedWorkspace(); + rmCtx.writableMock.mockRejectedValueOnce( + Object.assign(new Error('mock read-only storage'), { code: 'EROFS' }), + ); + + const result = await runFullAnalysis(tmpRepo.dbPath, {}, {}); + + expect(result.alreadyUpToDate).toBe(true); + expect(rmCtx.writableMock).not.toHaveBeenCalled(); + }); + + it('still requires writable storage when an up-to-date checkout has content changes', async () => { + const { flatStorage } = await seedFlippedWorkspace(); + await fs.writeFile(path.join(tmpRepo.dbPath, 'dirty.ts'), 'export const dirty = true;\n'); + const readOnly = Object.assign(new Error('mock read-only storage'), { code: 'EROFS' }); + rmCtx.writableMock.mockRejectedValueOnce(readOnly); + + await expect(runFullAnalysis(tmpRepo.dbPath, {}, {})).rejects.toBe(readOnly); + expect(rmCtx.writableMock).toHaveBeenCalledWith(flatStorage); + }); }); diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index 961caa730..0327c5a07 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -880,6 +880,13 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { await fs.mkdir(storagePath, { recursive: true }); // A pre-existing "previous index" that must survive the aborted rebuild. await createPlaceholderGraphStore(lbugPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + storagePath, + lastCommit: 'previous-index', + indexedAt: new Date().toISOString(), + stats: {}, + }); const before = await fs.readFile(lbugPath); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); @@ -1667,8 +1674,10 @@ describe('runFullAnalysis Phase 5 embedding gate (#2790)', () => { expect(error).toMatchObject({ message: expect.stringMatching(/--drop-embeddings/), }); - // The index really was not registered: no finalize meta was written. - expect(meta).toBeNull(); + // A pre-pipeline ownership marker is expected, but no finalized receipt + // may claim that the failed index is usable. + expect(meta).toMatchObject({ lastCommit: '' }); + expect(meta?.stats).toBeUndefined(); }); // State 3 — "cannot ask" is not "wrote nothing". diff --git a/gitnexus/test/unit/server-api-repo-resolution.test.ts b/gitnexus/test/unit/server-api-repo-resolution.test.ts index 4aad2fbb4..2de01c3e0 100644 --- a/gitnexus/test/unit/server-api-repo-resolution.test.ts +++ b/gitnexus/test/unit/server-api-repo-resolution.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { resolveRegisteredRepoEntry } from '../../src/server/api.js'; +import { resolveRegisteredRepoEntry, storageRequirementToHttp } from '../../src/server/api.js'; import type { RegistryEntry } from '../../src/storage/repo-manager.js'; +import { + STATUS_STORAGE_REQUIREMENTS, + StorageRequirementError, + type StorageInspection, +} from '../../src/storage/storage-resolver.js'; const entry = (overrides: Partial): RegistryEntry => ({ name: 'repo', @@ -125,3 +130,44 @@ describe('resolveRegisteredRepoEntry', () => { expect(resolveRegisteredRepoEntry([reels], 'REELS')).toBe(reels); }); }); + +describe('storageRequirementToHttp — GET /api/repo', () => { + const inspection = (state: StorageInspection['state']): StorageInspection => ({ + repoPath: '/tmp/repo', + storagePath: '/tmp/repo/.gitnexus', + state, + hasCodeIndexDB: false, + }); + + it('maps a missing index slot to 404 index-unavailable', () => { + const err = new StorageRequirementError(inspection('missing'), STATUS_STORAGE_REQUIREMENTS); + expect(storageRequirementToHttp(err)).toEqual({ + status: 404, + body: { + error: err.message, + code: 'index-unavailable', + state: 'missing', + }, + }); + }); + + it('maps an empty index slot to 404 index-unavailable', () => { + const err = new StorageRequirementError(inspection('empty'), STATUS_STORAGE_REQUIREMENTS); + expect(storageRequirementToHttp(err).status).toBe(404); + expect(storageRequirementToHttp(err).body.code).toBe('index-unavailable'); + }); + + it('maps an owned slot without a code index to 503 index-unavailable', () => { + const err = new StorageRequirementError(inspection('owned'), STATUS_STORAGE_REQUIREMENTS); + expect(storageRequirementToHttp(err)).toMatchObject({ + status: 503, + body: { code: 'index-unavailable', state: 'owned' }, + }); + }); + + it('maps a foreign storage path to 503 index-unavailable', () => { + const err = new StorageRequirementError(inspection('foreign'), STATUS_STORAGE_REQUIREMENTS); + expect(storageRequirementToHttp(err).status).toBe(503); + expect(storageRequirementToHttp(err).body.code).toBe('index-unavailable'); + }); +}); diff --git a/gitnexus/test/unit/setup-antigravity.test.ts b/gitnexus/test/unit/setup-antigravity.test.ts index 4f817c89e..4bc8b930f 100644 --- a/gitnexus/test/unit/setup-antigravity.test.ts +++ b/gitnexus/test/unit/setup-antigravity.test.ts @@ -254,6 +254,7 @@ describe('setupAntigravity', () => { // The adapter top-level require()s this; the production install path must // co-locate it next to the adapter (symmetric with the Claude install). await expect(fs.access(path.join(destDir, 'resolve-analyze-cmd.cjs'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(destDir, 'registry-query.cjs'))).resolves.toBeUndefined(); }); it('installs skills under ~/.gemini/antigravity/skills//SKILL.md', async () => { @@ -414,6 +415,7 @@ const LOCK_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-lock.cjs'); const PROBE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-db-lock-probe.cjs'); const WIN_RM_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'win-rm-list-json.ps1'); const RESOLVE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'resolve-analyze-cmd.cjs'); +const REGISTRY_QUERY_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'registry-query.cjs'); async function stageAdapter(): Promise { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-adapter-')); @@ -427,6 +429,7 @@ async function stageAdapter(): Promise { // The adapter top-level `require('./resolve-analyze-cmd.cjs')`s this helper; // without staging it the spawned adapter crashes with MODULE_NOT_FOUND. await fs.copyFile(RESOLVE_SRC, path.join(tmp, 'resolve-analyze-cmd.cjs')); + await fs.copyFile(REGISTRY_QUERY_SRC, path.join(tmp, 'registry-query.cjs')); return path.join(tmp, 'gitnexus-antigravity-hook.cjs'); } @@ -458,17 +461,29 @@ function expectAdapterLoaded(stderr: string, status: number | null): void { describe('gitnexus-antigravity-hook adapter', () => { let adapter: string; let workdir: string; + let registryHome: string; beforeEach(async () => { adapter = await stageAdapter(); workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-work-')); + registryHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-registry-')); + await fs.writeFile(path.join(registryHome, 'registry.json'), '[]\n', 'utf-8'); }); afterEach(async () => { await fs.rm(path.dirname(adapter), { recursive: true, force: true }); await fs.rm(workdir, { recursive: true, force: true }); + await fs.rm(registryHome, { recursive: true, force: true }); }); + async function registerWorkdir(storagePath: string): Promise { + await fs.writeFile( + path.join(registryHome, 'registry.json'), + JSON.stringify([{ name: 'adapter-test', path: workdir, storagePath }]), + 'utf-8', + ); + } + it('AfterTool with no .gitnexus/ produces no stdout', async () => { const { stdout, stderr, status } = runAdapter( adapter, @@ -540,6 +555,7 @@ describe('gitnexus-antigravity-hook adapter', () => { JSON.stringify({ lastCommit: '0000000000000000000000000000000000000000', stats: {} }), 'utf-8', ); + await registerWorkdir(gnDir); const input = { hook_event_name: 'AfterTool', @@ -554,6 +570,7 @@ describe('gitnexus-antigravity-hook adapter', () => { const { stdout, stderr } = runAdapter(adapter, input, workdir, { GITNEXUS_INVOCATION: 'gitnexus', GITNEXUS_DEBUG: '', + GITNEXUS_HOME: registryHome, }); // #1913: by default the hint reaches the agent via additionalContext (stdout @@ -568,6 +585,7 @@ describe('gitnexus-antigravity-hook adapter', () => { const debug = runAdapter(adapter, input, workdir, { GITNEXUS_INVOCATION: 'gitnexus', GITNEXUS_DEBUG: '1', + GITNEXUS_HOME: registryHome, }); expect(debug.stderr).toMatch(/\[GitNexus\] index is stale/); expect(debug.stderr).toMatch(/gitnexus analyze/); diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index 97744511d..cff1b300a 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -271,7 +271,7 @@ describe('setupClaudeCode', () => { }); }); - it('copies shared hook helpers (incl. resolve-analyze-cmd.cjs) to ~/.claude/hooks/gitnexus/', async () => { + it('copies shared hook helpers to ~/.claude/hooks/gitnexus/', async () => { setPlatform('linux'); const { setupCommand } = await import('../../src/cli/setup.js'); @@ -290,6 +290,7 @@ describe('setupClaudeCode', () => { await expect( fs.access(path.join(destHooksDir, 'resolve-analyze-cmd.cjs')), ).resolves.toBeUndefined(); + await expect(fs.access(path.join(destHooksDir, 'registry-query.cjs'))).resolves.toBeUndefined(); }); it('records errors and returns the failed REQUIRED helpers when copies fail', async () => { @@ -304,13 +305,15 @@ describe('setupClaudeCode', () => { 'Claude Code hooks', result, ); - expect(result.errors.length).toBe(4); + expect(result.errors.length).toBe(5); expect(result.errors.some((e) => e.includes('resolve-analyze-cmd.cjs'))).toBe(true); + expect(result.errors.some((e) => e.includes('registry-query.cjs'))).toBe(true); expect(result.errors.every((e) => e.startsWith('Claude Code hooks:'))).toBe(true); - // Only the hard-required .cjs trio gates registration; win-rm is best-effort. + // Only the hard-required .cjs helpers gate registration; win-rm is best-effort. expect([...failedRequired].sort()).toEqual([ 'hook-db-lock-probe.cjs', 'hook-lock.cjs', + 'registry-query.cjs', 'resolve-analyze-cmd.cjs', ]); expect(failedRequired).not.toContain('win-rm-list-json.ps1'); @@ -325,8 +328,13 @@ describe('setupClaudeCode', () => { const destDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-helpers-dest-')); const result = { configured: [] as string[], skipped: [] as string[], errors: [] as string[] }; try { - // Provide the three hard-required .cjs helpers; omit win-rm-list-json.ps1. - for (const h of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'resolve-analyze-cmd.cjs']) { + // Provide all hard-required .cjs helpers; omit win-rm-list-json.ps1. + for (const h of [ + 'hook-lock.cjs', + 'hook-db-lock-probe.cjs', + 'resolve-analyze-cmd.cjs', + 'registry-query.cjs', + ]) { await fs.writeFile(path.join(srcDir, h), '// stub\n', 'utf-8'); } const failedRequired = await copyHookHelpers(srcDir, destDir, 'Claude Code hooks', result); diff --git a/gitnexus/test/unit/skip-git-cli.test.ts b/gitnexus/test/unit/skip-git-cli.test.ts index 66dc82474..dca1165b0 100644 --- a/gitnexus/test/unit/skip-git-cli.test.ts +++ b/gitnexus/test/unit/skip-git-cli.test.ts @@ -70,6 +70,10 @@ describe('--skip-git CLI flag', () => { ...process.env, HOME: gitnexusHome, GITNEXUS_HOME: gitnexusHome, + // This suite tests repository-root selection, not extension installation. + // Keep child CLI runs offline so an unavailable FTS download cannot consume + // Vitest's per-test timeout. + GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', }; try { @@ -132,6 +136,7 @@ describe('--skip-git CLI flag', () => { ...process.env, HOME: gitnexusHome, GITNEXUS_HOME: gitnexusHome, + GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', }; try { @@ -198,6 +203,7 @@ describe('--skip-git CLI flag', () => { ...process.env, HOME: gitnexusHome, GITNEXUS_HOME: gitnexusHome, + GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', }; } diff --git a/gitnexus/test/unit/status-content-drift.test.ts b/gitnexus/test/unit/status-content-drift.test.ts index a773f7b80..f6b131f20 100644 --- a/gitnexus/test/unit/status-content-drift.test.ts +++ b/gitnexus/test/unit/status-content-drift.test.ts @@ -42,7 +42,6 @@ const { runnerIdentity } = vi.hoisted(() => ({ vi.mock('../../src/storage/repo-manager.js', () => ({ listRegisteredRepos: vi.fn(), - findRepo: vi.fn(), getStoragePaths: vi.fn((repoPath: string) => ({ storagePath: `${repoPath}/.gitnexus`, lbugPath: `${repoPath}/.gitnexus/lbug`, @@ -61,7 +60,7 @@ vi.mock('../../src/storage/git.js', () => ({ isGitRepo: vi.fn().mockReturnValue(true), getCurrentCommit: vi.fn().mockReturnValue('headsha0'), getCurrentBranch: vi.fn().mockReturnValue('main'), - getGitRoot: vi.fn((p: string) => p), + getGitRoot: vi.fn().mockReturnValue('/repo'), isWorkingTreeDirty: vi.fn().mockReturnValue(false), listWorkingTreeDirtyPaths: vi.fn().mockReturnValue([]), })); @@ -70,9 +69,16 @@ vi.mock('../../src/core/index-content-drift.js', () => ({ detectIndexContentDrift: vi.fn(), })); +vi.mock('../../src/storage/storage-resolver.js', () => ({ + requireStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'), + requireRegisteredStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'), + STATUS_STORAGE_REQUIREMENTS: { allowedStates: ['owned'], requireCodeIndexDB: true }, + StorageRequirementError: class StorageRequirementError extends Error {}, +})); + import { statusCommand } from '../../src/cli/status.js'; import { setCliLanguage } from '../../src/cli/i18n/index.js'; -import { findRepo } from '../../src/storage/repo-manager.js'; +import { loadMeta } from '../../src/storage/repo-manager.js'; import { getCurrentCommit, isWorkingTreeDirty } from '../../src/storage/git.js'; import { detectIndexContentDrift } from '../../src/core/index-content-drift.js'; @@ -98,7 +104,7 @@ const repoWithCoverage = { beforeEach(() => { vi.clearAllMocks(); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - (findRepo as any).mockResolvedValue(repoWithCoverage); + (loadMeta as any).mockResolvedValue(repoWithCoverage.meta); (getCurrentCommit as any).mockReturnValue('headsha0'); (isWorkingTreeDirty as any).mockReturnValue(false); }); @@ -266,10 +272,7 @@ describe('status freshness from per-file drift (#3077)', () => { it('replays persisted indexCoverage into the drift check', async () => { const coverage = { maxFileSizeBytes: 1024 * 1024, dirtyPaths: ['a.js'] }; - (findRepo as any).mockResolvedValue({ - ...repoWithCoverage, - meta: { ...repoWithCoverage.meta, indexCoverage: coverage }, - }); + (loadMeta as any).mockResolvedValue({ ...repoWithCoverage.meta, indexCoverage: coverage }); (detectIndexContentDrift as any).mockResolvedValue({ kind: 'current', coveredFileCount: 1 }); await statusCommand({ json: true }); diff --git a/gitnexus/test/unit/storage-resolver.test.ts b/gitnexus/test/unit/storage-resolver.test.ts new file mode 100644 index 000000000..620a5722d --- /dev/null +++ b/gitnexus/test/unit/storage-resolver.test.ts @@ -0,0 +1,367 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + ANALYZE_FORCE_STORAGE_REQUIREMENTS, + ANALYZE_STORAGE_REQUIREMENTS, + INDEX_FORCE_STORAGE_REQUIREMENTS, + InvalidStoragePathError, + STORAGE_PATH_ENV, + STORAGE_ROOT_ENV, + requireDeletableStoragePath, + requireStoragePath, + StorageDeletionError, + StorageRequirementError, + defaultStoragePath, + ensureStoragePathWritable, + getIndexStorageRequirements, + inspectStoragePath, + resolveStoragePath, + storagePathFromRoot, + storageSlotName, + validateConfiguredStoragePath, +} from '../../src/storage/storage-resolver.js'; + +const temporaryPaths: string[] = []; +const savedStoragePath = process.env[STORAGE_PATH_ENV]; +const savedStorageRoot = process.env[STORAGE_ROOT_ENV]; +const savedHome = process.env.GITNEXUS_HOME; + +const makeTempDir = async (prefix: string): Promise => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + temporaryPaths.push(dir); + return dir; +}; + +afterEach(async () => { + if (savedStoragePath === undefined) delete process.env[STORAGE_PATH_ENV]; + else process.env[STORAGE_PATH_ENV] = savedStoragePath; + if (savedStorageRoot === undefined) delete process.env[STORAGE_ROOT_ENV]; + else process.env[STORAGE_ROOT_ENV] = savedStorageRoot; + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + await Promise.all( + temporaryPaths.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +describe('storage resolver', () => { + it('keeps the repository-local default when no override or registration exists', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-repo-'); + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + process.env.GITNEXUS_HOME = await makeTempDir('gitnexus-storage-resolver-home-'); + + expect(resolveStoragePath(repo)).toBe(defaultStoragePath(repo)); + }); + + it('uses an explicit complete storage path before a root or registered slot', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-repo-'); + const home = await makeTempDir('gitnexus-storage-resolver-home-'); + const registered = path.join(home, 'registered-index'); + const explicit = path.join(home, 'explicit-index'); + const root = path.join(home, 'external-root'); + process.env.GITNEXUS_HOME = home; + await fs.writeFile( + path.join(home, 'registry.json'), + JSON.stringify([{ path: repo, storagePath: registered }]), + ); + process.env[STORAGE_PATH_ENV] = explicit; + process.env[STORAGE_ROOT_ENV] = root; + + expect(resolveStoragePath(repo)).toBe(explicit); + }); + + it('derives an isolated slot beneath an explicit storage root before the registered slot', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-repo-'); + const home = await makeTempDir('gitnexus-storage-resolver-home-'); + const registered = path.join(home, 'registered-index'); + const root = path.join(home, 'external-root'); + process.env.GITNEXUS_HOME = home; + await fs.writeFile( + path.join(home, 'registry.json'), + JSON.stringify([{ path: repo, storagePath: registered }]), + ); + delete process.env[STORAGE_PATH_ENV]; + process.env[STORAGE_ROOT_ENV] = root; + + expect(resolveStoragePath(repo)).toBe(storagePathFromRoot(root, repo)); + }); + + it('uses a registered external slot after the explicit override is absent', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-repo-'); + const home = await makeTempDir('gitnexus-storage-resolver-home-'); + const registered = path.join(home, 'registered-index'); + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + process.env.GITNEXUS_HOME = home; + await fs.writeFile( + path.join(home, 'registry.json'), + JSON.stringify([{ path: repo, storagePath: registered }]), + ); + + expect(resolveStoragePath(repo)).toBe(registered); + }); + + it('uses a registered external slot when the repository is reached through a symlink', async () => { + const root = await makeTempDir('gitnexus-storage-resolver-symlink-'); + const repo = path.join(root, 'repo'); + const linkedRepo = path.join(root, 'repo-link'); + const home = await makeTempDir('gitnexus-storage-resolver-home-'); + const registered = path.join(home, 'registered-index'); + await fs.mkdir(repo); + await fs.symlink(repo, linkedRepo, process.platform === 'win32' ? 'junction' : 'dir'); + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + process.env.GITNEXUS_HOME = home; + await fs.writeFile( + path.join(home, 'registry.json'), + JSON.stringify([{ path: repo, storagePath: registered }]), + ); + + expect(resolveStoragePath(linkedRepo)).toBe(registered); + }); + + it.skipIf(process.platform !== 'win32')( + 'matches a registered missing repository through a Windows extended-length path', + async () => { + const home = await makeTempDir('gitnexus-storage-resolver-home-'); + const repo = path.join(home, 'removed-repository'); + const registered = path.join(home, 'registered-index'); + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + process.env.GITNEXUS_HOME = home; + await fs.writeFile( + path.join(home, 'registry.json'), + JSON.stringify([{ path: repo, storagePath: registered }]), + ); + + expect(resolveStoragePath(`\\\\?\\${repo}`)).toBe(registered); + }, + ); + + it('uses the repository-local default for a legacy registry row without storagePath', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-legacy-'); + const home = await makeTempDir('gitnexus-storage-resolver-home-'); + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + process.env.GITNEXUS_HOME = home; + await fs.writeFile(path.join(home, 'registry.json'), JSON.stringify([{ path: repo }])); + + expect(resolveStoragePath(repo)).toBe(defaultStoragePath(repo)); + }); + + it('rejects a malformed matching registry row', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-repo-'); + const home = await makeTempDir('gitnexus-storage-resolver-home-'); + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + process.env.GITNEXUS_HOME = home; + await fs.writeFile( + path.join(home, 'registry.json'), + JSON.stringify([null, 1, [], { path: repo, storagePath: 1 }]), + ); + + expect(() => resolveStoragePath(repo)).toThrow(InvalidStoragePathError); + }); + + it.each(['', 'relative/index', `bad\0index`, path.parse(process.cwd()).root])( + 'rejects invalid configured storage path %j', + (value) => { + expect(() => validateConfiguredStoragePath(value)).toThrow(InvalidStoragePathError); + }, + ); + + it('strips trailing dots and spaces from a storage slot basename without a regex', () => { + const slot = storageSlotName(path.join(path.sep, 'tmp', 'My Repo. . ')); + expect(slot.startsWith('My Repo-')).toBe(true); + expect(slot).toMatch(/-[0-9a-f]{12}$/); + }); + + it('maps a Windows-reserved basename into a safe slot prefix', () => { + const slot = storageSlotName(path.join(path.sep, 'tmp', 'CON')); + expect(slot.startsWith('repository-CON-')).toBe(true); + }); + + it('trims an adversarial run of trailing spaces in linear time', () => { + const slot = storageSlotName(path.join(path.sep, 'tmp', `keep${' '.repeat(10_000)}`)); + expect(slot.startsWith('keep-')).toBe(true); + }); + + it('rejects inspecting a filesystem-root storage path', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-root-repo-'); + const inspection = await inspectStoragePath(path.parse(process.cwd()).root, repo); + expect(inspection.state).toBe('invalid_param'); + }); + + it('creates independent external slots and verifies they are writable', async () => { + const root = await makeTempDir('gitnexus-storage-resolver-slots-'); + const first = path.join(root, 'first'); + const second = path.join(root, 'second'); + + await Promise.all([ensureStoragePathWritable(first), ensureStoragePathWritable(second)]); + + expect((await fs.stat(first)).isDirectory()).toBe(true); + expect((await fs.stat(second)).isDirectory()).toBe(true); + }); + + it('fails before analysis when the target names a file instead of a writable directory', async () => { + const root = await makeTempDir('gitnexus-storage-resolver-file-'); + const target = path.join(root, 'not-a-directory'); + await fs.writeFile(target, 'not a directory'); + + await expect(ensureStoragePathWritable(target)).rejects.toThrow(); + }); + + it('allows deletion of a missing or empty repository-local slot', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-delete-repo-'); + const storagePath = defaultStoragePath(repo); + await fs.mkdir(storagePath, { recursive: true }); + + await expect(requireDeletableStoragePath({ path: repo, storagePath })).resolves.toBe( + storagePath, + ); + }); + + it('allows deletion of a repository-local slot whose metadata belongs to another repository', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-delete-repo-'); + const storagePath = defaultStoragePath(repo); + await fs.mkdir(storagePath, { recursive: true }); + await fs.writeFile( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ repoPath: path.join(path.dirname(repo), 'other-repo') }), + ); + + await expect(requireDeletableStoragePath({ path: repo, storagePath })).resolves.toBe( + storagePath, + ); + }); + + it('rejects a foreign external slot even when force requirements allow foreign', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-foreign-repo-'); + const storagePath = await makeTempDir('gitnexus-storage-resolver-foreign-storage-'); + await fs.mkdir(path.join(storagePath, 'lbug'), { recursive: true }); + await fs.writeFile( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ + repoPath: path.join(path.dirname(repo), 'other-repo'), + storagePath, + }), + ); + delete process.env[STORAGE_ROOT_ENV]; + process.env[STORAGE_PATH_ENV] = storagePath; + + await expect( + requireStoragePath(repo, ANALYZE_FORCE_STORAGE_REQUIREMENTS), + ).rejects.toBeInstanceOf(StorageRequirementError); + await expect(requireStoragePath(repo, INDEX_FORCE_STORAGE_REQUIREMENTS)).rejects.toBeInstanceOf( + StorageRequirementError, + ); + await expect(requireDeletableStoragePath({ path: repo, storagePath })).rejects.toBeInstanceOf( + StorageDeletionError, + ); + }); + + it('treats a lock-only storage directory as empty so non-force analyze can proceed', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-lock-repo-'); + const storagePath = defaultStoragePath(repo); + await fs.mkdir(storagePath, { recursive: true }); + await fs.writeFile(path.join(storagePath, 'analyze.lock'), 'pid'); + await fs.writeFile(path.join(storagePath, 'analyze.lock.guard'), 'guard'); + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + + await expect(requireStoragePath(repo, ANALYZE_STORAGE_REQUIREMENTS)).resolves.toBe(storagePath); + }); + + it('lets --force adopt a foreign repository-local slot, not the non-force analyze set', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-adopt-repo-'); + const storagePath = defaultStoragePath(repo); + await fs.mkdir(path.join(storagePath, 'lbug'), { recursive: true }); + await fs.writeFile( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ + repoPath: path.join(path.dirname(repo), 'other-repo'), + storagePath, + }), + ); + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + + expect(ANALYZE_FORCE_STORAGE_REQUIREMENTS.allowedStates).toEqual([ + 'missing', + 'empty', + 'owned', + 'unowned', + 'foreign', + ]); + expect(INDEX_FORCE_STORAGE_REQUIREMENTS.allowedStates).toEqual(['owned', 'unowned', 'foreign']); + expect(INDEX_FORCE_STORAGE_REQUIREMENTS.requireCodeIndexDB).toBe(true); + expect(getIndexStorageRequirements(true)).toBe(INDEX_FORCE_STORAGE_REQUIREMENTS); + + await expect(requireStoragePath(repo, ANALYZE_STORAGE_REQUIREMENTS)).rejects.toBeInstanceOf( + StorageRequirementError, + ); + await expect(requireStoragePath(repo, ANALYZE_FORCE_STORAGE_REQUIREMENTS)).resolves.toBe( + storagePath, + ); + await expect(requireStoragePath(repo, INDEX_FORCE_STORAGE_REQUIREMENTS)).resolves.toBe( + storagePath, + ); + }); + + it('lets --force adopt a foreign repository-local slot reached through a symlink', async () => { + const root = await makeTempDir('gitnexus-storage-resolver-force-symlink-'); + const repo = path.join(root, 'repo'); + const linkedRepo = path.join(root, 'repo-link'); + await fs.mkdir(repo); + await fs.symlink(repo, linkedRepo, process.platform === 'win32' ? 'junction' : 'dir'); + const storagePath = defaultStoragePath(linkedRepo); + await fs.mkdir(path.join(storagePath, 'lbug'), { recursive: true }); + await fs.writeFile( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ + repoPath: path.join(path.dirname(repo), 'other-repo'), + storagePath, + }), + ); + delete process.env[STORAGE_PATH_ENV]; + delete process.env[STORAGE_ROOT_ENV]; + + await expect( + requireStoragePath(linkedRepo, ANALYZE_FORCE_STORAGE_REQUIREMENTS), + ).resolves.toMatch(/[\\/]\.gitnexus$/); + }); + + it('requires matching metadata before deleting an external slot', async () => { + const repo = await makeTempDir('gitnexus-storage-resolver-delete-repo-'); + const storagePath = await makeTempDir('gitnexus-storage-resolver-delete-storage-'); + await fs.writeFile( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ repoPath: repo, storagePath }), + ); + + await expect(requireDeletableStoragePath({ path: repo, storagePath })).resolves.toBe( + storagePath, + ); + + await fs.rm(path.join(storagePath, 'gitnexus.json')); + await expect(requireDeletableStoragePath({ path: repo, storagePath })).rejects.toBeInstanceOf( + StorageDeletionError, + ); + }); + + it('rejects a storage path that is the repository after symlink resolution', async () => { + const root = await makeTempDir('gitnexus-storage-resolver-delete-symlink-'); + const repo = path.join(root, 'repo'); + const linkParent = path.join(root, 'link'); + await fs.mkdir(repo); + await fs.symlink(root, linkParent, process.platform === 'win32' ? 'junction' : 'dir'); + const aliasedRepo = path.join(linkParent, 'repo'); + + await expect( + requireDeletableStoragePath({ path: repo, storagePath: aliasedRepo }), + ).rejects.toBeInstanceOf(StorageDeletionError); + }); +}); diff --git a/gitnexus/test/unit/upload-sweep.test.ts b/gitnexus/test/unit/upload-sweep.test.ts index a2667bbcb..be6919b89 100644 --- a/gitnexus/test/unit/upload-sweep.test.ts +++ b/gitnexus/test/unit/upload-sweep.test.ts @@ -1,16 +1,25 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import fs from 'node:fs/promises'; import os from 'node:os'; import { sweepStaleUploads } from '../../src/server/upload-sweep.js'; let root: string; +let home: string; +let previousHome: string | undefined; beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-sweep-test-')); + home = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-sweep-home-')); + await fs.writeFile(path.join(home, 'registry.json'), '[]'); + previousHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = home; }); afterEach(async () => { await fs.rm(root, { recursive: true, force: true }).catch(() => {}); + await fs.rm(home, { recursive: true, force: true }).catch(() => {}); + if (previousHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = previousHome; }); describe('sweepStaleUploads', () => { @@ -36,17 +45,32 @@ describe('sweepStaleUploads', () => { await expect(fs.access(path.join(root, 'myrepo'))).resolves.toBeUndefined(); }); - it('removes a stale promoted dir without a .gitnexus index, keeps one with it', async () => { + it('removes an unregistered stale promoted dir, keeps a registered one without local index', async () => { const now = 2_000_000_000_000; const old = new Date(now - 10 * 60 * 60 * 1000); - // Orphan: a failed analysis that never wrote an index. + // Orphan: a failed analysis that never registered its source directory. await fs.mkdir(path.join(root, 'orphan')); await fs.utimes(path.join(root, 'orphan'), old, old); - // Registered: stale but carries the .gitnexus index → must be kept. - await fs.mkdir(path.join(root, 'registered', '.gitnexus'), { recursive: true }); - await fs.utimes(path.join(root, 'registered'), old, old); + // Registered: stale and its index is external, so it has no local + // `.gitnexus` directory at all. Registry membership is the persistence + // signal for promoted uploads. + const registered = path.join(root, 'registered'); + await fs.mkdir(registered); + await fs.writeFile( + path.join(home, 'registry.json'), + JSON.stringify([ + { + name: 'registered', + path: registered, + storagePath: path.join(home, 'storage', 'registered'), + indexedAt: '', + lastCommit: '', + }, + ]), + ); + await fs.utimes(registered, old, old); const { removed } = await sweepStaleUploads({ root, now, maxAgeMs: 6 * 60 * 60 * 1000 }); @@ -55,6 +79,60 @@ describe('sweepStaleUploads', () => { await expect(fs.access(path.join(root, 'registered'))).resolves.toBeUndefined(); }); + it('keeps a stale promoted dir when registry.json is absent (ENOENT)', async () => { + const now = 2_000_000_000_000; + const old = new Date(now - 10 * 60 * 60 * 1000); + const staging = path.join(root, '.staging-old'); + const promoted = path.join(root, 'promoted'); + await fs.mkdir(staging); + await fs.mkdir(promoted); + await fs.utimes(staging, old, old); + await fs.utimes(promoted, old, old); + await fs.rm(path.join(home, 'registry.json')); + + const { removed } = await sweepStaleUploads({ root, now, maxAgeMs: 6 * 60 * 60 * 1000 }); + + expect(removed).toContain(staging); + await expect(fs.access(staging)).rejects.toBeTruthy(); + await expect(fs.access(promoted)).resolves.toBeUndefined(); + }); + + it('still removes stale staging dirs but preserves promoted source dirs when the registry is corrupt', async () => { + const now = 2_000_000_000_000; + const old = new Date(now - 10 * 60 * 60 * 1000); + const staging = path.join(root, '.staging-old'); + const promoted = path.join(root, 'promoted'); + await fs.mkdir(staging); + await fs.mkdir(promoted); + await fs.utimes(staging, old, old); + await fs.utimes(promoted, old, old); + await fs.writeFile(path.join(home, 'registry.json'), '{"truncated":'); + + const { removed } = await sweepStaleUploads({ root, now, maxAgeMs: 6 * 60 * 60 * 1000 }); + + expect(removed).toContain(staging); + await expect(fs.access(staging)).rejects.toBeTruthy(); + await expect(fs.access(promoted)).resolves.toBeUndefined(); + }); + + it('does not report a path as removed when deletion fails', async () => { + const now = 3_000_000_000_000; + const old = new Date(now - 10 * 60 * 60 * 1000); + const orphan = path.join(root, 'orphan'); + await fs.mkdir(orphan); + await fs.utimes(orphan, old, old); + const spy = vi + .spyOn(fs, 'rm') + .mockRejectedValueOnce(Object.assign(new Error('EACCES'), { code: 'EACCES' })); + + try { + const { removed } = await sweepStaleUploads({ root, now, maxAgeMs: 6 * 60 * 60 * 1000 }); + expect(removed).toEqual([]); + } finally { + spy.mockRestore(); + } + }); + it('tolerates a missing root', async () => { const { removed } = await sweepStaleUploads({ root: path.join(root, 'does-not-exist') }); expect(removed).toEqual([]); diff --git a/gitnexus/test/unit/wiki-flags.test.ts b/gitnexus/test/unit/wiki-flags.test.ts index 49f4cc08e..6ffd46d57 100644 --- a/gitnexus/test/unit/wiki-flags.test.ts +++ b/gitnexus/test/unit/wiki-flags.test.ts @@ -10,6 +10,13 @@ import os from 'os'; import path from 'path'; import fs from 'fs/promises'; +const mockWikiStorage = () => { + vi.doMock('../../src/storage/storage-resolver.js', async (importActual) => ({ + ...(await importActual()), + requireStoragePath: vi.fn().mockResolvedValue('/tmp/wiki-storage'), + })); +}; + // ─── detectCursorCLI caching ───────────────────────────────────────── describe('detectCursorCLI', () => { @@ -368,6 +375,7 @@ describe('wikiCommand provider switch persistence', () => { vi.restoreAllMocks(); vi.doUnmock('../../src/storage/git.js'); vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/storage/storage-resolver.js'); vi.doUnmock('../../src/core/wiki/llm-client.js'); vi.doUnmock('../../src/core/wiki/generator.js'); vi.doUnmock('cli-progress'); @@ -430,6 +438,7 @@ describe('wikiCommand provider switch persistence', () => { Presets: { shades_grey: {} }, }, })); + mockWikiStorage(); vi.spyOn(console, 'log').mockImplementation(() => {}); const { wikiCommand } = await import('../../src/cli/wiki.js'); @@ -648,6 +657,7 @@ describe('wikiCommand --timeout validation', () => { vi.restoreAllMocks(); vi.doUnmock('../../src/storage/git.js'); vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/storage/storage-resolver.js'); vi.doUnmock('../../src/core/wiki/llm-client.js'); vi.doUnmock('../../src/core/wiki/generator.js'); vi.doUnmock('cli-progress'); @@ -707,6 +717,7 @@ describe('wikiCommand --timeout validation', () => { Presets: { shades_grey: {} }, }, })); + mockWikiStorage(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const { wikiCommand } = await import('../../src/cli/wiki.js'); @@ -736,6 +747,7 @@ describe('wikiCommand --retries validation', () => { vi.restoreAllMocks(); vi.doUnmock('../../src/storage/git.js'); vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/storage/storage-resolver.js'); vi.doUnmock('../../src/core/wiki/llm-client.js'); vi.doUnmock('../../src/core/wiki/generator.js'); vi.doUnmock('cli-progress'); @@ -795,6 +807,7 @@ describe('wikiCommand --retries validation', () => { Presets: { shades_grey: {} }, }, })); + mockWikiStorage(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const { wikiCommand } = await import('../../src/cli/wiki.js'); @@ -820,6 +833,7 @@ describe('wikiCommand --timeout mapping', () => { vi.restoreAllMocks(); vi.doUnmock('../../src/storage/git.js'); vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/storage/storage-resolver.js'); vi.doUnmock('../../src/core/wiki/llm-client.js'); vi.doUnmock('../../src/core/wiki/generator.js'); vi.doUnmock('cli-progress'); @@ -887,6 +901,7 @@ describe('wikiCommand --timeout mapping', () => { Presets: { shades_grey: {} }, }, })); + mockWikiStorage(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const { wikiCommand } = await import('../../src/cli/wiki.js'); @@ -958,6 +973,7 @@ describe('wikiCommand timeout messaging', () => { vi.restoreAllMocks(); vi.doUnmock('../../src/storage/git.js'); vi.doUnmock('../../src/storage/repo-manager.js'); + vi.doUnmock('../../src/storage/storage-resolver.js'); vi.doUnmock('../../src/core/wiki/llm-client.js'); vi.doUnmock('../../src/core/wiki/generator.js'); vi.doUnmock('cli-progress'); @@ -1023,6 +1039,7 @@ describe('wikiCommand timeout messaging', () => { Presets: { shades_grey: {} }, }, })); + mockWikiStorage(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const { wikiCommand } = await import('../../src/cli/wiki.js'); diff --git a/gitnexus/test/utils/hook-test-helpers.ts b/gitnexus/test/utils/hook-test-helpers.ts index a7838971a..0500fc790 100644 --- a/gitnexus/test/utils/hook-test-helpers.ts +++ b/gitnexus/test/utils/hook-test-helpers.ts @@ -84,6 +84,19 @@ function writeExecutable(filePath: string, content: string) { fs.writeFileSync(filePath, content, { mode: 0o755 }); } +function shellQuote(value: string) { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function writeImmediateShellExecutable(filePath: string, stdout: string, markerPath?: string) { + writeExecutable( + filePath, + `#!/bin/sh\n` + + (markerPath ? `: > ${shellQuote(markerPath)}\n` : '') + + `printf %s ${shellQuote(stdout)}\n`, + ); +} + export function createHookToolDir(options: { gitnexusStderr?: string; gitnexusMarkerPath?: string; @@ -129,6 +142,12 @@ export function createHookToolDir(options: { options.lsofOutputLines != null ? options.lsofOutputLines.join('\n') + (options.lsofOutputLines.length ? '\n' : '') : (options.lsofOutput ?? ''); + // The owner probe gives lsof one second. Use a shell fixture for immediate + // responses so a Node cold start cannot be misclassified as a timed-out + // (therefore fail-closed) owner under a heavily parallel test run. The + // delay, signal, and PID fixtures below still need a Node process. + const immediateLsof = + options.lsofSleepMs == null && options.lsofPidFile == null && !options.lsofIgnoreSigterm; // Composable prologue: pidFile write MUST stay the first statement (see the // option docs above); SIGTERM trap MUST be installed before any sleep. const lsofPrologue = @@ -144,7 +163,11 @@ export function createHookToolDir(options: { options.lsofSleepMs != null ? `${lsofPrologue}setTimeout(() => {}, ${Number(options.lsofSleepMs)});\n` : `${lsofPrologue}process.stdout.write(${JSON.stringify(lsofOutput)});\nprocess.exit(0);\n`; - writeExecutable(path.join(binDir, 'lsof'), lsofBody); + if (immediateLsof) { + writeImmediateShellExecutable(path.join(binDir, 'lsof'), lsofOutput, options.lsofMarkerPath); + } else { + writeExecutable(path.join(binDir, 'lsof'), lsofBody); + } const psBody = options.psOutputByPid != null @@ -156,7 +179,11 @@ process.stdout.write(byPid[p] ?? ''); process.exit(0); ` : `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(options.psOutput ?? '')});\nprocess.exit(0);\n`; - writeExecutable(path.join(binDir, 'ps'), psBody); + if (options.psOutputByPid == null) { + writeImmediateShellExecutable(path.join(binDir, 'ps'), options.psOutput ?? ''); + } else { + writeExecutable(path.join(binDir, 'ps'), psBody); + } return binDir; } diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 85d735259..4b2625805 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -14,7 +14,9 @@ export default defineConfig({ // respawn every such child with a RAM-sized cap. Children inherit it via // the harnesses' `{ ...process.env }` spreads. Tests that exercise the // respawn behavior itself delete GITNEXUS_MEMORY in their own setup. - env: { GITNEXUS_MEMORY: 'off' }, + // Tests assert the English CLI contract unless a case opts into another + // language explicitly. Do not inherit a developer shell's CLI locale. + env: { GITNEXUS_MEMORY: 'off', GITNEXUS_LANG: 'en' }, // N-API destructors can crash worker forks on macOS during process exit. // This is independent of the QueryResult lifetime fix in @ladybugdb/core 0.15.2 — // it's a vitest forks + native addon interaction where destructors run in