mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(storage): rename index metadata to gitnexus.json with dual-write compatibility (#2363)
This commit is contained in:
parent
6e42040070
commit
d546fa3cce
39 changed files with 1667 additions and 202 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
41
MIGRATION.md
41
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/<slug>/meta.json` to `branches/<slug>/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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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()`).
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
// 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<void> {
|
||||
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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -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<Pick<RepoMeta, 'lastCommit' | 'indexedAt'>> =
|
||||
(await loadMeta(repoObj.storagePath)) ?? {};
|
||||
|
||||
const staleness = meta.lastCommit
|
||||
? checkStaleness(repoObj.repoPath, meta.lastCommit)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<slug>/` 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 ──
|
||||
|
|
|
|||
|
|
@ -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 `<storagePath>/meta.json` (unchanged);
|
||||
// for a branch handle it is `<storagePath>/branches/<slug>/meta.json`.
|
||||
// Read the metadata that sits next to THIS handle's lbug. For the
|
||||
// flat/primary handle this is `<storagePath>/gitnexus.json`; for a
|
||||
// branch handle it is `<storagePath>/branches/<slug>/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.
|
||||
|
|
|
|||
28
gitnexus/src/storage/fs-atomic.ts
Normal file
28
gitnexus/src/storage/fs-atomic.ts
Normal file
|
|
@ -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<void> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string>;
|
||||
/**
|
||||
* 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/<slug>/` 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 `<repo>/.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/<slug>/`; 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/<slug>/`. 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: <repo>/.gitnexus/gitnexus.json
|
||||
* - Feature branches: <repo>/.gitnexus/branches/<slug>/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<RepoMeta | null> => {
|
||||
const loadMetaLegacy = async (metaDir: string): Promise<RepoMeta | null> =>
|
||||
tryReadMetaFile(metaDir, LEGACY_METADATA_FILE);
|
||||
|
||||
/**
|
||||
* Load metadata from a directory containing the metadata file (gitnexus.json).
|
||||
* For primary/flat: metaDir = <repo>/.gitnexus
|
||||
* For feature branches: metaDir = <repo>/.gitnexus/branches/<slug>
|
||||
*
|
||||
* 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<RepoMeta | null> => {
|
||||
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 `<dir>/<filename>`. 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<void> {
|
||||
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<void> => {
|
||||
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<boolean> => {
|
||||
const { metaPath } = getStoragePaths(repoPath);
|
||||
export const saveMeta = async (metaDir: string, meta: RepoMeta): Promise<void> => {
|
||||
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<boolean> => {
|
||||
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<IndexedRepo | null> => {
|
||||
const paths = getStoragePaths(repoPath);
|
||||
|
|
@ -442,6 +528,119 @@ export const loadRepo = async (repoPath: string): Promise<IndexedRepo | null> =>
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<RepoMeta | null> => {
|
||||
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<boolean> => {
|
||||
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/<slug>/` 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<boolean> => {
|
||||
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<void> => {
|
||||
const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore');
|
||||
|
|
@ -490,7 +700,7 @@ export const ensureGitNexusIgnored = async (repoPath: string): Promise<void> =>
|
|||
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<void> => {
|
|||
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<boolean> => {
|
|||
* 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 `<repoPath>/.gitnexus/meta.json`.
|
||||
* 1. `gitnexus.json` must exist at `<repoPath>/.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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<typeof import('../../src/storage/repo-manager.js')>()),
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
|
|
|
|||
|
|
@ -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<typeof import('../../src/core/lbug/pool-adapter.js')>();
|
||||
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<typeof import('../../src/storage/repo-manager.js')>()),
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
88
gitnexus/test/unit/remove-command.test.ts
Normal file
88
gitnexus/test/unit/remove-command.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
292
gitnexus/test/unit/repo-manager-reconcile.test.ts
Normal file
292
gitnexus/test/unit/repo-manager-reconcile.test.ts
Normal file
|
|
@ -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>): RepoMeta => ({
|
||||
repoPath: '/some/repo',
|
||||
lastCommit,
|
||||
indexedAt,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const readJson = async (dir: string, filename: string): Promise<RepoMeta> =>
|
||||
JSON.parse(await fs.readFile(path.join(dir, filename), 'utf-8')) as RepoMeta;
|
||||
|
||||
describe('reconcileMetadataFiles', () => {
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
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<RepoMeta>);
|
||||
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<typeof fs.open>[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<typeof import('../../src/storage/repo-manager.js')>()),
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof createTempDir>>;
|
||||
|
||||
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<typeof fs.open>[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<ReturnType<typeof createTempDir>>;
|
||||
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<typeof fs.readFile>) => {
|
||||
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<ReturnType<typeof createTempDir>>;
|
||||
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)', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue