diff --git a/.cursor/index.mdc b/.cursor/index.mdc index c51f18bfd..b7c8597df 100644 --- a/.cursor/index.mdc +++ b/.cursor/index.mdc @@ -14,7 +14,7 @@ Canonical agent instructions: **[AGENTS.md](../AGENTS.md)** (GitNexus MCP rules, - NEVER rename symbols with find-and-replace — use `gitnexus_rename`. - NEVER commit without running `gitnexus_detect_changes()`. - NEVER ignore HIGH/CRITICAL risk warnings from impact analysis. -- NEVER run `npx gitnexus analyze` without `--embeddings` if `.gitnexus/meta.json` shows stored embeddings. +- NEVER run `npx gitnexus analyze` without `--embeddings` if the index metadata (`.gitnexus/gitnexus.json` / legacy `meta.json`) shows stored embeddings. Full rules: **[AGENTS.md](../AGENTS.md)** (`gitnexus:start` block, Cursor Cloud section). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 109e9bbde..8ddaf12ba 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -383,7 +383,8 @@ CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks) ├── lbug # LadybugDB database ├── lbug.wal # Write-ahead log ├── lbug.lock # Single-writer lock - └── meta.json # lastCommit, indexedAt, stats + ├── gitnexus.json # lastCommit, indexedAt, stats (primary metadata file) + └── meta.json # legacy mirror of gitnexus.json, kept in sync (see MIGRATION.md) ~/.gitnexus/ └── registry.json # Global repo registry (MCP discovery) diff --git a/GUARDRAILS.md b/GUARDRAILS.md index aa3f70cf7..b6d3ad5ab 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -19,7 +19,7 @@ Maintainer may widen scope per task. 2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists. 3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off. 4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available. -5. **Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in `.gitnexus/meta.json` (the previous behavior wiped them). Use `--embeddings` to also generate vectors for new/changed nodes; use `--drop-embeddings` only when an explicit wipe is intended (e.g., model swap). +5. **Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in the index metadata (`.gitnexus/gitnexus.json`, mirrored to the legacy `meta.json`) — the previous behavior wiped them. Use `--embeddings` to also generate vectors for new/changed nodes; use `--drop-embeddings` only when an explicit wipe is intended (e.g., model swap). --- @@ -35,13 +35,13 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m ### Index seems corrupt or "incremental" is misbehaving -- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash. +- **Trigger:** `analyze` produces unexpected results, or `incrementalInProgress` is set in the index metadata (`.gitnexus/gitnexus.json` / legacy `meta.json`), or the index is in a half-state after a crash. - **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated. - **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index. ### Embeddings vanished after analyze -- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh. +- **Trigger:** Semantic search quality drops; `stats.embeddings` in the index metadata (`gitnexus.json` / legacy `meta.json`) is 0 after refresh. - **Do:** Re-run `npx gitnexus analyze --embeddings` to regenerate. Check the analyze log for a `Warning: could not load cached embeddings` line — if present, the cache restore failed (corrupt DB / schema mismatch) and the rebuild had nothing to preserve. If you intentionally passed `--drop-embeddings`, this is expected. - **Why:** Plain `analyze` preserves prior vectors by re-inserting them after the rebuild; the only ways to end up at zero are an explicit `--drop-embeddings`, a cache-load failure (now logged), or a model/dimension change that invalidates the cache. diff --git a/MIGRATION.md b/MIGRATION.md index 88488b0ae..f6af6c6a7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -69,3 +69,44 @@ normal full re-index. The `OVERRIDES` compat alias will remain until a future major version. Removal will be announced in this file and in the changelog before it happens. + +## meta.json → gitnexus.json (PR #2363) + +The per-repo index metadata file's primary name changed from +`.gitnexus/meta.json` to `.gitnexus/gitnexus.json` (and from +`branches//meta.json` to `branches//gitnexus.json` for +multi-branch indexes). This is purely a filename change — the JSON content +and every field in it are identical. + +### Do I need to migrate? + +**No.** Backward compatibility is handled automatically at runtime: + +- `saveMeta` dual-writes both filenames on every analyze, so `meta.json` + keeps existing and staying current. Older GitNexus binaries, still-running + MCP servers, and the shipped editor hooks that read `meta.json` continue + to work unchanged. +- `loadMeta` reads `gitnexus.json` first and falls back to `meta.json` when + the primary file is absent, so a repo indexed by an older version works + without re-analysis. +- Each `analyze` run also reconciles the two files (the fresher `indexedAt` + wins and is written to both), so even a repo written by a mix of old and + new versions converges. Nothing is ever deleted. + +### What happens on re-index? + +Running `npx gitnexus analyze` writes both `gitnexus.json` and `meta.json` +with identical content. A pre-existing repo that only has `meta.json` gets +`gitnexus.json` bootstrapped from it on the first run. + +### What about rollback? + +Downgrading to an older GitNexus version is safe: `meta.json` is always +present and current, so the older binary sees the existing index (including +the `incrementalInProgress` crash-recovery flag) instead of treating the +repo as never analyzed. + +### When will the legacy mirror be removed? + +The `meta.json` mirror will remain until a future major version. Removal +will be announced in this file and in the changelog before it happens. diff --git a/RUNBOOK.md b/RUNBOOK.md index c1a1b3b8d..cec107401 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -56,7 +56,7 @@ npx gitnexus list npx gitnexus analyze --embeddings ``` -**Important:** If you already had embeddings, **always** pass `--embeddings` on later analyzes, or they can be dropped. See `stats.embeddings` in `.gitnexus/meta.json` (0 means none). +**Important:** If you already had embeddings, **always** pass `--embeddings` on later analyzes, or they can be dropped. See `stats.embeddings` in `.gitnexus/gitnexus.json` (or its legacy `meta.json` mirror; 0 means none). **Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output. diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 2cf133cd9..021dce60c 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -38,13 +38,35 @@ function readInput() { * Returns the path to .gitnexus/ or null if not found. */ function isGlobalRegistryDir(candidate) { - if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + 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. @@ -462,12 +484,10 @@ function handlePostToolUse(input) { let lastCommit = ''; let hadEmbeddings = false; - try { - const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); + const meta = readIndexMeta(gitNexusDir); + if (meta) { lastCommit = meta.lastCommit || ''; hadEmbeddings = meta.stats && meta.stats.embeddings > 0; - } catch { - /* no meta — treat as stale */ } // If HEAD matches last indexed commit, no reindex needed diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs index d497a16d9..e68aca1de 100644 --- a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -30,7 +30,12 @@ function readInput() { } function isGlobalRegistryDir(candidate) { - if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + 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')) diff --git a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs index f7d12ca6c..6b9e8b9f8 100755 --- a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs +++ b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs @@ -40,13 +40,35 @@ function readInput() { } function isGlobalRegistryDir(candidate) { - if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + 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++) { @@ -426,12 +448,10 @@ function buildStaleIndexHint(gitNexusDir, cwd) { let lastCommit = ''; let hadEmbeddings = false; - try { - const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); + const meta = readIndexMeta(gitNexusDir); + if (meta) { lastCommit = meta.lastCommit || ''; hadEmbeddings = meta.stats && meta.stats.embeddings > 0; - } catch { - /* no meta — treat as stale */ } if (currentHead === lastCommit) return ''; diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 740fe8559..2f24a6f4d 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -38,13 +38,35 @@ function readInput() { * Returns the path to .gitnexus/ or null if not found. */ function isGlobalRegistryDir(candidate) { - if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + 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. @@ -442,12 +464,10 @@ function handlePostToolUse(input) { let lastCommit = ''; let hadEmbeddings = false; - try { - const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8')); + const meta = readIndexMeta(gitNexusDir); + if (meta) { lastCommit = meta.lastCommit || ''; hadEmbeddings = meta.stats && meta.stats.embeddings > 0; - } catch { - /* no meta — treat as stale */ } // If HEAD matches last indexed commit, no reindex needed diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 70f4dd955..525460258 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -200,7 +200,7 @@ export const en = { 'help.option.analyze.embeddingBatchSize': 'Number of nodes per embedding batch', 'help.option.analyze.embeddingSubBatchSize': 'Number of chunks per embedding model call', 'help.option.analyze.embeddingDevice': 'Embedding device: auto, cpu, dml, cuda, or wasm', - 'help.option.index.force': 'Register even if meta.json is missing (stats will be empty)', + 'help.option.index.force': 'Register even if index metadata is missing (stats will be empty)', 'help.option.index.allowNonGit': 'Allow registering folders that are not Git repositories', 'help.option.port': 'Port number', 'help.option.serve.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index ac57cbc2a..bda0dd673 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -187,7 +187,7 @@ export const zhCN = { 'help.option.analyze.embeddingBatchSize': '每个嵌入批次的节点数', 'help.option.analyze.embeddingSubBatchSize': '每次嵌入模型调用的分块数', 'help.option.analyze.embeddingDevice': '嵌入设备:auto、cpu、dml、cuda 或 wasm', - 'help.option.index.force': '即使缺少 meta.json 也注册(统计为空)', + 'help.option.index.force': '即使缺少索引元数据也注册(统计为空)', 'help.option.index.allowNonGit': '允许注册非 Git 仓库文件夹', 'help.option.port': '端口号', 'help.option.serve.host': '绑定地址(默认:127.0.0.1;远程访问可用 0.0.0.0)', diff --git a/gitnexus/src/cli/index-repo.ts b/gitnexus/src/cli/index-repo.ts index 52e8eb60d..09888e901 100644 --- a/gitnexus/src/cli/index-repo.ts +++ b/gitnexus/src/cli/index-repo.ts @@ -1,10 +1,14 @@ /** * Index Command * - * Registers an existing .gitnexus/ folder into the global registry so the + * Registers an existing GitNexus index into the global registry so the * MCP server can discover the repo without running a full `gitnexus analyze`. * - * Useful when a pre-built .gitnexus/ directory is already present (e.g. after + * The index can be either: + * - A per-worktree gitnexus.json file under .gitnexus/ (new format, worktree-compatible) + * - A legacy .gitnexus/meta.json file (auto-migrated on analyze) + * + * Useful when a pre-built index is already present (e.g. after * cloning a repo that ships its index, restoring from backup, or using a * shared team index). */ @@ -13,6 +17,7 @@ import path from 'path'; import fs from 'fs/promises'; import { getStoragePaths, + INDEX_METADATA_FILE, loadMeta, ensureGitNexusIgnored, registerRepo, @@ -66,21 +71,37 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt const { storagePath, lbugPath } = getStoragePaths(repoPath); - // ── Verify .gitnexus/ exists ────────────────────────────────────── + // ── Verify index exists (metadata file, legacy metadata, or restorable DB) ─ + let hasMetadataIndex = false; + let hasLegacyIndex = false; + let hasLbugIndex = false; + try { - await fs.access(storagePath); - } catch { - console.log(` No .gitnexus/ folder found at: ${storagePath}`); + 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 ─────────────────────────────────── - try { - await fs.access(lbugPath); - } catch { - console.log(` .gitnexus/ folder exists but contains no LadybugDB index.`); + if (!hasLbugIndex) { + console.log(` Index exists but contains no LadybugDB database.`); console.log(' Run `gitnexus analyze` to build the index.\n'); process.exitCode = 1; return; @@ -91,7 +112,7 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt if (!meta) { if (!options?.force) { - console.log(` .gitnexus/ exists but meta.json is missing.`); + 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'); process.exitCode = 1; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 62a1917b1..b238d0d3d 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -195,7 +195,7 @@ program .description( 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)', ) - .option('-f, --force', 'Register even if meta.json is missing (stats will be empty)') + .option('-f, --force', 'Register even if index metadata is missing (stats will be empty)') .option('--allow-non-git', 'Allow registering folders that are not Git repositories') .action(createLazyAction(() => import('./index-repo.js'), 'indexCommand')); diff --git a/gitnexus/src/cli/remove.ts b/gitnexus/src/cli/remove.ts index 02a0cf0c6..260c474e9 100644 --- a/gitnexus/src/cli/remove.ts +++ b/gitnexus/src/cli/remove.ts @@ -1,9 +1,12 @@ /** * Remove Command (#664) * - * Delete the `.gitnexus/` index for a registered repo and unregister it - * from the global registry (~/.gitnexus/registry.json). The target is - * identified by alias / basename-derived name / remote-inferred name / + * Delete the `.gitnexus/` index directory for a registered repo (including + * both metadata filenames — gitnexus.json and its legacy meta.json mirror — + * which live inside it) and unregister it from the global registry + * (~/.gitnexus/registry.json). + * + * The target is identified by alias / basename-derived name / remote-inferred name / * absolute path — no `--repo` flag, just a positional argument so the * destructive-command ergonomics match `clean` (which is also * destructive but scoped to `process.cwd()`). diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index f2834435a..6d99174a2 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -12,6 +12,7 @@ import { } from '../lbug/lbug-config.js'; import { dedupeContracts, dedupeCrossLinks } from './normalization.js'; import { createLogger } from '../logger.js'; +import { retryRename } from '../../storage/fs-atomic.js'; const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE', @@ -641,25 +642,6 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise { // The read-only CHECKPOINT skip above remains the load-bearing fix on // Linux/macOS. -/* ------------------------------------------------------------------ */ -/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */ -/* ------------------------------------------------------------------ */ - -const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']); - -export async function retryRename(src: string, dst: string, attempts = 3): Promise { - for (let i = 1; i <= attempts; i++) { - try { - await fsp.rename(src, dst); - return; - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (!code || !RETRY_CODES.has(code) || i === attempts) throw err; - await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i - 1))); - } - } -} - /* ------------------------------------------------------------------ */ /* writeBridgeMeta / readBridgeMeta */ /* ------------------------------------------------------------------ */ diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index 5edfdf3a0..c18587ce2 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -6,6 +6,7 @@ import fsp from 'node:fs/promises'; import path from 'node:path'; import { checkStaleness } from '../git-staleness.js'; +import { loadMeta, type RepoMeta } from '../../storage/repo-manager.js'; import { GroupNotFoundError, loadGroupConfig } from './config-parser.js'; import { fileMatchesServicePrefix, @@ -576,9 +577,8 @@ export class GroupService { for (const [repoPath, registryName] of Object.entries(config.repos)) { try { const repoObj = await this.port.resolveRepo(registryName); - const metaPath = path.join(repoObj.storagePath, 'meta.json'); - const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}'); - const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string }; + const meta: Partial> = + (await loadMeta(repoObj.storagePath)) ?? {}; const staleness = meta.lastCommit ? checkStaleness(repoObj.repoPath, meta.lastCommit) diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index bc08fd7f9..b23f48d68 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { randomBytes } from 'node:crypto'; import type { ContractRegistry } from './types.js'; -import { retryRename } from './bridge-db.js'; +import { retryRename } from '../../storage/fs-atomic.js'; /** * Build an unpredictable suffix for atomic-write tmp files. Replaces the diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 389294579..66d3a6cd2 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -55,6 +55,9 @@ import { registerRepo, isRepoRegistered, cleanupOldKuzuFiles, + reconcileMetadataFiles, + isMissingFilesystemError, + INDEX_METADATA_FILE, INCREMENTAL_SCHEMA_VERSION, type RepoMeta, } from '../storage/repo-manager.js'; @@ -363,11 +366,14 @@ export const primaryInversionWarning = ( /** * Collect the recorded parse-cache chunk keys across the flat + every branch - * meta under a flat `.gitnexus` storage, EXCLUDING `excludeDir` (the current - * run's own meta dir) so a single-branch repo collects nothing and its prune - * stays byte-identical to today (#2106 R6). `complete` is false when a sibling - * meta.json exists but fails to parse — callers then retain the whole shared - * cache rather than over-evict another branch's still-live shards. Exported for + * metadata directory under a flat `.gitnexus` storage, EXCLUDING `excludeDir` + * (the current run's own meta dir) so a single-branch repo collects nothing and + * its prune stays byte-identical to today (#2106 R6 — the byte-identity claim + * is about the PRUNE result; the metadata FILENAME read here changed with + * PR #2363's rename, checking `gitnexus.json` first then the legacy + * `meta.json` mirror). `complete` is false when a sibling metadata file exists + * but fails to read or parse — callers then retain the whole shared cache + * rather than over-evict another branch's still-live shards. Exported for * testing. */ export const collectBranchCacheKeys = async ( @@ -384,9 +390,18 @@ export const collectBranchCacheKeys = async ( if (excludeDir && path.resolve(dir) === path.resolve(excludeDir)) continue; let raw: string; try { - raw = await fs.readFile(path.join(dir, 'meta.json'), 'utf-8'); - } catch { - continue; // no meta here — not a branch index, not a failure + raw = await fs.readFile(path.join(dir, INDEX_METADATA_FILE), 'utf-8'); + } catch (newErr) { + if (!isMissingFilesystemError(newErr)) { + complete = false; + continue; + } + try { + raw = await fs.readFile(path.join(dir, 'meta.json'), 'utf-8'); + } catch (legacyErr) { + if (!isMissingFilesystemError(legacyErr)) complete = false; + continue; // no metadata here — not a branch index, not a failure + } } try { const parsed = JSON.parse(raw) as { cacheKeys?: unknown }; @@ -614,11 +629,22 @@ export async function runFullAnalysis( const branchLabel = options.branch ?? checkedOutBranch; const placement = await resolveBranchPlacement(repoPath, branchLabel); const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch); - // Directory that owns this run's meta.json (flat `.gitnexus` for the primary - // slot, `branches//` otherwise). loadMeta/saveMeta operate on it so - // each branch keeps its own lastCommit / fileHashes / incremental dirty flag. + // metaPath now points to the metadata file (gitnexus.json) in a branch-specific directory. + // metaDir is the directory containing the metadata file (and branch-specific DBs). const metaDir = path.dirname(metaPath); + // 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. + try { + await reconcileMetadataFiles(repoPath); + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + log(`Metadata reconciliation failed (non-critical${code ? `, ${code}` : ''}); continuing.`); + } + const existingMeta = await loadMeta(metaDir); // ── #2106 (R8): warn when the repo's default branch is not the primary ── diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 037d0cafe..fa59dfe53 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -1295,14 +1295,14 @@ export class LocalBackend { this.lastStalenessCheck.set(poolKey, now); try { - // Read the meta.json that sits next to THIS handle's lbug. For the - // flat/primary handle this is `/meta.json` (unchanged); - // for a branch handle it is `/branches//meta.json`. + // Read the metadata that sits next to THIS handle's lbug. For the + // flat/primary handle this is `/gitnexus.json`; for a + // branch handle it is `/branches//gitnexus.json`. + // loadMeta falls back to legacy meta.json during migration. // Reading the flat meta for a branch handle would compare the branch // index's indexedAt against the primary's and thrash the pool (#2106). - const metaPath = path.join(path.dirname(repo.lbugPath), 'meta.json'); - const metaRaw = await fs.readFile(metaPath, 'utf-8'); - const meta = JSON.parse(metaRaw); + const meta = await loadMeta(path.dirname(repo.lbugPath)); + if (!meta) return; // Compare against the last indexedAt OBSERVED for this pool (keyed by // lbugPath), not the handle's — branch handles are fresh spreads so a // handle mutation would not persist and would reinit on every check. diff --git a/gitnexus/src/storage/fs-atomic.ts b/gitnexus/src/storage/fs-atomic.ts new file mode 100644 index 000000000..8fcf3e5ed --- /dev/null +++ b/gitnexus/src/storage/fs-atomic.ts @@ -0,0 +1,28 @@ +/** + * Atomic file-write primitives shared across storage/ and core/group/. + * + * `retryRename` originated in core/group/bridge-db.ts; it lives here so + * storage/repo-manager.ts can use it without introducing a storage/ -> + * core/group/ import (the established direction is core/group/ -> storage/, + * e.g. core/group/service.ts already imports loadMeta from here). + */ +import fsp from 'fs/promises'; + +const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']); + +/** + * Rename with retry on transient EBUSY/EPERM/EACCES (observed on Windows + * when a concurrent reader holds the target file open). + */ +export async function retryRename(src: string, dst: string, attempts = 3): Promise { + for (let i = 1; i <= attempts; i++) { + try { + await fsp.rename(src, dst); + return; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (!code || !RETRY_CODES.has(code) || i === attempts) throw err; + await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i - 1))); + } + } +} diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 7f0243855..2ad952481 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -1,16 +1,26 @@ /** * Repository Manager * - * Manages GitNexus index storage in .gitnexus/ at repo root. - * Also maintains a global registry at ~/.gitnexus/registry.json - * so the MCP server can discover indexed repos from any cwd. + * Manages GitNexus index storage: + * - Per-repo metadata file (gitnexus.json) under .gitnexus/, dual-written to a + * legacy meta.json mirror for backward compatibility (see MIGRATION.md) + * - .gitnexus/ directory for local metadata and caches (parse-cache, parsedfile-store) + * - Global registry at ~/.gitnexus/registry.json for MCP server discovery + * + * gitnexus.json is simply a filename distinct from the generic meta.json — it + * has no bearing on git worktree behavior. .gitnexus/ remains fully git-ignored + * in every case; each worktree already has its own independent .gitnexus/ by + * construction (getStoragePath is per-checkout), regardless of which filename + * the metadata inside it uses. */ import fs from 'fs/promises'; import { realpathSync } from 'fs'; import path from 'path'; import os from 'os'; +import { randomBytes } from 'crypto'; import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js'; +import { retryRename } from './fs-atomic.js'; import { logger } from '../core/logger.js'; import { branchSlug, @@ -117,12 +127,13 @@ export interface RepoMeta { */ fileHashes?: Record; /** - * Crash-recovery dirty flag — a generic marker written to meta.json - * BEFORE any destructive DB mutation by BOTH writeback branches - * (incremental since its introduction; full rebuilds over an existing - * meta since #2099 F1); cleared on success by overwriting meta.json. - * If a run crashes between, the next run sees the flag and forces a - * full rebuild — the cheapest path back to a known-good index. + * Crash-recovery dirty flag — a generic marker written to the metadata + * file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB + * mutation by BOTH writeback branches (incremental since its introduction; + * full rebuilds over an existing meta since #2099 F1); cleared on success + * by overwriting the metadata file. If a run crashes between, the next + * run sees the flag and forces a full rebuild — the cheapest path back + * to a known-good index. */ incrementalInProgress?: { /** When the run started (epoch ms). */ @@ -133,9 +144,9 @@ export interface RepoMeta { }; /** * Name of the git branch this index represents (#2106). Absent for the - * default/legacy single-branch case so the flat `meta.json` stays + * default/legacy single-branch case so the flat metadata file stays * byte-identical to pre-multi-branch output. When present in the FLAT - * `meta.json`, it records which branch "owns" the flat slot (the first + * metadata file, it records which branch "owns" the flat slot (the first * branch indexed); per-branch indexes under `branches//` always carry * their own `branch`. */ @@ -299,11 +310,16 @@ export interface RegistryEntry { const GITNEXUS_DIR = '.gitnexus'; const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`; +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). +const LEGACY_METADATA_FILE = 'meta.json'; // ─── Local Storage Helpers ───────────────────────────────────────────── /** - * Get the .gitnexus storage path for a repository + * Get the .gitnexus storage path for a repository. + * Used for local metadata and caches that are not committed. */ export const getStoragePath = (repoPath: string): string => { return path.join(path.resolve(repoPath), GITNEXUS_DIR); @@ -314,9 +330,20 @@ export const getStoragePath = (repoPath: string): string => { * * `storagePath` is ALWAYS the flat `/.gitnexus` — content-addressed * caches (`parse-cache/`, `parsedfile-store/`) live there and are shared - * across branches (#2106 KTD7). When `branch` is provided, only `lbugPath` and - * `metaPath` are scoped under `branches//`; the flat call (no `branch`) - * returns byte-identical paths to the pre-multi-branch behavior. + * across branches (#2106 KTD7). When `branch` is provided, both `lbugPath` + * and `metaPath` are scoped under `branches//`. For the flat call + * (no `branch`), `storagePath` and `lbugPath` remain byte-identical to the + * pre-multi-branch behavior (#2106); `metaPath`'s FILENAME changed from + * `meta.json` to `gitnexus.json` (PR #2363) — `saveMeta` keeps a `meta.json` + * mirror in sync for consumers that still read the legacy name. + * + * Each branch slot has its own metadata file: + * - Primary/flat: /.gitnexus/gitnexus.json + * - Feature branches: /.gitnexus/branches//gitnexus.json + * + * Callers should use `loadMeta(metaDir)` and `saveMeta(metaDir, meta)` where + * 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); @@ -324,7 +351,7 @@ export const getStoragePaths = (repoPath: string, branch?: string) => { return { storagePath, lbugPath: path.join(baseDir, 'lbug'), - metaPath: path.join(baseDir, 'meta.json'), + metaPath: path.join(baseDir, INDEX_METADATA_FILE), // Branch-specific metadata file }; }; @@ -382,53 +409,112 @@ export const cleanupOldKuzuFiles = async ( }; /** - * Load metadata from an indexed repo + * Load metadata from the legacy `meta.json` mirror in the given directory. + * Returns null when the file is absent, unreadable, or unparseable — a + * corrupt legacy file is treated the same as a missing one (safe rebuild). */ -export const loadMeta = async (storagePath: string): Promise => { +const loadMetaLegacy = async (metaDir: string): Promise => + tryReadMetaFile(metaDir, LEGACY_METADATA_FILE); + +/** + * Load metadata from a directory containing the metadata file (gitnexus.json). + * For primary/flat: metaDir = /.gitnexus + * For feature branches: metaDir = /.gitnexus/branches/ + * + * Falls back to the legacy `meta.json` mirror ONLY when `gitnexus.json` is + * provably absent (ENOENT/ENOTDIR). Any other failure — a parse error, EACCES, + * EIO — returns null instead of silently resurrecting possibly-stale legacy + * content: a corrupt primary file must trigger the same safe full-rebuild path + * a missing index would (the fail-safe `saveMeta`'s docstring relies on), not + * an incremental run over a stale legacy baseline. + */ +export const loadMeta = async (metaDir: string): Promise => { + let raw: string; + try { + raw = await fs.readFile(path.join(metaDir, INDEX_METADATA_FILE), 'utf-8'); + } catch (err) { + // Provably absent → the legacy mirror is the source of truth (pre-rename + // repo, or a mirror-only state). Anything else → fail safe with null. + return isMissingFilesystemError(err) ? loadMetaLegacy(metaDir) : null; + } try { - const metaPath = path.join(storagePath, 'meta.json'); - const raw = await fs.readFile(metaPath, 'utf-8'); return JSON.parse(raw) as RepoMeta; } catch { + // Corrupt primary file — do NOT mask it with legacy content. return null; } }; /** - * Save metadata to storage. + * Atomically write `meta` to `/`. Tmp name includes a random + * suffix (not a fixed `.tmp`) so two concurrent writers targeting the same + * directory never collide on the same tmp path — mirrors the pattern in + * core/group/bridge-db.ts's `writeBridgeMeta` (`'wx'` + `0o600` closes the + * symlink-race/permissions holes CodeQL flags as `js/insecure-temporary-file`; + * `retryRename` absorbs a transient EBUSY/EPERM/EACCES on the rename itself). + */ +async function writeMetaFile(dir: string, filename: string, meta: RepoMeta): Promise { + const targetPath = path.join(dir, filename); + const tmpPath = `${targetPath}.tmp.${randomBytes(8).toString('hex')}`; + const handle = await fs.open(tmpPath, 'wx', 0o600); + try { + await handle.writeFile(JSON.stringify(meta, null, 2), 'utf-8'); + } finally { + await handle.close(); + } + await retryRename(tmpPath, targetPath); +} + +/** + * Save metadata to the metadata file (gitnexus.json) in the given directory, + * dual-writing the legacy `meta.json` mirror for backward compatibility. * * Atomic via tmp-file + rename (matches `saveParseCache`'s pattern). The * `incrementalInProgress` dirty flag travels through this file — a crash - * mid-write would leave a corrupt `meta.json` that the next run's + * mid-write would leave a corrupt `gitnexus.json` that the next run's * `loadMeta` would silently treat as "no prior index", losing the dirty * flag and skipping the recovery full-rebuild. Write-and-rename rules * that out: the rename is atomic on POSIX and on Windows (`fs.rename` * on `node:fs/promises` uses `MoveFileEx(REPLACE_EXISTING)`), so either * the old or the new file is observed at every moment. + * + * `gitnexus.json` is the primary write and must succeed. `meta.json` is a + * best-effort mirror kept for consumers that only know the legacy filename + * (see MIGRATION.md) — its write failure is logged, not thrown, so a + * mirror-write hiccup never fails the caller's analyze run. */ -export const saveMeta = async (storagePath: string, meta: RepoMeta): Promise => { - await fs.mkdir(storagePath, { recursive: true }); - const metaPath = path.join(storagePath, 'meta.json'); - const tmpPath = `${metaPath}.tmp`; - await fs.writeFile(tmpPath, JSON.stringify(meta, null, 2), 'utf-8'); - await fs.rename(tmpPath, metaPath); -}; - -/** - * Check if a path has a GitNexus index - */ -export const hasIndex = async (repoPath: string): Promise => { - const { metaPath } = getStoragePaths(repoPath); +export const saveMeta = async (metaDir: string, meta: RepoMeta): Promise => { + await fs.mkdir(metaDir, { recursive: true }); + await writeMetaFile(metaDir, INDEX_METADATA_FILE, meta); try { - await fs.access(metaPath); - return true; - } catch { - return false; + await writeMetaFile(metaDir, LEGACY_METADATA_FILE, meta); + } catch (err) { + logger.warn({ err, metaDir }, 'Failed to write legacy meta.json mirror (non-critical)'); } }; /** - * Load an indexed repo from a path + * Check if a path has a GitNexus index (metadata file or legacy location) + */ +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; + } + } +}; + +/** + * Load an indexed repo from a path (checks metadata file first, then legacy) */ export const loadRepo = async (repoPath: string): Promise => { const paths = getStoragePaths(repoPath); @@ -442,6 +528,119 @@ export const loadRepo = async (repoPath: string): Promise => }; }; +/** + * Best-effort read of one specific metadata filename — no fallback, null on + * any failure (absent, unreadable, or unparseable). + */ +const tryReadMetaFile = async (dir: string, filename: string): Promise => { + try { + const raw = await fs.readFile(path.join(dir, filename), 'utf-8'); + return JSON.parse(raw) as RepoMeta; + } catch { + return null; + } +}; + +/** `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; +}; + +/** + * 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. + * 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 + } + } + 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)', + ); + 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; +}; + +/** + * Reconcile the metadata files for a repo's flat slot and every + * `branches//` slot. Runs once per `analyze` (see run-analyze.ts). + * + * 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 + * a future major version — see MIGRATION.md), so older binaries, still-running + * MCP servers, and the shipped editor hooks keep working, and a rollback to a + * 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); + let changed = await reconcileMetaDir(storagePath); + + const branchesDir = path.join(storagePath, BRANCHES_DIR); + let branchDirs: string[]; + try { + branchDirs = await fs.readdir(branchesDir); + } catch { + // branchesDir may not exist (not a multi-branch repo) — expected, silent. + return changed; + } + + for (const branchDir of branchDirs) { + const branchPath = path.join(branchesDir, branchDir); + // Per-branch isolation: one bad branch dir (dangling symlink, EACCES) + // must not silently abort reconciliation for every branch after it — + // readdir order is stable, so an unguarded throw here would permanently + // starve the same trailing branches on every run. + try { + const stat = await fs.stat(branchPath); + if (!stat.isDirectory()) continue; + if (await reconcileMetaDir(branchPath)) changed = true; + } catch (err) { + logger.warn( + { branchDir, err }, + 'Skipping branch directory during metadata reconciliation (non-critical)', + ); + } + } + + return changed; +}; + /** * Find .gitnexus by walking up from a starting path */ @@ -464,7 +663,18 @@ function isReadOnlyFilesystemError(err: unknown): boolean { } /** - * Keep generated index files ignored without modifying the user's root .gitignore. + * True for errors that prove a path is absent (ENOENT/ENOTDIR) — as opposed + * to transient/permission failures (EIO/EACCES/EBUSY…) where the file may + * well still exist. Exported for consumers that need the same "provably + * missing vs not provably absent" distinction (e.g. collectBranchCacheKeys). + */ +export function isMissingFilesystemError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException)?.code; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +/** + * Keep .gitnexus/ ignored. It contains local index state and caches. */ export const ensureGitNexusIgnored = async (repoPath: string): Promise => { const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore'); @@ -490,7 +700,7 @@ export const ensureGitNexusIgnored = async (repoPath: string): Promise => if (isReadOnlyFilesystemError(err)) { logger.warn( { path: gitignorePath, code: err.code }, - 'GitNexus storage filesystem is not writable; skipping .gitnexus/.gitignore. Generated files may appear as untracked in this repo locally.', + 'GitNexus storage filesystem is not writable; skipping .gitnexus/.gitignore. Cache files may appear as untracked in this repo locally.', ); } else { throw err; @@ -532,7 +742,7 @@ const ensureGitInfoExclude = async (repoPath: string): Promise => { if (isReadOnlyFilesystemError(err)) { logger.warn( { path: excludePath, code: err.code }, - 'GitNexus storage filesystem is not writable; skipping .git/info/exclude update. .gitnexus/ may appear as untracked in `git status` locally.', + 'GitNexus storage filesystem is not writable; skipping .git/info/exclude update. .gitnexus/ cache directory may appear as untracked in `git status` locally.', ); } else { throw err; @@ -962,11 +1172,11 @@ export class RegistryAmbiguousTargetError extends Error { /** * Thrown by {@link assertAnalysisFinalized} when a successful `analyze` - * run did not actually persist `meta.json` or did not register the repo - * in `~/.gitnexus/registry.json` (#1169). + * run did not actually persist the index metadata file or did not register + * the repo in `~/.gitnexus/registry.json` (#1169). * * Why this exists: on Windows, `gitnexus analyze` has been observed to - * exit cleanly (code 0) with `lbug.wal` written but no `meta.json`, + * exit cleanly (code 0) with `lbug.wal` written but no metadata file, * leaving the repo invisible to `gitnexus list`/`status` and downstream * MCP discovery. The only signal to the user was an empty banner — * which is indistinguishable from a no-op early return. This invariant @@ -985,7 +1195,7 @@ export class AnalysisNotFinalizedError extends Error { ) { const detail = missing === 'meta' - ? `meta.json was not written to ${path.join(storagePath, 'meta.json')}` + ? `${INDEX_METADATA_FILE} was not written to ${path.join(storagePath, INDEX_METADATA_FILE)}` : `registry entry for ${repoPath} was not added to ${registryPath}`; super( `Analysis did not finalize for ${repoPath}: ${detail}. ` + @@ -1013,7 +1223,9 @@ 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. `meta.json` must exist at `/.gitnexus/meta.json`. + * 1. `gitnexus.json` must exist at `/.gitnexus/gitnexus.json` + * (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`. * @@ -1181,13 +1393,13 @@ export const resolveRegistryEntry = (entries: RegistryEntry[], target: string): /** * List all registered repos from the global registry. * - * With `validate: true`, prunes only entries whose index is *provably* gone - * (fs.access on .gitnexus/meta.json fails with ENOENT or ENOTDIR) and persists - * the result. 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`, 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. 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. */ export const listRegisteredRepos = async (opts?: { validate?: boolean; @@ -1195,37 +1407,48 @@ export const listRegisteredRepos = async (opts?: { const entries = await readRegistry(); if (!opts?.validate) return entries; - // Validate each entry still has a .gitnexus/ directory + // Validate each entry still has a .gitnexus/ directory with metadata 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, 'meta.json')); - valid.push(entry); + await fs.access(path.join(entry.storagePath, INDEX_METADATA_FILE)); + indexFound = true; } catch (err: any) { - // Prune ONLY when the index is provably gone: ENOENT (file absent) or - // ENOTDIR (a path component is no longer a directory). Every other - // fs.access failure keeps the entry, because the file may well still - // exist and we must not wipe the registry on a transient I/O storm - // (EIO/EAGAIN/EBUSY under swap pressure, NFS hiccups, etc.). - // - // Note: some kept codes are NOT necessarily transient — EACCES, for - // example, can be permanent (a chmod'd directory). Keeping is still the - // correct conservative choice: a stale-but-kept entry is harmless (DB - // opens are lazily guarded) and removable via `gitnexus remove`, whereas - // an over-eager prune destroys data. When in doubt, keep. - if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') { - // 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: err?.code }, - 'Keeping registry entry despite fs.access failure (not provably absent); not pruning to avoid mass registry wipe.', - ); - valid.push(entry); + 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) { + 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.', + ); + valid.push(entry); + } } // If we pruned any entries, save the cleaned registry diff --git a/gitnexus/test/integration/antigravity-hook-e2e.test.ts b/gitnexus/test/integration/antigravity-hook-e2e.test.ts index 8cb68b000..b99005a91 100644 --- a/gitnexus/test/integration/antigravity-hook-e2e.test.ts +++ b/gitnexus/test/integration/antigravity-hook-e2e.test.ts @@ -243,6 +243,34 @@ describe('antigravity hook adapter e2e', () => { expect(output!.additionalContext).toContain('npx gitnexus@latest analyze --embeddings'); }); + it('prefers gitnexus.json over meta.json when both are present (dual-write steady state)', () => { + const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json'); + const metaJsonPath = path.join(gitNexusDir, 'meta.json'); + fs.writeFileSync(gitnexusJsonPath, JSON.stringify({ lastCommit: 'f'.repeat(40), stats: {} })); + fs.writeFileSync( + metaJsonPath, + JSON.stringify({ lastCommit: 'stale'.padEnd(40, '0'), stats: {} }), + ); + + try { + const result = runHook(installedHook, { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "test"' }, + tool_response: { llmContent: '[committed]' }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + // Reports staleness against gitnexus.json's commit — proves it's consulted first. + expect(output!.additionalContext).toContain('fffffff'); + } finally { + fs.rmSync(gitnexusJsonPath, { force: true }); + fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} })); + } + }); + it('treats missing meta.json as stale', () => { const metaPath = path.join(gitNexusDir, 'meta.json'); if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath); diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 168daab14..7f13b9ed7 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -390,11 +390,21 @@ describe('CLI end-to-end', () => { ].join('\n'), ).toBe(0); + // Both metadata filenames must exist after a successful analyze: + // gitnexus.json is the primary (what assertAnalysisFinalized checks — + // its absence is the #1169 silent-finalize symptom) and meta.json is + // the dual-written legacy mirror older consumers still read. + const primaryMetaPath = path.join(repo, '.gitnexus', 'gitnexus.json'); + expect( + fs.existsSync(primaryMetaPath), + `gitnexus.json missing at ${primaryMetaPath} after analyze exited 0 — this is the #1169 silent-finalize symptom`, + ).toBe(true); const metaPath = path.join(repo, '.gitnexus', 'meta.json'); expect( fs.existsSync(metaPath), - `meta.json missing at ${metaPath} after analyze exited 0 — this is the #1169 silent-finalize symptom`, + `legacy meta.json mirror missing at ${metaPath} after analyze exited 0 — dual-write regressed`, ).toBe(true); + expect(fs.readFileSync(primaryMetaPath, 'utf-8')).toBe(fs.readFileSync(metaPath, 'utf-8')); const registryPath = path.join(gnHome, 'registry.json'); expect( @@ -439,10 +449,11 @@ describe('CLI end-to-end', () => { const metaPath = path.join(repo, '.gitnexus', 'meta.json'); expect(fs.existsSync(metaPath)).toBe(true); + expect(fs.existsSync(path.join(repo, '.gitnexus', 'gitnexus.json'))).toBe(true); - // Simulate the half-finalized state from the review: meta.json is - // present and lastCommit matches, but the repo is not discoverable - // because the global registry entry is missing. + // Simulate the half-finalized state from the review: the metadata + // (both filenames) is present and lastCommit matches, but the repo is + // not discoverable because the global registry entry is missing. fs.writeFileSync(path.join(gnHome, 'registry.json'), '[]', 'utf-8'); const second = runCliWithEnv(['analyze'], repo, { GITNEXUS_HOME: gnHome }, 60000); diff --git a/gitnexus/test/integration/group/bridge-cache-reopen.test.ts b/gitnexus/test/integration/group/bridge-cache-reopen.test.ts index 566afad3b..b116c7da2 100644 --- a/gitnexus/test/integration/group/bridge-cache-reopen.test.ts +++ b/gitnexus/test/integration/group/bridge-cache-reopen.test.ts @@ -26,8 +26,8 @@ import { queryBridge, closeBridgeDb, closeAllCachedBridges, - retryRename, } from '../../../src/core/group/bridge-db.js'; +import { retryRename } from '../../../src/storage/fs-atomic.js'; import { cleanupTempDir } from '../../helpers/test-db.js'; // Absolute file:// URL to the tsx loader so the seed script runs under tsx in a diff --git a/gitnexus/test/integration/impact-pdg-degradation.test.ts b/gitnexus/test/integration/impact-pdg-degradation.test.ts index ee4e41cbb..9337e7ffc 100644 --- a/gitnexus/test/integration/impact-pdg-degradation.test.ts +++ b/gitnexus/test/integration/impact-pdg-degradation.test.ts @@ -32,7 +32,9 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), findSiblingClones: vi.fn().mockResolvedValue([]), // Default: meta unreadable (the seeded-DB reality — no on-disk meta.json). - // Individual tests override per state via mockResolvedValueOnce. + // Individual tests override per state via mockResolvedValue (reset in + // beforeEach; not Once — the staleness check in ensureInitialized also + // calls loadMeta and must not starve the PDG caps read of its value). loadMeta: vi.fn().mockResolvedValue(null), }; }); @@ -74,7 +76,7 @@ withTestLbugDB( }); // Reset the loadMeta mock to the default (unreadable) before each test so a - // mockResolvedValueOnce set in one test never leaks into the next. + // mockResolvedValue set in one test never leaks into the next. beforeEach(() => { vi.mocked(loadMeta).mockReset(); vi.mocked(loadMeta).mockResolvedValue(null); @@ -83,7 +85,7 @@ withTestLbugDB( describe('no-layer (meta readable, no pdg stamp)', () => { it('returns the definitive target-aware "run analyze --pdg" note', async () => { // Readable meta with no `pdg` key ⇒ the layer was never recorded. - vi.mocked(loadMeta).mockResolvedValueOnce(META(undefined)); + vi.mocked(loadMeta).mockResolvedValue(META(undefined)); const result = await backend.callTool('impact', { target: 'hot', direction: 'downstream', @@ -110,7 +112,7 @@ withTestLbugDB( describe('sub-layer-missing (exactly one cap stamped)', () => { it('CDG present, RD absent → names REACHING_DEF as missing', async () => { - vi.mocked(loadMeta).mockResolvedValueOnce(META({ maxCdgEdgesPerFunction: 0 } as any)); + vi.mocked(loadMeta).mockResolvedValue(META({ maxCdgEdgesPerFunction: 0 } as any)); const result = await backend.callTool('impact', { target: 'hot', direction: 'downstream', @@ -127,9 +129,7 @@ withTestLbugDB( }); it('RD present, CDG absent → names CDG as missing', async () => { - vi.mocked(loadMeta).mockResolvedValueOnce( - META({ maxReachingDefEdgesPerFunction: 0 } as any), - ); + vi.mocked(loadMeta).mockResolvedValue(META({ maxReachingDefEdgesPerFunction: 0 } as any)); const result = await backend.callTool('impact', { target: 'hot', direction: 'downstream', @@ -145,7 +145,7 @@ withTestLbugDB( describe('ready (both caps stamped)', () => { it('falls THROUGH the layer check to the real traversal (U3 _runImpactPDG)', async () => { - vi.mocked(loadMeta).mockResolvedValueOnce( + vi.mocked(loadMeta).mockResolvedValue( META({ maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 } as any), ); const result = await backend.callTool('impact', { @@ -177,7 +177,7 @@ withTestLbugDB( // B0 reaches B1 via the CDG edge, so calleesOfBlocks runs over real // seed+reachable blocks; with no callee data it must yield an empty set // and degrade to callgraph-equal — no throw, no partial precision. - vi.mocked(loadMeta).mockResolvedValueOnce( + vi.mocked(loadMeta).mockResolvedValue( META({ maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 } as any), ); const result = await backend.callTool('impact', { diff --git a/gitnexus/test/integration/impact-pdg-id-degradation.test.ts b/gitnexus/test/integration/impact-pdg-id-degradation.test.ts index 09ed0f230..865d54840 100644 --- a/gitnexus/test/integration/impact-pdg-id-degradation.test.ts +++ b/gitnexus/test/integration/impact-pdg-id-degradation.test.ts @@ -144,7 +144,7 @@ withTestLbugDB( }); it('Scenario 1 (R3): empty calleeIds → bridge falls back to the leaf-NAME match', async () => { - vi.mocked(loadMeta).mockResolvedValueOnce(READY_META); + vi.mocked(loadMeta).mockResolvedValue(READY_META); const result = await backend.callTool('impact', { target: 'nameCaller', direction: 'downstream', @@ -188,7 +188,7 @@ withTestLbugDB( }); it('Scenario 3 (R7): a capped-sentinel slice block stays callgraph-equal', async () => { - vi.mocked(loadMeta).mockResolvedValueOnce(READY_META); + vi.mocked(loadMeta).mockResolvedValue(READY_META); const result = await backend.callTool('impact', { target: 'cappedCaller', direction: 'downstream', diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index 00d08a09a..0da59583a 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -14,7 +14,13 @@ import { LOCAL_BACKEND_FTS_INDEXES, } from '../fixtures/local-backend-seed.js'; -vi.mock('../../src/storage/repo-manager.js', () => ({ +// Partial mock: registry access is faked, but everything else — critically +// `loadMeta`, which the staleness check in LocalBackend.ensureInitialized +// calls on every throttled window — stays REAL. A factory that omitted +// loadMeta made that call site throw a TypeError that the staleness check's +// catch silently swallowed, so the code path was never actually exercised. +vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), listRegisteredRepos: vi.fn().mockResolvedValue([]), cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), findSiblingClones: vi.fn().mockResolvedValue([]), diff --git a/gitnexus/test/integration/staleness-and-stability.test.ts b/gitnexus/test/integration/staleness-and-stability.test.ts index 308753781..c23ae054e 100644 --- a/gitnexus/test/integration/staleness-and-stability.test.ts +++ b/gitnexus/test/integration/staleness-and-stability.test.ts @@ -18,6 +18,21 @@ import { describe, it, expect, afterAll } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; import { initLbug, executeQuery, closeLbug } from '../../src/mcp/core/lbug-adapter.js'; + +// Passthrough spies on the pool adapter: real behavior, observable calls — +// the staleness tests assert a fresher metadata stamp actually triggers a +// pool reinit (closeLbug + initLbug), not merely "didn't crash". The mock +// targets core/lbug/pool-adapter.js (LocalBackend's direct import); +// mcp/core/lbug-adapter.js is a re-export shim over the same module, so the +// spies are visible through both specifiers. +vi.mock('../../src/core/lbug/pool-adapter.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + initLbug: vi.fn(actual.initLbug), + closeLbug: vi.fn(actual.closeLbug), + }; +}); import { withTestLbugDB } from '../helpers/test-indexed-db.js'; import { LOCAL_BACKEND_SEED_DATA, @@ -27,7 +42,15 @@ import { LocalBackend } from '../../src/mcp/local/local-backend.js'; import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; import { vi } from 'vitest'; -vi.mock('../../src/storage/repo-manager.js', () => ({ +// Partial mock: registry access is faked, but everything else — critically +// `loadMeta`, which the staleness check in LocalBackend.ensureInitialized +// calls on every throttled window — stays REAL, so the staleness tests +// below exercise the true read path against the fixture metadata files. +// (A factory that omitted loadMeta made that call site throw a TypeError +// that the staleness check's catch silently swallowed — the whole "detects +// stale index" block passed without ever running the detection.) +vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), listRegisteredRepos: vi.fn().mockResolvedValue([]), cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), findSiblingClones: vi.fn().mockResolvedValue([]), @@ -163,7 +186,7 @@ withTestLbugDB( expect(result.row_count).toBeGreaterThanOrEqual(3); }); - it('detects stale index when meta.json indexedAt changes', async () => { + it('detects stale index when meta.json indexedAt changes and reinits the pool', async () => { const metaPath = path.join(storagePath, 'meta.json'); await fs.writeFile( metaPath, @@ -174,15 +197,48 @@ withTestLbugDB( }), ); - // Next call triggers re-init. May fail but must NOT crash. + const initCallsBefore = vi.mocked(initLbug).mock.calls.length; + // Beat the 5s staleness throttle without freezing real timers/IO. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(Date.now() + 10_000)); try { const result = await backend.callTool('cypher', { query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', }); - expect(result).toBeDefined(); - } catch (err: any) { - expect(err.message).not.toMatch(/SIGSEGV/i); + // The pool was re-inited AND the query on the fresh pool succeeded. + expect(result).toHaveProperty('row_count'); + } finally { + vi.useRealTimers(); } + expect(vi.mocked(initLbug).mock.calls.length).toBeGreaterThan(initCallsBefore); + expect(vi.mocked(closeLbug)).toHaveBeenCalled(); + }); + + it('prefers a fresher gitnexus.json over meta.json in the staleness check', async () => { + // The primary metadata filename is consulted first; the stale + // meta.json mirror left behind must not mask the newer stamp. + await fs.writeFile( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify({ + indexedAt: new Date(Date.now() + 120_000).toISOString(), + lastCommit: 'primary-newer-commit', + stats: { files: 2, nodes: 3, communities: 1, processes: 1 }, + }), + ); + + const initCallsBefore = vi.mocked(initLbug).mock.calls.length; + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(Date.now() + 20_000)); + try { + const result = await backend.callTool('cypher', { + query: 'MATCH (n:Function) RETURN COUNT(n) AS cnt', + }); + expect(result).toHaveProperty('row_count'); + } finally { + vi.useRealTimers(); + await fs.rm(path.join(storagePath, 'gitnexus.json'), { force: true }); + } + expect(vi.mocked(initLbug).mock.calls.length).toBeGreaterThan(initCallsBefore); }); it('throttle: no re-read within 5s window', async () => { diff --git a/gitnexus/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts index e64979895..5e2666555 100644 --- a/gitnexus/test/unit/cursor-hook.test.ts +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -213,6 +213,10 @@ describe('Cursor hook source regressions', () => { expect(source).toContain('isGlobalRegistryDir'); }); + it('isGlobalRegistryDir recognizes gitnexus.json as well as legacy meta.json', () => { + expect(source).toContain('gitnexus.json'); + }); + it('handles linked git worktrees via git rev-parse --git-common-dir', () => { expect(source).toContain('--git-common-dir'); }); diff --git a/gitnexus/test/unit/group/bridge-db.test.ts b/gitnexus/test/unit/group/bridge-db.test.ts index d2a4c9a8a..5fb3308de 100644 --- a/gitnexus/test/unit/group/bridge-db.test.ts +++ b/gitnexus/test/unit/group/bridge-db.test.ts @@ -9,7 +9,6 @@ import { queryBridge, closeBridgeDb, contractNodeId, - retryRename, writeBridge, openBridgeDbReadOnly, readBridgeMeta, @@ -18,6 +17,7 @@ import { indexContract, findContractNode, } from '../../../src/core/group/bridge-db.js'; +import { retryRename } from '../../../src/storage/fs-atomic.js'; import type { BridgeHandle, CrossLink } from '../../../src/core/group/types.js'; import { makeContract } from './fixtures.js'; diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index b3bc3db97..afc4c2f3d 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -2761,6 +2761,118 @@ describe('PostToolUse staleness detection (integration)', () => { } }); +// ─── Integration: PostToolUse staleness detection with gitnexus.json ──── +// (the current primary metadata filename; meta.json is a dual-written +// compatibility mirror — see repo-manager.ts's saveMeta/loadMeta) + +describe('PostToolUse staleness detection with gitnexus.json (integration)', () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: emits stale notification when HEAD differs from gitnexus.json`, () => { + const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json'); + const metaJsonPath = path.join(gitNexusDir, 'meta.json'); + fs.rmSync(metaJsonPath, { force: true }); + fs.writeFileSync( + gitnexusJsonPath, + JSON.stringify({ lastCommit: 'aaaaaaa0000000000000000000000000deadbeef', stats: {} }), + ); + + try { + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('stale'); + expect(output!.additionalContext).toContain('aaaaaaa'); + } finally { + fs.rmSync(gitnexusJsonPath, { force: true }); + fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} })); + } + }); + + it(`${label}: silent when HEAD matches gitnexus.json lastCommit`, () => { + const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json'); + const metaJsonPath = path.join(gitNexusDir, 'meta.json'); + const head = getHeadCommit(); + fs.rmSync(metaJsonPath, { force: true }); + fs.writeFileSync(gitnexusJsonPath, JSON.stringify({ lastCommit: head, stats: {} })); + + try { + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + expect(result.stdout.trim()).toBe(''); + } finally { + fs.rmSync(gitnexusJsonPath, { force: true }); + fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} })); + } + }); + + it(`${label}: prefers gitnexus.json over meta.json when both are present (dual-write steady state)`, () => { + const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json'); + const metaJsonPath = path.join(gitNexusDir, 'meta.json'); + fs.writeFileSync(gitnexusJsonPath, JSON.stringify({ lastCommit: 'freshcommit', stats: {} })); + fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'stalecommit', stats: {} })); + + try { + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + // Reports staleness against gitnexus.json's commit, not meta.json's — + // proves gitnexus.json is consulted first. + expect(output!.additionalContext).toContain('freshco'); + } finally { + fs.rmSync(gitnexusJsonPath, { force: true }); + fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} })); + } + }); + + it(`${label}: falls back to meta.json when gitnexus.json is corrupt`, () => { + const gitnexusJsonPath = path.join(gitNexusDir, 'gitnexus.json'); + const metaJsonPath = path.join(gitNexusDir, 'meta.json'); + const head = getHeadCommit(); + fs.writeFileSync(gitnexusJsonPath, 'not valid json!!!'); + fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: head, stats: {} })); + + try { + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }); + + // meta.json's lastCommit matches HEAD, so a correct fallback stays silent. + expect(result.stdout.trim()).toBe(''); + } finally { + fs.rmSync(gitnexusJsonPath, { force: true }); + fs.writeFileSync(metaJsonPath, JSON.stringify({ lastCommit: 'old', stats: {} })); + } + }); + } +}); + // ─── Integration: cwd validation rejects relative paths ───────────── describe('cwd validation (integration)', () => { @@ -3082,3 +3194,40 @@ 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. + +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], + ] as const) { + it(`${label}: source references gitnexus.json, not only meta.json`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('gitnexus.json'); + }); + } +}); diff --git a/gitnexus/test/unit/index-repo-command.test.ts b/gitnexus/test/unit/index-repo-command.test.ts index 8e1994063..a3a32dd23 100644 --- a/gitnexus/test/unit/index-repo-command.test.ts +++ b/gitnexus/test/unit/index-repo-command.test.ts @@ -17,6 +17,7 @@ vi.mock('fs/promises', () => ({ vi.mock('../../src/storage/repo-manager.js', () => ({ getStoragePaths: mockGetStoragePaths, + INDEX_METADATA_FILE: 'gitnexus.json', loadMeta: mockLoadMeta, registerRepo: mockRegisterRepo, ensureGitNexusIgnored: mockEnsureGitNexusIgnored, @@ -44,7 +45,7 @@ describe('indexCommand', () => { mockGetStoragePaths.mockImplementation((repoPath: string) => ({ storagePath: `${repoPath}/.gitnexus`, lbugPath: `${repoPath}/.gitnexus/lbug`, - metaPath: `${repoPath}/.gitnexus/meta.json`, + metaPath: `${repoPath}/.gitnexus/gitnexus.json`, })); mockLoadMeta.mockResolvedValue({ repoPath: resolvedRepo, @@ -70,9 +71,12 @@ describe('indexCommand', () => { expect(logSpy).toHaveBeenCalledWith(` Not a git repository: ${resolvedOutside}`); }); - it('fails when .gitnexus folder does not exist', async () => { + it('fails when no metadata or LadybugDB index exists', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mockAccess.mockRejectedValueOnce(new Error('missing .gitnexus')); + mockAccess.mockImplementation(async (targetPath: string) => { + if (targetPath.includes('/.gitnexus/')) throw new Error(`missing ${targetPath}`); + return undefined; + }); const { indexCommand } = await import('../../src/cli/index-repo.js'); await indexCommand(['/repo']); @@ -80,22 +84,23 @@ describe('indexCommand', () => { expect(mockRegisterRepo).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(logSpy).toHaveBeenCalledWith( - ` No .gitnexus/ folder found at: ${resolvedRepo}/.gitnexus`, + ` Expected gitnexus.json, .gitnexus/meta.json, or LadybugDB at: ${resolvedRepo}/.gitnexus`, ); }); it('fails when lbug database does not exist', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mockAccess.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('missing lbug')); + mockAccess.mockImplementation(async (targetPath: string) => { + if (targetPath === `${resolvedRepo}/.gitnexus/lbug`) throw new Error('missing lbug'); + return undefined; + }); const { indexCommand } = await import('../../src/cli/index-repo.js'); await indexCommand(['/repo']); expect(mockRegisterRepo).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); - expect(logSpy).toHaveBeenCalledWith( - ' .gitnexus/ folder exists but contains no LadybugDB index.', - ); + expect(logSpy).toHaveBeenCalledWith(' Index exists but contains no LadybugDB database.'); }); it('fails when meta.json is missing and --force is not set', async () => { @@ -125,6 +130,43 @@ describe('indexCommand', () => { expect(process.exitCode).toBeUndefined(); }); + it('registers with --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; + }); + + const { indexCommand } = await import('../../src/cli/index-repo.js'); + await indexCommand(['/repo'], { force: true }); + + expect(mockRegisterRepo).toHaveBeenCalledTimes(1); + expect(mockRegisterRepo).toHaveBeenCalledWith( + resolvedRepo, + expect.objectContaining({ + repoPath: resolvedRepo, + lastCommit: '', + }), + ); + 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; + }); + + const { indexCommand } = await import('../../src/cli/index-repo.js'); + await indexCommand(['/repo']); + + expect(mockRegisterRepo).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + it('registers successfully with existing metadata', async () => { const { indexCommand } = await import('../../src/cli/index-repo.js'); await indexCommand(['/repo']); diff --git a/gitnexus/test/unit/remove-command.test.ts b/gitnexus/test/unit/remove-command.test.ts new file mode 100644 index 000000000..a30027c83 --- /dev/null +++ b/gitnexus/test/unit/remove-command.test.ts @@ -0,0 +1,88 @@ +/** + * Unit tests: removeCommand deletion order (PR #2363 review fix, F14) + * + * The documented contract (remove.ts header): fs.rm FIRST, then unregister. + * A partial failure leaves the registry entry in place so the user can + * retry (and `listRegisteredRepos({ validate: true })` self-heals a + * rm-succeeded/unregister-failed orphan) — the registry must never be + * unregistered while index files may still remain on disk. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import path from 'node:path'; + +const mockRm = vi.fn(); +const mockReadRegistry = vi.fn(); +const mockResolveRegistryEntry = vi.fn(); +const mockAssertSafeStoragePath = vi.fn(); +const mockUnregisterRepo = vi.fn(); + +vi.mock('fs/promises', () => ({ + default: { + rm: mockRm, + }, +})); + +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 {}, +})); + +describe('removeCommand', () => { + const repoPath = path.resolve('/repo'); + const entry = { + name: 'repo', + path: repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + process.exitCode = undefined; + + mockReadRegistry.mockResolvedValue([entry]); + mockResolveRegistryEntry.mockReturnValue(entry); + mockAssertSafeStoragePath.mockReturnValue(undefined); + mockRm.mockResolvedValue(undefined); + mockUnregisterRepo.mockResolvedValue(undefined); + }); + + it('removes the whole .gitnexus/ directory recursively, then unregisters', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const { removeCommand } = await import('../../src/cli/remove.js'); + await removeCommand('repo', { force: true }); + + expect(mockRm).toHaveBeenCalledWith(entry.storagePath, { recursive: true, force: true }); + expect(mockUnregisterRepo).toHaveBeenCalledWith(entry.path); + // rm strictly precedes unregister (retryable partial-failure contract). + expect(mockRm.mock.invocationCallOrder[0]).toBeLessThan( + mockUnregisterRepo.mock.invocationCallOrder[0], + ); + // No pre-unlink of individual metadata files — fs.rm removes both + // gitnexus.json and the legacy meta.json mirror with the directory. + expect(mockRm).toHaveBeenCalledTimes(1); + }); + + it('does NOT unregister when fs.rm fails (entry stays for retry)', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as unknown as typeof process.exit); + const err = new Error('EBUSY: resource busy') as NodeJS.ErrnoException; + err.code = 'EBUSY'; + mockRm.mockRejectedValue(err); + + const { removeCommand } = await import('../../src/cli/remove.js'); + await removeCommand('repo', { force: true }); + + expect(mockUnregisterRepo).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/gitnexus/test/unit/repo-manager-finalize-invariant.test.ts b/gitnexus/test/unit/repo-manager-finalize-invariant.test.ts index d0e217365..9bdbf4813 100644 --- a/gitnexus/test/unit/repo-manager-finalize-invariant.test.ts +++ b/gitnexus/test/unit/repo-manager-finalize-invariant.test.ts @@ -22,6 +22,7 @@ import { registerRepo, saveMeta, getStoragePaths, + INDEX_METADATA_FILE, type RepoMeta, } from '../../src/storage/repo-manager.js'; import { createTempDir } from '../helpers/test-db.js'; @@ -52,10 +53,10 @@ describe('assertAnalysisFinalized (#1169)', () => { await tmpRepo.cleanup(); }); - it('throws missing="meta" when .gitnexus/meta.json was never written (the #1169 symptom)', async () => { + it('throws missing="meta" when .gitnexus/gitnexus.json was never written (the #1169 symptom)', async () => { // Reproduce the exact disk shape from the user's repro: lbug.wal - // present, meta.json absent. analyze must report this as a hard - // failure, not silently return success. + // present, the metadata file absent. analyze must report this as a + // hard failure, not silently return success. const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); await fs.mkdir(storagePath, { recursive: true }); await fs.writeFile(`${lbugPath}.wal`, 'simulated uncommitted WAL data'); @@ -73,10 +74,11 @@ describe('assertAnalysisFinalized (#1169)', () => { expect(err.kind).toBe('AnalysisNotFinalizedError'); expect(err.repoPath).toBe(path.resolve(tmpRepo.dbPath)); expect(err.storagePath).toBe(storagePath); - // Diagnostic message names the missing artifact and the storage + // Diagnostic message names the missing artifact (the PRIMARY + // metadata filename the check actually probes) and the storage // path the user must inspect — required to clear DoD §2.8 // (errors must be actionable). - expect(err.message).toContain('meta.json'); + expect(err.message).toContain(INDEX_METADATA_FILE); expect(err.message).toContain(storagePath); expect(err.message).toContain('lbug.wal'); } diff --git a/gitnexus/test/unit/repo-manager-reconcile.test.ts b/gitnexus/test/unit/repo-manager-reconcile.test.ts new file mode 100644 index 000000000..9e4da5c6c --- /dev/null +++ b/gitnexus/test/unit/repo-manager-reconcile.test.ts @@ -0,0 +1,292 @@ +/** + * Unit tests: reconcileMetadataFiles (PR #2363 review fix, F6) + * + * 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 + * - loadMeta prefers gitnexus.json, falls back to the mirror only when the + * primary is provably absent (ENOENT/ENOTDIR) + * + * Uses real tmp dirs (house style — see repo-manager.test.ts); the final + * describe drives a mocked-pipeline runFullAnalysis to prove the analyze + * entry point leaves a pre-rename (legacy-only) repo with both files. + */ +import fs from 'fs/promises'; +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { _captureLogger } from '../../src/core/logger.js'; +import { + getStoragePaths, + saveMeta, + loadMeta, + reconcileMetadataFiles, + type RepoMeta, +} from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const metaAt = (indexedAt: string, lastCommit: string, extra?: Partial): RepoMeta => ({ + repoPath: '/some/repo', + lastCommit, + indexedAt, + ...extra, +}); + +const readJson = async (dir: string, filename: string): Promise => + JSON.parse(await fs.readFile(path.join(dir, filename), 'utf-8')) as RepoMeta; + +describe('reconcileMetadataFiles', () => { + let tmpRepo: Awaited>; + let storagePath: string; + + beforeEach(async () => { + tmpRepo = await createTempDir('gitnexus-reconcile-suite-'); + storagePath = getStoragePaths(tmpRepo.dbPath).storagePath; + await fs.mkdir(storagePath, { recursive: true }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + }); + + it('flat round-trip: legacy-only dir gains an identical gitnexus.json; meta.json is untouched', async () => { + const legacy = metaAt('2026-06-01T00:00:00.000Z', 'legacy-commit', { + fileHashes: { 'src/a.ts': 'hash-a' }, + }); + const legacyRaw = JSON.stringify(legacy); + await fs.writeFile(path.join(storagePath, 'meta.json'), legacyRaw); + + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); + + await expect(readJson(storagePath, 'gitnexus.json')).resolves.toEqual(legacy); + await expect(readJson(storagePath, 'meta.json')).resolves.toEqual(legacy); + }); + + it('primary-only dir gets its meta.json mirror re-established', async () => { + const primary = metaAt('2026-06-01T00:00:00.000Z', 'primary-commit'); + await fs.writeFile(path.join(storagePath, 'gitnexus.json'), JSON.stringify(primary)); + + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); + + await expect(readJson(storagePath, 'meta.json')).resolves.toEqual(primary); + }); + + it('preserves the incrementalInProgress crash-recovery flag through a bootstrap', async () => { + // The dirty flag travels through this file; a reconciliation that + // reconstructed a trimmed object instead of carrying fields verbatim + // would silently drop it and skip the recovery full-rebuild. + const dirty = metaAt('2026-06-01T00:00:00.000Z', 'crashed-run', { + incrementalInProgress: true, + } as Partial); + await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(dirty)); + + await reconcileMetadataFiles(tmpRepo.dbPath); + + const primary = await readJson(storagePath, 'gitnexus.json'); + expect(primary).toMatchObject({ incrementalInProgress: true, lastCommit: 'crashed-run' }); + }); + + it('mixed branch states converge in one call (legacy-only / converged / stale-primary)', 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]) { + await fs.mkdir(dir, { recursive: true }); + } + + await fs.writeFile( + path.join(legacyOnly, 'meta.json'), + JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'lo-commit')), + ); + + const convergedMeta = metaAt('2026-06-01T00:00:00.000Z', 'cv-commit'); + 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')), + ); + await fs.writeFile( + path.join(stalePrimary, 'meta.json'), + JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'sp-fresh')), + ); + + // Flat slot: nothing — stays empty and untouched. + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); + + await expect(readJson(legacyOnly, 'gitnexus.json')).resolves.toMatchObject({ + 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(stalePrimary, 'meta.json')).resolves.toMatchObject({ + lastCommit: 'sp-fresh', + }); + // Flat slot stayed empty (reconcile fabricates nothing). + await expect(fs.access(path.join(storagePath, 'gitnexus.json'))).rejects.toThrow(); + }); + + it('second call after convergence is a no-op with identical file content', async () => { + 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(true); + const primaryAfterFirst = await fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8'); + const legacyAfterFirst = await fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8'); + + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(false); + await expect(fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8')).resolves.toBe( + primaryAfterFirst, + ); + await expect(fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8')).resolves.toBe( + legacyAfterFirst, + ); + }); + + it('both files corrupt: no throw, no fabricated content, a warning per corrupt file', async () => { + await fs.writeFile(path.join(storagePath, 'gitnexus.json'), '{ nope'); + await fs.writeFile(path.join(storagePath, 'meta.json'), 'also nope {{{'); + + const cap = _captureLogger(); + try { + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(false); + } finally { + cap.restore(); + } + + // Corrupt bytes left exactly as they were (next successful saveMeta heals). + await expect(fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8')).resolves.toBe( + '{ nope', + ); + 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); + }); + + it('fresh directory (neither file) is a silent no-op', async () => { + const cap = _captureLogger(); + try { + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(false); + } finally { + cap.restore(); + } + expect(cap.records().filter((r) => r.level === 40)).toEqual([]); + }); + + it('a mirror-write failure during reconciliation does not throw (best-effort semantics)', async () => { + await fs.writeFile( + path.join(storagePath, 'meta.json'), + JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'legacy-commit')), + ); + + // Fail only the legacy-mirror write inside saveMeta's dual-write. + const realOpen = fs.open; + vi.spyOn(fs, 'open').mockImplementation( + async (filePath: Parameters[0], ...rest) => { + if (String(filePath).includes(`${path.sep}meta.json.tmp.`)) { + const err = new Error('simulated mirror-write failure') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return realOpen(filePath, ...rest); + }, + ); + + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); + // Primary was bootstrapped; the pre-existing legacy file is still intact. + await expect(readJson(storagePath, 'gitnexus.json')).resolves.toMatchObject({ + lastCommit: 'legacy-commit', + }); + await expect(readJson(storagePath, 'meta.json')).resolves.toMatchObject({ + lastCommit: 'legacy-commit', + }); + }); + + it('loadMeta sees the reconciled state (bootstrap then read round-trip)', async () => { + const legacy = metaAt('2026-06-01T00:00:00.000Z', 'roundtrip-commit'); + await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(legacy)); + + await reconcileMetadataFiles(tmpRepo.dbPath); + + await expect(loadMeta(storagePath)).resolves.toEqual(legacy); + }); +}); + +// ─── analyze entry point: a pre-rename repo ends with both files ───────── + +describe('runFullAnalysis metadata reconciliation (mocked pipeline)', () => { + afterEach(() => { + vi.doUnmock('../../src/core/lbug/lbug-adapter.js'); + vi.doUnmock('../../src/core/search/fts-indexes.js'); + vi.doUnmock('../../src/core/ingestion/pipeline.js'); + vi.doUnmock('../../src/storage/repo-manager.js'); + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('analyze on a legacy-only (pre-rename) repo ends with both metadata files in sync', async () => { + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + initLbug: vi.fn(async () => undefined), + loadGraphToLbug: vi.fn(async () => undefined), + getLbugStats: vi.fn(async () => ({ nodes: 1, edges: 0, communities: 0, processes: 0 })), + executeQuery: vi.fn(async () => []), + executeWithReusedStatement: vi.fn(async () => []), + closeLbug: vi.fn(async () => undefined), + loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })), + deleteNodesForFile: vi.fn(async () => undefined), + deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined), + queryImporters: vi.fn(async () => []), + loadFTSExtension: vi.fn(async () => false), + })); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => undefined), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + totalFileCount: 1, + graph: { forEachNode: () => undefined }, + })), + })); + // Avoid touching the global registry / repo .gitnexusignore from a unit test. + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + registerRepo: vi.fn(async () => 'reconcile-e2e-repo'), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + + const tmpRepo = await createTempDir('gitnexus-reconcile-analyze-e2e-'); + try { + // Pre-rename repo: ONLY the legacy filename exists before analyze. + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + 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')), + ); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(tmpRepo.dbPath, { force: true }, { onProgress: () => {} }); + + const primary = await readJson(storagePath, 'gitnexus.json'); + const legacy = await readJson(storagePath, 'meta.json'); + expect(primary).toEqual(legacy); + // The final saveMeta of THIS run wrote both (not just the reconciled + // pre-analyze stamp): lastCommit was re-stamped by the analyze. + expect(primary.lastCommit).not.toBe('pre-rename-commit'); + } finally { + await tmpRepo.cleanup(); + } + }); +}); diff --git a/gitnexus/test/unit/repo-manager-transient-error.test.ts b/gitnexus/test/unit/repo-manager-transient-error.test.ts index 57e12780f..2e04df550 100644 --- a/gitnexus/test/unit/repo-manager-transient-error.test.ts +++ b/gitnexus/test/unit/repo-manager-transient-error.test.ts @@ -183,6 +183,84 @@ describe('listRegisteredRepos({ validate: true }) — transient error safety (PR expect(await readRegistryFromDisk()).toHaveLength(1); }); + it.each(['EACCES', 'EIO', 'EBUSY'])( + '%s from gitnexus.json keeps the entry even when legacy meta.json is ENOENT', + async (newMetadataCode) => { + await registerRepo(tmpRepo.dbPath, mockMeta); + const before = await listRegisteredRepos(); + expect(before).toHaveLength(1); + + const newMetadataPath = path.join(tmpRepo.dbPath, '.gitnexus', 'gitnexus.json'); + const legacyMetadataPath = path.join(tmpRepo.dbPath, '.gitnexus', 'meta.json'); + + const originalAccess = fs.access; + vi.spyOn(fs, 'access').mockImplementation(async (p, mode) => { + const pStr = typeof p === 'string' ? p : p.toString(); + + if (pStr === newMetadataPath) { + const err = new Error(newMetadataCode) as NodeJS.ErrnoException; + err.code = newMetadataCode; + throw err; + } + + if (pStr === legacyMetadataPath) { + const err = new Error('no such file') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + + return (originalAccess as any).call(fs, p, mode); + }); + + const after = await listRegisteredRepos({ validate: true }); + expect(after).toHaveLength(1); + expect(after[0].name).toBe(before[0].name); + + const onDisk = await readRegistryFromDisk(); + expect(onDisk).toHaveLength(1); + expect(onDisk[0].name).toBe(before[0].name); + }, + ); + + it.each(['EACCES', 'EIO', 'EBUSY'])( + '%s from legacy meta.json keeps the entry when gitnexus.json is ENOENT', + async (legacyMetadataCode) => { + await registerRepo(tmpRepo.dbPath, mockMeta); + const before = await listRegisteredRepos(); + expect(before).toHaveLength(1); + + const newMetadataPath = path.join(tmpRepo.dbPath, '.gitnexus', 'gitnexus.json'); + const legacyMetadataPath = path.join(tmpRepo.dbPath, '.gitnexus', 'meta.json'); + + const originalAccess = fs.access; + vi.spyOn(fs, 'access').mockImplementation(async (p, mode) => { + const pStr = typeof p === 'string' ? p : p.toString(); + + if (pStr === newMetadataPath) { + const err = new Error('no such file') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + + if (pStr === legacyMetadataPath) { + const err = new Error(legacyMetadataCode) as NodeJS.ErrnoException; + err.code = legacyMetadataCode; + throw err; + } + + return (originalAccess as any).call(fs, p, mode); + }); + + const after = await listRegisteredRepos({ validate: true }); + expect(after).toHaveLength(1); + expect(after[0].name).toBe(before[0].name); + + const onDisk = await readRegistryFromDisk(); + expect(onDisk).toHaveLength(1); + expect(onDisk[0].name).toBe(before[0].name); + }, + ); + it('mixed batch persists only the survivor (ENOENT pruned, EIO kept)', async () => { // Two registered repos: one whose index is genuinely gone (ENOENT) and one // that hits a transient I/O error (EIO) in the SAME validation call. This is diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index fc7f1950e..4238738aa 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -15,6 +15,10 @@ import { branchSlug, resolveBranchPlacement, saveMeta, + loadMeta, + reconcileMetadataFiles, + AnalysisNotFinalizedError, + INDEX_METADATA_FILE, ensureGitNexusIgnored, readRegistry, loadCLIConfig, @@ -58,7 +62,7 @@ describe('getStoragePaths', () => { const paths = getStoragePaths('/home/user/project'); expect(paths.storagePath).toContain('.gitnexus'); expect(paths.lbugPath).toContain('lbug'); - expect(paths.metaPath).toContain('meta.json'); + expect(paths.metaPath).toContain('gitnexus.json'); }); it('all paths are under storagePath', () => { @@ -88,7 +92,7 @@ describe('getStoragePaths', () => { expect(path.dirname(branched.lbugPath)).toBe(expectedDir); expect(path.dirname(branched.metaPath)).toBe(expectedDir); expect(path.basename(branched.lbugPath)).toBe('lbug'); - expect(path.basename(branched.metaPath)).toBe('meta.json'); + expect(path.basename(branched.metaPath)).toBe('gitnexus.json'); }); }); @@ -188,6 +192,303 @@ describe('resolveBranchPlacement (#2106)', () => { }); }); +// ─── saveMeta: dual-write + collision-safe tmp (review fix, F2/F8) ────── + +describe('saveMeta dual-write', () => { + let tmpRepo: Awaited>; + + beforeEach(async () => { + tmpRepo = await createTempDir('gitnexus-savemeta-dualwrite-'); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + }); + + const meta: RepoMeta = { + repoPath: '/some/repo', + lastCommit: 'abc123', + indexedAt: new Date(0).toISOString(), + }; + + it('writes identical content to gitnexus.json and legacy meta.json', async () => { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, meta); + + const primary = await fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8'); + const legacy = await fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8'); + expect(JSON.parse(primary)).toEqual(meta); + expect(JSON.parse(legacy)).toEqual(meta); + }); + + it('leaves no stray tmp files behind after a successful write', async () => { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, meta); + + const entries = await fs.readdir(storagePath); + expect(entries.filter((f) => f.includes('.tmp.'))).toEqual([]); + }); + + it('two concurrent saveMeta calls on the same directory both succeed (no tmp-name collision)', async () => { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + + const results = await Promise.allSettled([ + saveMeta(storagePath, { ...meta, lastCommit: 'writerA' }), + saveMeta(storagePath, { ...meta, lastCommit: 'writerB' }), + ]); + + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']); + }); + + it('a legacy meta.json write failure is logged and does not fail the caller', async () => { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const realOpen = fs.open; + // Fail only the write whose tmp path is for the legacy file. + vi.spyOn(fs, 'open').mockImplementation( + async (filePath: Parameters[0], ...rest) => { + if (String(filePath).includes(`${path.sep}meta.json.tmp.`)) { + const err = new Error('simulated legacy-write failure') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return realOpen(filePath, ...rest); + }, + ); + + const cap = _captureLogger(); + try { + await expect(saveMeta(storagePath, meta)).resolves.not.toThrow(); + + const primary = await fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8'); + expect(JSON.parse(primary)).toEqual(meta); + await expect(fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8')).rejects.toThrow(); + + expect( + cap + .records() + .some((r) => r.level === 40 && String(r.msg ?? '').includes('legacy meta.json mirror')), + ).toBe(true); + } finally { + cap.restore(); + } + }); +}); + +// ─── AnalysisNotFinalizedError message names the checked file (F10) ───── + +describe('AnalysisNotFinalizedError diagnostic', () => { + it("the 'meta' variant names the file assertAnalysisFinalized actually checks", () => { + const err = new AnalysisNotFinalizedError( + '/repo', + '/repo/.gitnexus', + 'meta', + '/home/user/.gitnexus/registry.json', + ); + // Built from INDEX_METADATA_FILE so a future rename can't silently desync + // the diagnostic from the check again (#1169 misdirection regression). + expect(err.message).toContain(INDEX_METADATA_FILE); + expect(err.message).toContain(path.join('/repo/.gitnexus', INDEX_METADATA_FILE)); + }); +}); + +// ─── loadMeta: strict legacy fallback (review fix, F4) ────────────────── + +describe('loadMeta strict fallback', () => { + let tmpRepo: Awaited>; + let storagePath: string; + + beforeEach(async () => { + tmpRepo = await createTempDir('gitnexus-loadmeta-fallback-'); + storagePath = getStoragePaths(tmpRepo.dbPath).storagePath; + await fs.mkdir(storagePath, { recursive: true }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + }); + + const meta: RepoMeta = { + repoPath: '/some/repo', + lastCommit: 'abc123', + indexedAt: new Date(0).toISOString(), + }; + + it('reads gitnexus.json directly when present', async () => { + await fs.writeFile(path.join(storagePath, 'gitnexus.json'), JSON.stringify(meta)); + await expect(loadMeta(storagePath)).resolves.toEqual(meta); + }); + + it('falls back to legacy meta.json when gitnexus.json is absent (ENOENT)', async () => { + await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(meta)); + await expect(loadMeta(storagePath)).resolves.toEqual(meta); + }); + + it('returns null (NOT legacy content) when gitnexus.json is corrupt', async () => { + // Pre-fix behavior silently resurrected the stale legacy baseline here, + // masking the corruption; post-fix a corrupt primary forces the same safe + // full-rebuild path a missing index would. + await fs.writeFile(path.join(storagePath, 'gitnexus.json'), '{ not valid json'); + await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(meta)); + await expect(loadMeta(storagePath)).resolves.toBeNull(); + }); + + it('returns null (NOT legacy content) when gitnexus.json read fails with EACCES', async () => { + await fs.writeFile(path.join(storagePath, 'gitnexus.json'), JSON.stringify(meta)); + await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(meta)); + + const realReadFile = fs.readFile; + vi.spyOn(fs, 'readFile').mockImplementation(async (...args: Parameters) => { + if (String(args[0]).endsWith('gitnexus.json')) { + const err = new Error('permission denied') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return realReadFile(...args); + }); + + await expect(loadMeta(storagePath)).resolves.toBeNull(); + }); + + it('returns null when neither file exists', async () => { + await expect(loadMeta(storagePath)).resolves.toBeNull(); + }); +}); + +// ─── reconcileMetadataFiles: stale-shadow regression (review fix, F3) ─── + +describe('reconcileMetadataFiles stale-shadow regression', () => { + let tmpRepo: Awaited>; + let storagePath: string; + + beforeEach(async () => { + tmpRepo = await createTempDir('gitnexus-reconcile-shadow-'); + storagePath = getStoragePaths(tmpRepo.dbPath).storagePath; + await fs.mkdir(storagePath, { recursive: true }); + }); + + afterEach(async () => { + await tmpRepo.cleanup(); + }); + + const metaAt = (indexedAt: string, lastCommit: string): RepoMeta => ({ + repoPath: '/some/repo', + lastCommit, + 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). + await fs.writeFile( + path.join(storagePath, 'gitnexus.json'), + JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'stale-commit')), + ); + await fs.writeFile( + path.join(storagePath, 'meta.json'), + JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'fresh-commit')), + ); + + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); + + const primary = JSON.parse( + await fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8'), + ) as RepoMeta; + 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'); + }); + + it('bootstraps gitnexus.json from a legacy-only directory (pre-rename repo)', async () => { + 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(true); + + const primary = JSON.parse( + await fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8'), + ) as RepoMeta; + expect(primary.lastCommit).toBe('legacy-commit'); + // Legacy file is NOT deleted — it stays as the in-sync mirror. + await expect(fs.access(path.join(storagePath, 'meta.json'))).resolves.toBeUndefined(); + }); + + it('is idempotent — a second run with no intervening writes is a no-op', async () => { + 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(true); + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(false); + }); + + it('one bad branch dir does not abort reconciliation for sibling branches (F9)', async () => { + const branchesDir = path.join(storagePath, 'branches'); + const goodA = path.join(branchesDir, 'feat-a'); + const goodB = path.join(branchesDir, 'feat-b'); + await fs.mkdir(goodA, { recursive: true }); + await fs.mkdir(goodB, { recursive: true }); + await fs.writeFile( + path.join(goodA, 'meta.json'), + JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'branch-a')), + ); + await fs.writeFile( + path.join(goodB, 'meta.json'), + JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'branch-b')), + ); + // A dangling symlink sorts between the two healthy dirs ('feat-a' < + // 'feat-ax' < 'feat-b'), so pre-fix it would starve feat-b every run. + await fs.symlink( + path.join(tmpRepo.dbPath, 'does-not-exist'), + path.join(branchesDir, 'feat-ax'), + ); + + const cap = _captureLogger(); + try { + await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(true); + } finally { + cap.restore(); + } + + // Both healthy branches were bootstrapped despite the bad sibling… + await expect(fs.access(path.join(goodA, 'gitnexus.json'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(goodB, 'gitnexus.json'))).resolves.toBeUndefined(); + // …and the skip is observable, naming the offending branch dir. + expect( + cap + .records() + .some( + (r) => + r.level === 40 && + r.branchDir === 'feat-ax' && + String(r.msg ?? '').includes('Skipping branch directory'), + ), + ).toBe(true); + }); + + it('stays silent when branches/ does not exist (not a multi-branch repo)', async () => { + await fs.writeFile( + path.join(storagePath, 'meta.json'), + JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'flat-only')), + ); + + const cap = _captureLogger(); + try { + await reconcileMetadataFiles(tmpRepo.dbPath); + } finally { + cap.restore(); + } + expect(cap.records().filter((r) => r.level === 40)).toEqual([]); + }); +}); + // ─── GitNexus ignore rules (#1233) ───────────────────────────────────── describe('ensureGitNexusIgnored (#1233)', () => { diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index 415ff58b2..ce4493308 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -414,10 +414,13 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => { expect(verifySearchFTSIndexes).not.toHaveBeenCalled(); expect(logs.join('\n')).toMatch(/FTS extension unavailable; skipping search-index creation/i); - // The degraded state is persisted so meta.json / doctor stay honest. + // The degraded state is persisted so the metadata / doctor stay honest — + // in BOTH filenames (gitnexus.json primary + dual-written meta.json mirror). const { storagePath } = getStoragePaths(tmpRepo.dbPath); const meta = JSON.parse(await fs.readFile(`${storagePath}/meta.json`, 'utf-8')); expect(meta.capabilities.fts.status).toBe('unavailable'); + const primaryMeta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8')); + expect(primaryMeta.capabilities.fts.status).toBe('unavailable'); } finally { await tmpRepo.cleanup(); } diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index 08112c4ef..8f8703f8a 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -140,9 +140,9 @@ describe('run-analyze module', () => { }); describe('collectBranchCacheKeys (#2106 R6)', () => { - const writeMeta = async (dir: string, cacheKeys: unknown) => { + const writeMeta = async (dir: string, cacheKeys: unknown, filename = 'gitnexus.json') => { await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify({ cacheKeys })); + await fs.writeFile(path.join(dir, filename), JSON.stringify({ cacheKeys })); }; it('collects sibling branch keys, excluding the current run dir', async () => { @@ -188,7 +188,7 @@ describe('collectBranchCacheKeys (#2106 R6)', () => { await writeMeta(storagePath, ['a']); const branchDir = path.join(storagePath, 'branches', 'feat'); await fs.mkdir(branchDir, { recursive: true }); - await fs.writeFile(path.join(branchDir, 'meta.json'), '{ not valid json'); + await fs.writeFile(path.join(branchDir, 'gitnexus.json'), '{ not valid json'); const { collectBranchCacheKeys } = await import('../../src/core/run-analyze.js'); const r = await collectBranchCacheKeys(storagePath, storagePath); expect(r.complete).toBe(false); @@ -196,6 +196,21 @@ describe('collectBranchCacheKeys (#2106 R6)', () => { await tmp.cleanup(); } }); + + it('falls back to legacy meta.json sibling keys during migration', async () => { + const tmp = await createTempDir('gnx-cachekeys-legacy-'); + try { + const storagePath = path.join(tmp.dbPath, '.gitnexus'); + await writeMeta(storagePath, ['a']); + await writeMeta(path.join(storagePath, 'branches', 'legacy'), ['legacy'], 'meta.json'); + const { collectBranchCacheKeys } = await import('../../src/core/run-analyze.js'); + const r = await collectBranchCacheKeys(storagePath, storagePath); + expect([...r.keys]).toEqual(['legacy']); + expect(r.complete).toBe(true); + } finally { + await tmp.cleanup(); + } + }); }); describe('primaryInversionWarning (#2106 R8)', () => {