mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-12 23:02:45 +00:00
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH, GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in main's FTS skip, embed-session, and help-text updates. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep legacy registry rows on the local storage fallback, resolve symlinks before the destructive-path guard, and align hook lookup with CLI branch slugs, branch-slot metadata, and longest-path match. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Only list swept upload directories after a successful removal so callers cannot treat a permission or transient rm failure as gone. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Document that getStoragePath may consult registered storage while this module still does not mutate the global registry. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(storage): close review findings for external indexes and retention Re-inspect ownership under the analyze lock, fail-closed when the registry file is missing, and keep skip-git hook discovery plus retention fields on HTTP/MCP list surfaces. /api/file stays 410 unless contentRetention is full. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3060) Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3060) Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix macOS hook test expecting realpath'd registry paths. resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that. * Address gitnexus-check warnings on hook install docs and slot tests. The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory. * Align the HTTP catalog source-scan with skippable resolveRepo validation. resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true. * Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear. Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time. * Settle bridge stamps before writing so CI size/mtime matches stay stable. LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches. * Type the settled bridge stat as fs.Stats so tsc does not see bigint. Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI. * Keep the bridge mtime stamp exact so same-size swaps still fail the pair check. Co-authored-by: Cursor <cursoragent@cursor.com> * Wrap the bridge stamp predicate so prettier --check stays green. Co-authored-by: Cursor <cursoragent@cursor.com> * Require a quiet interval before stamping a settled bridge file. Co-authored-by: Cursor <cursoragent@cursor.com> * Reuse shared storage and settle helpers instead of local copies. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
a8736a07d0
commit
79543c8f83
111 changed files with 7313 additions and 1357 deletions
|
|
@ -34,6 +34,18 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
|
||||
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
|
||||
|
||||
## Index storage and retention
|
||||
|
||||
Default location is `<repo>/.gitnexus/`. Override with environment variables (also documented in README):
|
||||
|
||||
| Env | Effect |
|
||||
| --- | ------ |
|
||||
| `GITNEXUS_STORAGE_PATH` | One complete external index directory. Wins if both storage vars are set. |
|
||||
| `GITNEXUS_STORAGE_ROOT` | Absolute root; GitNexus creates an isolated `<repo-basename>-<12-hex>/` slot per repository. |
|
||||
| `GITNEXUS_CONTENT_RETENTION` | `full` (default) keeps file text; `symbol` keeps snippets; `none` keeps the graph only. |
|
||||
|
||||
`list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`. MCP `include_content` may still return symbol spans when retention is `symbol`.
|
||||
|
||||
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
|
||||
|
||||
### status — Check index freshness
|
||||
|
|
|
|||
|
|
@ -197,3 +197,4 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind
|
|||
- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (`build-tree-sitter-grammars.cjs` activates committed prebuilds in place under `vendor/`, and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback.
|
||||
- The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin,zig}` are handled uniformly: c is required; dart/proto/swift/kotlin/zig are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable.
|
||||
- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`.
|
||||
- Index storage defaults to `<repo>/.gitnexus/`. `GITNEXUS_STORAGE_PATH` selects one complete external index directory and wins over `GITNEXUS_STORAGE_ROOT`, which creates an isolated `<repo-basename>-<12-hex>/` slot per repository. `GITNEXUS_CONTENT_RETENTION` is `full` (default), `symbol`, or `none`. MCP `list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`; MCP `include_content` may still return symbol spans at `symbol`.
|
||||
|
|
|
|||
|
|
@ -598,6 +598,9 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
|||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_STORAGE_PATH` | unset (`<repo>/.gitnexus/`) | Complete external index directory. This preserves the existing configuration semantics and takes precedence over `GITNEXUS_STORAGE_ROOT` when both are set. | You already keep one repository index outside its checkout or need one explicit index location. |
|
||||
| `GITNEXUS_STORAGE_ROOT` | unset | Absolute root directory for external indexes. GitNexus creates an isolated `<repo-basename>-<canonical-path-hash>/` slot beneath it for each repository, then registers the resolved slot so `status`, MCP, and `serve` can reopen it later. | You want to manage multiple repository indexes centrally or keep generated data outside source checkouts. |
|
||||
| `GITNEXUS_CONTENT_RETENTION` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. | You need to reduce persisted source text while preserving graph structure. |
|
||||
| `GITNEXUS_SKIP_FTS` | unset | When exactly `1`, skips FTS extension loading and keyword index creation during analyze. Equivalent to `--skip-fts`; a later analyze without either option restores FTS. | Graph-only consumers with their own retrieval, or short-lived indexes that do not need keyword search. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
|
|
@ -676,7 +679,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
|||
|
||||
GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere.
|
||||
|
||||
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
|
||||
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo by default (portable, gitignored). `GITNEXUS_STORAGE_PATH` selects one complete external index directory and preserves the established configuration behavior. To manage multiple repositories under one external directory, set `GITNEXUS_STORAGE_ROOT`; GitNexus derives an isolated `<repo-basename>-<canonical-path-hash>/` slot beneath it for each repository. If both variables are set, `GITNEXUS_STORAGE_PATH` takes precedence. GitNexus registers the resolved slot in `~/.gitnexus/registry.json`, allowing later `status`, MCP, and `serve` commands to reopen the index without repeating the environment variable. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
|
||||
|
||||
<details>
|
||||
<summary><strong>Architecture diagram</strong></summary>
|
||||
|
|
@ -1095,7 +1098,7 @@ Built by the community — not officially maintained, but worth checking out.
|
|||
|
||||
## Security & Privacy
|
||||
|
||||
- **CLI**: everything runs locally on your machine. No network calls. Index stored in `.gitnexus/` (gitignored). Global registry at `~/.gitnexus/` stores only paths and metadata.
|
||||
- **CLI**: everything runs locally on your machine. No network calls. Indexes are stored in `.gitnexus/` by default (gitignored), in the complete external directory selected by `GITNEXUS_STORAGE_PATH`, or in repository-specific slots beneath `GITNEXUS_STORAGE_ROOT`. Global registry at `~/.gitnexus/` stores only paths and metadata.
|
||||
- **Web**: everything runs in your browser. No code uploaded to any server. API keys stored in localStorage only.
|
||||
- Open source — audit the code yourself.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const {
|
|||
resolveUnixGuardTimeout,
|
||||
} = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
const { resolveHookRepo } = require('./registry-query.cjs');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
|
|
@ -33,106 +34,10 @@ function readInput() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (
|
||||
fs.existsSync(path.join(candidate, 'gitnexus.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'meta.json'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
/* Registry-backed hooks resolve the source repo and storage path together. */
|
||||
|
||||
/**
|
||||
* Read the index metadata file, preferring `gitnexus.json` (current format)
|
||||
* and falling back to the legacy `meta.json` mirror. Returns `null` if
|
||||
* neither exists or parses.
|
||||
*/
|
||||
function readIndexMeta(gitNexusDir) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'gitnexus.json'), 'utf-8'));
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
|
||||
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
|
||||
*/
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
|
||||
* any git working tree — including a *linked* worktree created via
|
||||
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
|
||||
* upward walk from cwd alone misses the index. Returns null when `cwd` is
|
||||
* not inside a git repo or `git` is not available.
|
||||
*
|
||||
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
|
||||
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
|
||||
* all linked worktrees. The canonical repo root is its parent directory.
|
||||
*/
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
|
||||
// Fast path: the cwd is inside the canonical repo (most common case).
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
|
||||
// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
|
||||
// only lives in the canonical repo root. Resolve the shared git dir
|
||||
// and retry from there.
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
function hasGitNexusServerOwner(lbugPath) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(lbugPath, process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -406,11 +311,11 @@ function buildMcpQueryHint(pattern) {
|
|||
* ponytail: per-repo mtime marker, shared across concurrent sessions on the same
|
||||
* repo; add per-session dedup only if that sharing becomes a problem.
|
||||
*/
|
||||
function shouldEmitMcpHint(gitNexusDir) {
|
||||
function shouldEmitMcpHint(storagePath) {
|
||||
const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS;
|
||||
const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw);
|
||||
if (!Number.isFinite(windowMs) || windowMs <= 0) return true;
|
||||
const marker = path.join(gitNexusDir, '.mcp-hint-shown');
|
||||
const marker = path.join(storagePath, '.mcp-hint-shown');
|
||||
try {
|
||||
if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false;
|
||||
} catch {
|
||||
|
|
@ -430,8 +335,6 @@ function shouldEmitMcpHint(gitNexusDir) {
|
|||
function handlePreToolUse(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
|
@ -441,12 +344,18 @@ function handlePreToolUse(input) {
|
|||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
// Registry row first (persisted external storagePath wins). Local owned
|
||||
// `.gitnexus` is only the fallback when no matching registry row exists.
|
||||
const repo = resolveHookRepo(cwd);
|
||||
if (!repo) return;
|
||||
const storagePath = repo.storagePath;
|
||||
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. Keep the acquire right after the cheap guards above —
|
||||
// moving it earlier would churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
const release = acquireHookSlot(storagePath);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
|
|
@ -459,7 +368,7 @@ function handlePreToolUse(input) {
|
|||
|
||||
let result = '';
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
if (hasGitNexusServerOwner(repo.lbugPath)) {
|
||||
// #2396: the MCP server holds the DB write lock, so a competing CLI
|
||||
// `augment` would only contend on it (LadybugDB is single-writer). But the
|
||||
// session that triggered this hook has the GitNexus MCP tools live — route
|
||||
|
|
@ -470,7 +379,7 @@ function handlePreToolUse(input) {
|
|||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
if (shouldEmitMcpHint(gitNexusDir)) {
|
||||
if (shouldEmitMcpHint(storagePath)) {
|
||||
result = buildMcpQueryHint(pattern);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -496,7 +405,7 @@ function handlePreToolUse(input) {
|
|||
* Instead of spawning a full `gitnexus analyze` synchronously (which blocks
|
||||
* the agent for up to 120s and risks LadybugDB corruption on timeout), we do a
|
||||
* lightweight staleness check: compare `git rev-parse HEAD` against the
|
||||
* lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the
|
||||
* lastCommit stored in the registered index metadata. If they differ, notify the
|
||||
* agent so it can decide when to reindex.
|
||||
*/
|
||||
function handlePostToolUse(input) {
|
||||
|
|
@ -512,8 +421,8 @@ function handlePostToolUse(input) {
|
|||
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
const repo = resolveHookRepo(cwd);
|
||||
if (!repo) return;
|
||||
|
||||
// Compare HEAD against last indexed commit — skip if unchanged
|
||||
let currentHead = '';
|
||||
|
|
@ -534,7 +443,7 @@ function handlePostToolUse(input) {
|
|||
|
||||
let lastCommit = '';
|
||||
let hadEmbeddings = false;
|
||||
const meta = readIndexMeta(gitNexusDir);
|
||||
const meta = repo.metadata;
|
||||
if (meta) {
|
||||
lastCommit = meta.lastCommit || '';
|
||||
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
|
||||
|
|
|
|||
410
gitnexus-claude-plugin/hooks/registry-query.cjs
Normal file
410
gitnexus-claude-plugin/hooks/registry-query.cjs
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { createHash } = require('crypto');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Hooks are copied into editor-specific directories and run without the
|
||||
// package's TypeScript modules. Keep their on-disk names centralized here.
|
||||
const GITNEXUS_DIR = '.gitnexus';
|
||||
const INDEX_METADATA_FILE = 'gitnexus.json';
|
||||
const LEGACY_METADATA_FILE = 'meta.json';
|
||||
const LBUG_DIRECTORY = 'lbug';
|
||||
const BRANCHES_DIRECTORY = 'branches';
|
||||
const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
|
||||
const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT';
|
||||
const STORAGE_SLOT_HASH_LENGTH = 12;
|
||||
const LOCAL_OWNED_PARENT_HOPS = 5;
|
||||
|
||||
function stripWindowsLongPathPrefix(p) {
|
||||
if (process.platform !== 'win32') return p;
|
||||
if (/^\\\\\?\\UNC\\(?=[^\\])/i.test(p)) return `\\\\${p.slice(8)}`;
|
||||
if (/^\\\\\?\\[A-Za-z]:\\/.test(p)) return p.slice(4);
|
||||
return p;
|
||||
}
|
||||
|
||||
function canonicalize(value) {
|
||||
if (typeof value !== 'string' || !value || value.includes('\0') || !path.isAbsolute(value))
|
||||
return null;
|
||||
const resolved = path.resolve(value);
|
||||
try {
|
||||
return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved));
|
||||
} catch {
|
||||
return stripWindowsLongPathPrefix(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
function samePath(left, right) {
|
||||
if (left == null || right == null) return false;
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
function isMissingFile(error) {
|
||||
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
||||
}
|
||||
|
||||
function readMetadataFile(storagePath, filename) {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(path.join(storagePath, filename), 'utf-8'));
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? { state: 'valid', value }
|
||||
: { state: 'invalid' };
|
||||
} catch (error) {
|
||||
return isMissingFile(error) ? { state: 'absent' } : { state: 'invalid' };
|
||||
}
|
||||
}
|
||||
|
||||
function readIndexMetadata(storagePath) {
|
||||
const primary = readMetadataFile(storagePath, INDEX_METADATA_FILE);
|
||||
if (primary.state === 'valid') return primary.value;
|
||||
if (primary.state !== 'absent') return null;
|
||||
|
||||
const legacy = readMetadataFile(storagePath, LEGACY_METADATA_FILE);
|
||||
return legacy.state === 'valid' ? legacy.value : null;
|
||||
}
|
||||
|
||||
function isOwnedStorage(repoPath, storagePath, repositoryLocal, metadata) {
|
||||
// Repository-local storage remains usable for metadata written before
|
||||
// repoPath was recorded, but an explicit repoPath must never name another
|
||||
// checkout. External storage always requires the complete ownership binding.
|
||||
if (repositoryLocal && (!metadata || typeof metadata.repoPath !== 'string')) {
|
||||
return true;
|
||||
}
|
||||
if (!metadata || typeof metadata.repoPath !== 'string') return false;
|
||||
|
||||
const metadataRepoPath = canonicalize(metadata.repoPath);
|
||||
const expectedRepoPath = canonicalize(repoPath);
|
||||
if (
|
||||
metadataRepoPath == null ||
|
||||
expectedRepoPath == null ||
|
||||
!samePath(metadataRepoPath, expectedRepoPath)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (repositoryLocal) return true;
|
||||
if (typeof metadata.storagePath !== 'string') return false;
|
||||
|
||||
const metadataStoragePath = canonicalize(metadata.storagePath);
|
||||
const expectedStoragePath = canonicalize(storagePath);
|
||||
return (
|
||||
metadataStoragePath != null &&
|
||||
expectedStoragePath != null &&
|
||||
samePath(metadataStoragePath, expectedStoragePath)
|
||||
);
|
||||
}
|
||||
|
||||
function ancestorPaths(cwd) {
|
||||
const paths = [];
|
||||
let current = canonicalize(cwd);
|
||||
while (current) {
|
||||
paths.push(current);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isInsideOrEqual(child, ancestor) {
|
||||
if (child == null || ancestor == null) return false;
|
||||
if (samePath(child, ancestor)) return true;
|
||||
const relative = path.relative(ancestor, child);
|
||||
return (
|
||||
relative !== '' &&
|
||||
relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
function ancestorPathsThrough(cwd, stopAt) {
|
||||
const paths = [];
|
||||
let current = canonicalize(cwd);
|
||||
const stop = canonicalize(stopAt);
|
||||
while (current) {
|
||||
if (stop && !isInsideOrEqual(current, stop)) break;
|
||||
paths.push(current);
|
||||
if (stop && samePath(current, stop)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function currentGitBranch(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const branch = String(result.stdout || '').trim();
|
||||
return branch || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function registryPathsForCwd(cwd) {
|
||||
const fallbackPaths = ancestorPaths(cwd);
|
||||
if (fallbackPaths.length === 0) return { repoPaths: [], branch: null };
|
||||
try {
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (result.error || result.status !== 0) return { repoPaths: fallbackPaths, branch: null };
|
||||
|
||||
const [worktreeRoot, commonDir] = String(result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (!worktreeRoot || !path.isAbsolute(worktreeRoot)) {
|
||||
return { repoPaths: fallbackPaths, branch: null };
|
||||
}
|
||||
|
||||
// Keep ancestor paths of cwd that stay inside this worktree (cwd up to
|
||||
// and including show-toplevel) so a --skip-git subdirectory index can
|
||||
// win via longest-match. Do not walk ancestors outside the worktree —
|
||||
// that would re-attribute a parent index to a nested git checkout.
|
||||
const repoPaths = ancestorPathsThrough(cwd, worktreeRoot);
|
||||
const worktreeCanon = canonicalize(worktreeRoot);
|
||||
if (worktreeCanon && !repoPaths.some((repoPath) => samePath(repoPath, worktreeCanon))) {
|
||||
repoPaths.push(worktreeCanon);
|
||||
}
|
||||
|
||||
// Linked worktrees share the canonical repo's git dir. Include that
|
||||
// parent so the registered main checkout is still discoverable, but do
|
||||
// not walk any further outside this worktree.
|
||||
if (commonDir) {
|
||||
const commonParent = canonicalize(path.dirname(commonDir));
|
||||
if (
|
||||
commonParent &&
|
||||
worktreeCanon &&
|
||||
!samePath(commonParent, worktreeCanon) &&
|
||||
!repoPaths.some((repoPath) => samePath(repoPath, commonParent))
|
||||
) {
|
||||
repoPaths.push(commonParent);
|
||||
}
|
||||
}
|
||||
return {
|
||||
repoPaths,
|
||||
branch: currentGitBranch(cwd),
|
||||
};
|
||||
} catch {
|
||||
return { repoPaths: fallbackPaths, branch: null };
|
||||
}
|
||||
}
|
||||
|
||||
function branchSlug(rawRef) {
|
||||
const sanitized = rawRef.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
|
||||
const safe =
|
||||
!sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized)
|
||||
? 'unknown'
|
||||
: sanitized;
|
||||
const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8);
|
||||
return `${safe}-${hash}`;
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly
|
||||
// (sanitize + sha256 of the canonical repo path, 12-hex suffix).
|
||||
function sanitizeSlotBasename(value) {
|
||||
// Cap first, then walk the tail once — same order as
|
||||
// gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS).
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
const code = sanitized.charCodeAt(end - 1);
|
||||
if (code !== 0x20 && code !== 0x2e) break;
|
||||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
}
|
||||
|
||||
function storageSlotName(repoPath) {
|
||||
const canonical = canonicalize(repoPath);
|
||||
if (!canonical) return null;
|
||||
const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
const basename = sanitizeSlotBasename(path.basename(canonical));
|
||||
const digest = createHash('sha256')
|
||||
.update(identity)
|
||||
.digest('hex')
|
||||
.slice(0, STORAGE_SLOT_HASH_LENGTH);
|
||||
return `${basename}-${digest}`;
|
||||
}
|
||||
|
||||
function envOverridesStorage() {
|
||||
const envPath = process.env[STORAGE_PATH_ENV];
|
||||
const envRoot = process.env[STORAGE_ROOT_ENV];
|
||||
return (
|
||||
(typeof envPath === 'string' && envPath.length > 0) ||
|
||||
(typeof envRoot === 'string' && envRoot.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEntryStoragePath(entry) {
|
||||
const envPath = process.env[STORAGE_PATH_ENV];
|
||||
if (
|
||||
typeof envPath === 'string' &&
|
||||
envPath.length > 0 &&
|
||||
!envPath.includes('\0') &&
|
||||
path.isAbsolute(envPath)
|
||||
) {
|
||||
const resolved = path.resolve(envPath);
|
||||
if (path.isAbsolute(resolved)) return resolved;
|
||||
}
|
||||
|
||||
const envRoot = process.env[STORAGE_ROOT_ENV];
|
||||
if (
|
||||
typeof envRoot === 'string' &&
|
||||
envRoot.length > 0 &&
|
||||
!envRoot.includes('\0') &&
|
||||
path.isAbsolute(envRoot)
|
||||
) {
|
||||
const root = path.resolve(envRoot);
|
||||
const slot = storageSlotName(entry.path);
|
||||
if (slot) {
|
||||
const storagePath = path.join(root, slot);
|
||||
if (samePath(path.dirname(storagePath), root)) return storagePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.storagePath !== undefined) {
|
||||
if (
|
||||
typeof entry.storagePath !== 'string' ||
|
||||
!entry.storagePath ||
|
||||
entry.storagePath.includes('\0') ||
|
||||
!path.isAbsolute(entry.storagePath)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return path.resolve(entry.storagePath);
|
||||
}
|
||||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
function hasLocalIndexSignal(storagePath) {
|
||||
try {
|
||||
return (
|
||||
fs.existsSync(path.join(storagePath, INDEX_METADATA_FILE)) ||
|
||||
fs.existsSync(path.join(storagePath, LBUG_DIRECTORY))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findLocalOwnedRepo(cwd) {
|
||||
// Environment storage overrides win; a leftover repo-local .gitnexus must
|
||||
// not skip the registry scan that applies STORAGE_PATH / STORAGE_ROOT.
|
||||
if (envOverridesStorage()) return null;
|
||||
const { repoPaths, branch } = registryPathsForCwd(cwd);
|
||||
let current = canonicalize(cwd);
|
||||
for (let hops = 0; hops <= LOCAL_OWNED_PARENT_HOPS && current; hops++) {
|
||||
const storagePath = path.join(current, GITNEXUS_DIR);
|
||||
if (hasLocalIndexSignal(storagePath)) {
|
||||
const metadata = readIndexMetadata(storagePath);
|
||||
if (isOwnedStorage(current, storagePath, true, metadata)) {
|
||||
const branchDir =
|
||||
branch != null ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) : null;
|
||||
const indexDir = branchDir && hasLocalIndexSignal(branchDir) ? branchDir : storagePath;
|
||||
return {
|
||||
path: current,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
metadata: indexDir === storagePath ? metadata : readIndexMetadata(indexDir),
|
||||
};
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
// Stay inside this checkout. Registered lookup already stops at
|
||||
// `--show-toplevel`; walking raw parents would adopt `/outer/.gitnexus`
|
||||
// from `/outer/nested-repo`.
|
||||
if (repoPaths.length > 0 && !repoPaths.some((repoPath) => samePath(repoPath, parent))) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findRegisteredRepo(cwd) {
|
||||
const { repoPaths, branch } = registryPathsForCwd(cwd);
|
||||
if (repoPaths.length === 0) return null;
|
||||
|
||||
const home = process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
let entries;
|
||||
try {
|
||||
entries = JSON.parse(fs.readFileSync(path.join(home, 'registry.json'), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entries)) return null;
|
||||
|
||||
let best = null;
|
||||
let bestLen = -1;
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
if (typeof entry.path !== 'string') continue;
|
||||
if (entry.path.includes('\0') || !path.isAbsolute(entry.path)) continue;
|
||||
const registeredPath = canonicalize(entry.path);
|
||||
if (!registeredPath || !repoPaths.some((repoPath) => samePath(repoPath, registeredPath))) {
|
||||
continue;
|
||||
}
|
||||
const storagePath = resolveEntryStoragePath(entry);
|
||||
if (!storagePath) continue;
|
||||
const repositoryLocal = samePath(
|
||||
canonicalize(path.join(entry.path, GITNEXUS_DIR)),
|
||||
canonicalize(storagePath),
|
||||
);
|
||||
const ownershipMetadata = readIndexMetadata(storagePath);
|
||||
if (!isOwnedStorage(entry.path, storagePath, repositoryLocal, ownershipMetadata)) continue;
|
||||
const branchIsIndexed =
|
||||
branch &&
|
||||
Array.isArray(entry.branches) &&
|
||||
entry.branches.some((summary) => summary && summary.branch === branch);
|
||||
const indexDir = branchIsIndexed
|
||||
? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch))
|
||||
: storagePath;
|
||||
if (registeredPath.length > bestLen) {
|
||||
bestLen = registeredPath.length;
|
||||
best = {
|
||||
path: entry.path,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Registry row wins (including persisted external storagePath); local owned is fallback. */
|
||||
function resolveHookRepo(cwd) {
|
||||
return findRegisteredRepo(cwd) || findLocalOwnedRepo(cwd);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findRegisteredRepo,
|
||||
findLocalOwnedRepo,
|
||||
resolveHookRepo,
|
||||
INDEX_METADATA_FILE,
|
||||
LEGACY_METADATA_FILE,
|
||||
LBUG_DIRECTORY,
|
||||
};
|
||||
|
|
@ -34,6 +34,18 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
|
||||
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
|
||||
|
||||
## Index storage and retention
|
||||
|
||||
Default location is `<repo>/.gitnexus/`. Override with environment variables (also documented in README):
|
||||
|
||||
| Env | Effect |
|
||||
| --- | ------ |
|
||||
| `GITNEXUS_STORAGE_PATH` | One complete external index directory. Wins if both storage vars are set. |
|
||||
| `GITNEXUS_STORAGE_ROOT` | Absolute root; GitNexus creates an isolated `<repo-basename>-<12-hex>/` slot per repository. |
|
||||
| `GITNEXUS_CONTENT_RETENTION` | `full` (default) keeps file text; `symbol` keeps snippets; `none` keeps the graph only. |
|
||||
|
||||
`list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`. MCP `include_content` may still return symbol spans when retention is `symbol`.
|
||||
|
||||
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
|
||||
|
||||
### status — Check index freshness
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ Static config that adds GitNexus knowledge-graph augmentation and skill files to
|
|||
|
||||
## What you get
|
||||
|
||||
| Layer | What it does | How it's installed |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **MCP** | `gitnexus` MCP server with 17 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
|
||||
| **Skills** | All bundled markdown skills (`/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-guide`, `/gitnexus-cli`, `/gitnexus-review`, `/gitnexus-plan`, `/gitnexus-work`, `/gitnexus-lfg`, `/gitnexus-pdg-query`, `/gitnexus-taint-analysis`) | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
|
||||
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. |
|
||||
| Layer | What it does | How it's installed |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| **MCP** | `gitnexus` MCP server with 17 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
|
||||
| **Skills** | All bundled markdown skills (`/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-guide`, `/gitnexus-cli`, `/gitnexus-review`, `/gitnexus-plan`, `/gitnexus-work`, `/gitnexus-lfg`, `/gitnexus-pdg-query`, `/gitnexus-taint-analysis`) | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
|
||||
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. |
|
||||
|
||||
## Hook install
|
||||
|
||||
|
|
@ -24,7 +24,8 @@ From this repo's `gitnexus-cursor-integration/hooks/`, copy the files below into
|
|||
│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json
|
||||
└── hooks/
|
||||
├── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
||||
└── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs
|
||||
├── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs
|
||||
└── registry-query.cjs ← from gitnexus-cursor-integration/hooks/registry-query.cjs
|
||||
```
|
||||
|
||||
Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo):
|
||||
|
|
@ -34,6 +35,7 @@ mkdir -p .cursor hooks
|
|||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hook-lock.cjs" hooks/hook-lock.cjs
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/registry-query.cjs" hooks/registry-query.cjs
|
||||
```
|
||||
|
||||
If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting.
|
||||
|
|
@ -47,11 +49,11 @@ If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array
|
|||
|
||||
### What's installed manually vs. automated
|
||||
|
||||
| Step | Automated by `gitnexus setup`? |
|
||||
| -------------------------------------------------------------------- | ------------------------------ |
|
||||
| `~/.cursor/mcp.json` | ✅ |
|
||||
| `~/.cursor/skills/gitnexus/*` | ✅ |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` + `<project>/hooks/hook-lock.cjs` | ❌ — copy manually (see above) |
|
||||
| Step | Automated by `gitnexus setup`? |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `~/.cursor/mcp.json` | ✅ |
|
||||
| `~/.cursor/skills/gitnexus/*` | ✅ |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` + `<project>/hooks/hook-lock.cjs` + `<project>/hooks/registry-query.cjs` | ❌ — copy manually (see above) |
|
||||
|
||||
Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global.
|
||||
|
||||
|
|
@ -86,6 +88,6 @@ Empty stdout means "no augmentation, continue normally" — the hook never block
|
|||
|
||||
## Troubleshooting
|
||||
|
||||
- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus both hook files at `hooks/gitnexus-hook.cjs` and `hooks/hook-lock.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
|
||||
- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus the hook files at `hooks/gitnexus-hook.cjs`, `hooks/hook-lock.cjs`, and `hooks/registry-query.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
|
||||
- **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency.
|
||||
- **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
const { resolveHookRepo } = require('./registry-query.cjs');
|
||||
|
||||
function readInput() {
|
||||
try {
|
||||
|
|
@ -29,62 +30,6 @@ function readInput() {
|
|||
}
|
||||
}
|
||||
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (
|
||||
fs.existsSync(path.join(candidate, 'gitnexus.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'meta.json'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tokenizeShellWords(command) {
|
||||
const tokens = [];
|
||||
let current = '';
|
||||
|
|
@ -431,16 +376,19 @@ function main() {
|
|||
}
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
// Registry row first (persisted external storagePath wins). Local owned
|
||||
// `.gitnexus` is only the fallback when no matching registry row exists.
|
||||
const repo = resolveHookRepo(cwd);
|
||||
if (!repo) return;
|
||||
const storagePath = repo.storagePath;
|
||||
|
||||
const release = acquireHookSlot(storagePath);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stays silent by default; surfaced only under the cursor
|
||||
|
|
|
|||
410
gitnexus-cursor-integration/hooks/registry-query.cjs
Normal file
410
gitnexus-cursor-integration/hooks/registry-query.cjs
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { createHash } = require('crypto');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Hooks are copied into editor-specific directories and run without the
|
||||
// package's TypeScript modules. Keep their on-disk names centralized here.
|
||||
const GITNEXUS_DIR = '.gitnexus';
|
||||
const INDEX_METADATA_FILE = 'gitnexus.json';
|
||||
const LEGACY_METADATA_FILE = 'meta.json';
|
||||
const LBUG_DIRECTORY = 'lbug';
|
||||
const BRANCHES_DIRECTORY = 'branches';
|
||||
const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
|
||||
const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT';
|
||||
const STORAGE_SLOT_HASH_LENGTH = 12;
|
||||
const LOCAL_OWNED_PARENT_HOPS = 5;
|
||||
|
||||
function stripWindowsLongPathPrefix(p) {
|
||||
if (process.platform !== 'win32') return p;
|
||||
if (/^\\\\\?\\UNC\\(?=[^\\])/i.test(p)) return `\\\\${p.slice(8)}`;
|
||||
if (/^\\\\\?\\[A-Za-z]:\\/.test(p)) return p.slice(4);
|
||||
return p;
|
||||
}
|
||||
|
||||
function canonicalize(value) {
|
||||
if (typeof value !== 'string' || !value || value.includes('\0') || !path.isAbsolute(value))
|
||||
return null;
|
||||
const resolved = path.resolve(value);
|
||||
try {
|
||||
return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved));
|
||||
} catch {
|
||||
return stripWindowsLongPathPrefix(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
function samePath(left, right) {
|
||||
if (left == null || right == null) return false;
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
function isMissingFile(error) {
|
||||
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
||||
}
|
||||
|
||||
function readMetadataFile(storagePath, filename) {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(path.join(storagePath, filename), 'utf-8'));
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? { state: 'valid', value }
|
||||
: { state: 'invalid' };
|
||||
} catch (error) {
|
||||
return isMissingFile(error) ? { state: 'absent' } : { state: 'invalid' };
|
||||
}
|
||||
}
|
||||
|
||||
function readIndexMetadata(storagePath) {
|
||||
const primary = readMetadataFile(storagePath, INDEX_METADATA_FILE);
|
||||
if (primary.state === 'valid') return primary.value;
|
||||
if (primary.state !== 'absent') return null;
|
||||
|
||||
const legacy = readMetadataFile(storagePath, LEGACY_METADATA_FILE);
|
||||
return legacy.state === 'valid' ? legacy.value : null;
|
||||
}
|
||||
|
||||
function isOwnedStorage(repoPath, storagePath, repositoryLocal, metadata) {
|
||||
// Repository-local storage remains usable for metadata written before
|
||||
// repoPath was recorded, but an explicit repoPath must never name another
|
||||
// checkout. External storage always requires the complete ownership binding.
|
||||
if (repositoryLocal && (!metadata || typeof metadata.repoPath !== 'string')) {
|
||||
return true;
|
||||
}
|
||||
if (!metadata || typeof metadata.repoPath !== 'string') return false;
|
||||
|
||||
const metadataRepoPath = canonicalize(metadata.repoPath);
|
||||
const expectedRepoPath = canonicalize(repoPath);
|
||||
if (
|
||||
metadataRepoPath == null ||
|
||||
expectedRepoPath == null ||
|
||||
!samePath(metadataRepoPath, expectedRepoPath)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (repositoryLocal) return true;
|
||||
if (typeof metadata.storagePath !== 'string') return false;
|
||||
|
||||
const metadataStoragePath = canonicalize(metadata.storagePath);
|
||||
const expectedStoragePath = canonicalize(storagePath);
|
||||
return (
|
||||
metadataStoragePath != null &&
|
||||
expectedStoragePath != null &&
|
||||
samePath(metadataStoragePath, expectedStoragePath)
|
||||
);
|
||||
}
|
||||
|
||||
function ancestorPaths(cwd) {
|
||||
const paths = [];
|
||||
let current = canonicalize(cwd);
|
||||
while (current) {
|
||||
paths.push(current);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isInsideOrEqual(child, ancestor) {
|
||||
if (child == null || ancestor == null) return false;
|
||||
if (samePath(child, ancestor)) return true;
|
||||
const relative = path.relative(ancestor, child);
|
||||
return (
|
||||
relative !== '' &&
|
||||
relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
function ancestorPathsThrough(cwd, stopAt) {
|
||||
const paths = [];
|
||||
let current = canonicalize(cwd);
|
||||
const stop = canonicalize(stopAt);
|
||||
while (current) {
|
||||
if (stop && !isInsideOrEqual(current, stop)) break;
|
||||
paths.push(current);
|
||||
if (stop && samePath(current, stop)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function currentGitBranch(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const branch = String(result.stdout || '').trim();
|
||||
return branch || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function registryPathsForCwd(cwd) {
|
||||
const fallbackPaths = ancestorPaths(cwd);
|
||||
if (fallbackPaths.length === 0) return { repoPaths: [], branch: null };
|
||||
try {
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (result.error || result.status !== 0) return { repoPaths: fallbackPaths, branch: null };
|
||||
|
||||
const [worktreeRoot, commonDir] = String(result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (!worktreeRoot || !path.isAbsolute(worktreeRoot)) {
|
||||
return { repoPaths: fallbackPaths, branch: null };
|
||||
}
|
||||
|
||||
// Keep ancestor paths of cwd that stay inside this worktree (cwd up to
|
||||
// and including show-toplevel) so a --skip-git subdirectory index can
|
||||
// win via longest-match. Do not walk ancestors outside the worktree —
|
||||
// that would re-attribute a parent index to a nested git checkout.
|
||||
const repoPaths = ancestorPathsThrough(cwd, worktreeRoot);
|
||||
const worktreeCanon = canonicalize(worktreeRoot);
|
||||
if (worktreeCanon && !repoPaths.some((repoPath) => samePath(repoPath, worktreeCanon))) {
|
||||
repoPaths.push(worktreeCanon);
|
||||
}
|
||||
|
||||
// Linked worktrees share the canonical repo's git dir. Include that
|
||||
// parent so the registered main checkout is still discoverable, but do
|
||||
// not walk any further outside this worktree.
|
||||
if (commonDir) {
|
||||
const commonParent = canonicalize(path.dirname(commonDir));
|
||||
if (
|
||||
commonParent &&
|
||||
worktreeCanon &&
|
||||
!samePath(commonParent, worktreeCanon) &&
|
||||
!repoPaths.some((repoPath) => samePath(repoPath, commonParent))
|
||||
) {
|
||||
repoPaths.push(commonParent);
|
||||
}
|
||||
}
|
||||
return {
|
||||
repoPaths,
|
||||
branch: currentGitBranch(cwd),
|
||||
};
|
||||
} catch {
|
||||
return { repoPaths: fallbackPaths, branch: null };
|
||||
}
|
||||
}
|
||||
|
||||
function branchSlug(rawRef) {
|
||||
const sanitized = rawRef.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
|
||||
const safe =
|
||||
!sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized)
|
||||
? 'unknown'
|
||||
: sanitized;
|
||||
const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8);
|
||||
return `${safe}-${hash}`;
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly
|
||||
// (sanitize + sha256 of the canonical repo path, 12-hex suffix).
|
||||
function sanitizeSlotBasename(value) {
|
||||
// Cap first, then walk the tail once — same order as
|
||||
// gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS).
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
const code = sanitized.charCodeAt(end - 1);
|
||||
if (code !== 0x20 && code !== 0x2e) break;
|
||||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
}
|
||||
|
||||
function storageSlotName(repoPath) {
|
||||
const canonical = canonicalize(repoPath);
|
||||
if (!canonical) return null;
|
||||
const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
const basename = sanitizeSlotBasename(path.basename(canonical));
|
||||
const digest = createHash('sha256')
|
||||
.update(identity)
|
||||
.digest('hex')
|
||||
.slice(0, STORAGE_SLOT_HASH_LENGTH);
|
||||
return `${basename}-${digest}`;
|
||||
}
|
||||
|
||||
function envOverridesStorage() {
|
||||
const envPath = process.env[STORAGE_PATH_ENV];
|
||||
const envRoot = process.env[STORAGE_ROOT_ENV];
|
||||
return (
|
||||
(typeof envPath === 'string' && envPath.length > 0) ||
|
||||
(typeof envRoot === 'string' && envRoot.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEntryStoragePath(entry) {
|
||||
const envPath = process.env[STORAGE_PATH_ENV];
|
||||
if (
|
||||
typeof envPath === 'string' &&
|
||||
envPath.length > 0 &&
|
||||
!envPath.includes('\0') &&
|
||||
path.isAbsolute(envPath)
|
||||
) {
|
||||
const resolved = path.resolve(envPath);
|
||||
if (path.isAbsolute(resolved)) return resolved;
|
||||
}
|
||||
|
||||
const envRoot = process.env[STORAGE_ROOT_ENV];
|
||||
if (
|
||||
typeof envRoot === 'string' &&
|
||||
envRoot.length > 0 &&
|
||||
!envRoot.includes('\0') &&
|
||||
path.isAbsolute(envRoot)
|
||||
) {
|
||||
const root = path.resolve(envRoot);
|
||||
const slot = storageSlotName(entry.path);
|
||||
if (slot) {
|
||||
const storagePath = path.join(root, slot);
|
||||
if (samePath(path.dirname(storagePath), root)) return storagePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.storagePath !== undefined) {
|
||||
if (
|
||||
typeof entry.storagePath !== 'string' ||
|
||||
!entry.storagePath ||
|
||||
entry.storagePath.includes('\0') ||
|
||||
!path.isAbsolute(entry.storagePath)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return path.resolve(entry.storagePath);
|
||||
}
|
||||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
function hasLocalIndexSignal(storagePath) {
|
||||
try {
|
||||
return (
|
||||
fs.existsSync(path.join(storagePath, INDEX_METADATA_FILE)) ||
|
||||
fs.existsSync(path.join(storagePath, LBUG_DIRECTORY))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findLocalOwnedRepo(cwd) {
|
||||
// Environment storage overrides win; a leftover repo-local .gitnexus must
|
||||
// not skip the registry scan that applies STORAGE_PATH / STORAGE_ROOT.
|
||||
if (envOverridesStorage()) return null;
|
||||
const { repoPaths, branch } = registryPathsForCwd(cwd);
|
||||
let current = canonicalize(cwd);
|
||||
for (let hops = 0; hops <= LOCAL_OWNED_PARENT_HOPS && current; hops++) {
|
||||
const storagePath = path.join(current, GITNEXUS_DIR);
|
||||
if (hasLocalIndexSignal(storagePath)) {
|
||||
const metadata = readIndexMetadata(storagePath);
|
||||
if (isOwnedStorage(current, storagePath, true, metadata)) {
|
||||
const branchDir =
|
||||
branch != null ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) : null;
|
||||
const indexDir = branchDir && hasLocalIndexSignal(branchDir) ? branchDir : storagePath;
|
||||
return {
|
||||
path: current,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
metadata: indexDir === storagePath ? metadata : readIndexMetadata(indexDir),
|
||||
};
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
// Stay inside this checkout. Registered lookup already stops at
|
||||
// `--show-toplevel`; walking raw parents would adopt `/outer/.gitnexus`
|
||||
// from `/outer/nested-repo`.
|
||||
if (repoPaths.length > 0 && !repoPaths.some((repoPath) => samePath(repoPath, parent))) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findRegisteredRepo(cwd) {
|
||||
const { repoPaths, branch } = registryPathsForCwd(cwd);
|
||||
if (repoPaths.length === 0) return null;
|
||||
|
||||
const home = process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
let entries;
|
||||
try {
|
||||
entries = JSON.parse(fs.readFileSync(path.join(home, 'registry.json'), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entries)) return null;
|
||||
|
||||
let best = null;
|
||||
let bestLen = -1;
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
if (typeof entry.path !== 'string') continue;
|
||||
if (entry.path.includes('\0') || !path.isAbsolute(entry.path)) continue;
|
||||
const registeredPath = canonicalize(entry.path);
|
||||
if (!registeredPath || !repoPaths.some((repoPath) => samePath(repoPath, registeredPath))) {
|
||||
continue;
|
||||
}
|
||||
const storagePath = resolveEntryStoragePath(entry);
|
||||
if (!storagePath) continue;
|
||||
const repositoryLocal = samePath(
|
||||
canonicalize(path.join(entry.path, GITNEXUS_DIR)),
|
||||
canonicalize(storagePath),
|
||||
);
|
||||
const ownershipMetadata = readIndexMetadata(storagePath);
|
||||
if (!isOwnedStorage(entry.path, storagePath, repositoryLocal, ownershipMetadata)) continue;
|
||||
const branchIsIndexed =
|
||||
branch &&
|
||||
Array.isArray(entry.branches) &&
|
||||
entry.branches.some((summary) => summary && summary.branch === branch);
|
||||
const indexDir = branchIsIndexed
|
||||
? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch))
|
||||
: storagePath;
|
||||
if (registeredPath.length > bestLen) {
|
||||
bestLen = registeredPath.length;
|
||||
best = {
|
||||
path: entry.path,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Registry row wins (including persisted external storagePath); local owned is fallback. */
|
||||
function resolveHookRepo(cwd) {
|
||||
return findRegisteredRepo(cwd) || findLocalOwnedRepo(cwd);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findRegisteredRepo,
|
||||
findLocalOwnedRepo,
|
||||
resolveHookRepo,
|
||||
INDEX_METADATA_FILE,
|
||||
LEGACY_METADATA_FILE,
|
||||
LBUG_DIRECTORY,
|
||||
};
|
||||
|
|
@ -16,7 +16,7 @@ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|||
import { useAppState } from '../hooks/useAppState';
|
||||
import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { NODE_COLORS } from '../lib/constants';
|
||||
import { readFile, type ReadFileResult } from '../services/backend-client';
|
||||
import { BackendError, readFile, type ReadFileResult } from '../services/backend-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const getSyntaxLanguage = (filePath: string | undefined): string => {
|
||||
|
|
@ -205,6 +205,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
const CONTEXT_LINES = 50; // lines of context above and below the symbol
|
||||
|
||||
const [fileResult, setFileResult] = useState<ReadFileResult | null>(null);
|
||||
const [sourceUnavailable, setSourceUnavailable] = useState(false);
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||
const selectedViewerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -214,12 +215,14 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
useEffect(() => {
|
||||
if (!selectedFilePath) {
|
||||
setFileResult(null);
|
||||
setSourceUnavailable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingFile(true);
|
||||
setFileResult(null);
|
||||
setSourceUnavailable(false);
|
||||
|
||||
// Determine read range: full file for File nodes, buffered for symbols
|
||||
const startLine = selectedNode?.properties?.startLine as number | undefined;
|
||||
|
|
@ -242,9 +245,12 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
setIsLoadingFile(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setFileResult(null);
|
||||
setSourceUnavailable(
|
||||
error instanceof BackendError && error.code === 'source_unavailable',
|
||||
);
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
});
|
||||
|
|
@ -426,11 +432,11 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="px-3 py-3 text-sm text-text-muted">
|
||||
{selectedIsFile ? (
|
||||
<>{t('graph:codePanel.codeNotAvailable', { path: selectedFilePath })}</>
|
||||
) : (
|
||||
<>{t('graph:codePanel.selectFile')}</>
|
||||
)}
|
||||
{sourceUnavailable
|
||||
? t('graph:codePanel.sourceUnavailable')
|
||||
: selectedIsFile
|
||||
? t('graph:codePanel.codeNotAvailable', { path: selectedFilePath })
|
||||
: t('graph:codePanel.selectFile')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,8 @@
|
|||
"references_other": "{{count}} references",
|
||||
"lines_one": "{{count}} line",
|
||||
"lines_other": "{{count}} lines",
|
||||
"codeNotAvailable": "Code not available in memory for {{path}}"
|
||||
"codeNotAvailable": "Code not available in memory for {{path}}",
|
||||
"sourceUnavailable": "Full source is unavailable for this index."
|
||||
},
|
||||
"canvas": {
|
||||
"viewModes": {
|
||||
|
|
|
|||
|
|
@ -110,7 +110,8 @@
|
|||
"references_other": "{{count}} 条引用",
|
||||
"lines_one": "{{count}} 行",
|
||||
"lines_other": "{{count}} 行",
|
||||
"codeNotAvailable": "内存中没有 {{path}} 的代码内容"
|
||||
"codeNotAvailable": "内存中没有 {{path}} 的代码内容",
|
||||
"sourceUnavailable": "此索引无法提供完整源码。"
|
||||
},
|
||||
"canvas": {
|
||||
"viewModes": {
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export class BackendError extends Error {
|
|||
| 'server'
|
||||
| 'client'
|
||||
| 'not_found'
|
||||
| 'source_unavailable'
|
||||
| 'timeout'
|
||||
| 'rate_limited'
|
||||
// The write-route same-host Origin guard rejected this request (HTTP 403
|
||||
|
|
@ -549,22 +550,22 @@ const assertOk = async (response: Response): Promise<void> => {
|
|||
// Response body was not JSON
|
||||
}
|
||||
|
||||
const code =
|
||||
response.status === 404
|
||||
? 'not_found'
|
||||
: response.status === 429
|
||||
? 'rate_limited'
|
||||
: // The public edge's token gate returns 401 with this discriminator;
|
||||
// surface it as a distinct code so the UI can prompt for the token.
|
||||
bodyCode === 'unauthorized'
|
||||
? 'unauthorized'
|
||||
: // The write-route Origin guard returns 403 with this discriminator;
|
||||
// surface it as a distinct code so the UI can give actionable guidance.
|
||||
bodyCode === 'origin_not_allowed'
|
||||
? 'origin_blocked'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
let code: ConstructorParameters<typeof BackendError>[2] = 'server';
|
||||
if (bodyCode === 'source-unavailable') {
|
||||
code = 'source_unavailable';
|
||||
} else if (response.status === 404) {
|
||||
code = 'not_found';
|
||||
} else if (response.status === 429) {
|
||||
code = 'rate_limited';
|
||||
} else if (bodyCode === 'unauthorized') {
|
||||
// Public-edge token gate: HTTP 401 with this discriminator.
|
||||
code = 'unauthorized';
|
||||
} else if (bodyCode === 'origin_not_allowed') {
|
||||
// Write-route Origin guard: HTTP 403 with this discriminator.
|
||||
code = 'origin_blocked';
|
||||
} else if (response.status >= 400 && response.status < 500) {
|
||||
code = 'client';
|
||||
}
|
||||
|
||||
// Retry-After is the standard HTTP signal for when the client may try again.
|
||||
// express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { CodeReferencesPanel } from '../../src/components/CodeReferencesPanel';
|
||||
import { readFile } from '../../src/services/backend-client';
|
||||
import { BackendError, readFile } from '../../src/services/backend-client';
|
||||
|
||||
const fileNode: GraphNode = {
|
||||
id: 'File:src/foo.ts',
|
||||
|
|
@ -31,6 +31,15 @@ vi.mock('../../src/hooks/useAppState', () => ({
|
|||
|
||||
vi.mock('../../src/services/backend-client', () => ({
|
||||
readFile: vi.fn(),
|
||||
BackendError: class BackendError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
_status: number,
|
||||
public readonly code: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-syntax-highlighter', () => ({
|
||||
|
|
@ -70,4 +79,16 @@ describe('CodeReferencesPanel repo identity (#2420)', () => {
|
|||
|
||||
expect(readFile).toHaveBeenCalledWith('src/foo.ts', { repo: 'reels' });
|
||||
});
|
||||
|
||||
it('renders the dedicated source-unavailable state for retained indexes without a checkout', async () => {
|
||||
vi.mocked(readFile).mockRejectedValue(
|
||||
new BackendError('source unavailable', 410, 'source_unavailable'),
|
||||
);
|
||||
|
||||
render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('graph:codePanel.sourceUnavailable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
|||
5. **Processes** — Traces execution flows from entry points through call chains
|
||||
6. **Search** — Builds hybrid search indexes for fast retrieval
|
||||
|
||||
The result is a **LadybugDB graph database** stored locally in `.gitnexus/` with full-text search and semantic embeddings.
|
||||
The result is a **LadybugDB graph database** stored locally in `.gitnexus/` by default, with full-text search and semantic embeddings.
|
||||
|
||||
### Experimental community detection engine
|
||||
|
||||
|
|
@ -677,6 +677,9 @@ Configure the behavior with these environment variables:
|
|||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. |
|
||||
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
|
||||
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
|
||||
| `GITNEXUS_STORAGE_PATH` | absolute, non-empty directory | unset (repo-local) | Complete external index directory. This preserves the existing configuration semantics and takes precedence over `GITNEXUS_STORAGE_ROOT` when both are set. |
|
||||
| `GITNEXUS_STORAGE_ROOT` | absolute, non-empty directory | unset (repo-local) | Absolute root directory for external indexes. GitNexus creates an isolated `<repo-basename>-<canonical-path-hash>/` slot beneath it for each repository, then registers the resolved slot so `status`, MCP, and `serve` can reopen it later. |
|
||||
| `GITNEXUS_CONTENT_RETENTION` | `full`, `symbol`, `none` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. |
|
||||
| `GITNEXUS_STREAM_GRAPH_EMIT` | `0`, `1` | `1` (on) | **On by default** on a full rebuild (`--force`); incremental runs ignore it. Holds structural relationships (CALLS, IMPORTS, ACCESSES, CONTAINS, ...) as CSV-on-disk plus compact in-memory columns instead of as objects in three overlapping indexes, cutting peak in-memory graph heap by ~1.4x at no measurable CPU cost (measured A/B on a synthetic 400k-node / 1.08M-edge graph: 819 MB -> 584 MB, iteration at parity, scaling verified linear from 100k to 800k nodes, with every edge still visible through the graph interface; no end-to-end measurement on a real repository yet). Nothing is traded away — community detection, process extraction, PDG taint summaries and the local-symbol pruner all read a complete relationship set and behave identically. Set to `0` only to bisect a suspected streaming-related fault. |
|
||||
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` is the supported default. `icebug` and `auto` are **experimental** and currently behave identically: both try the optional `@ladybugmem/icebug` native Leiden over a CSR export and fall back to Graphology if it is not installed, cannot load, or lacks the deterministic thread/seed controls. Experimental engines partition differently, so community IDs are not comparable across engines. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
|
|
@ -882,7 +885,7 @@ only — the hook's structured stdout (the JSON the agent consumes) is unaffecte
|
|||
|
||||
- All processing happens locally on your machine
|
||||
- No code is sent to any server
|
||||
- Index stored in `.gitnexus/` inside your repo (gitignored)
|
||||
- Index stored in `.gitnexus/` inside your repo by default (gitignored), in the complete external directory selected by `GITNEXUS_STORAGE_PATH`, or in a repository-specific slot beneath `GITNEXUS_STORAGE_ROOT`
|
||||
- Global registry at `~/.gitnexus/` stores only paths and metadata
|
||||
|
||||
## Web UI
|
||||
|
|
|
|||
|
|
@ -131,7 +131,11 @@ function setupFixture() {
|
|||
lastCommit: git(repoPath, ['rev-parse', 'HEAD']),
|
||||
stats: { files: 1, nodes: 1, edges: 0, communities: 0, processes: 0 },
|
||||
});
|
||||
writeFileSync(path.join(storagePath, 'gitnexus.json'), '{}\n');
|
||||
mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(storagePath, 'gitnexus.json'),
|
||||
`${JSON.stringify({ repoPath, storagePath })}\n`,
|
||||
);
|
||||
}
|
||||
writeFileSync(path.join(HOME, 'registry.json'), `${JSON.stringify(entries, null, 2)}\n`);
|
||||
writeFileSync(marker, `${N}\n`);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ const {
|
|||
resolveUnixGuardTimeout,
|
||||
} = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
const { resolveHookRepo } = (() => {
|
||||
// Installed copies get helpers next to this adapter. The in-tree source
|
||||
// tree only ships the adapter, so fall back to the Claude helper copies.
|
||||
const local = path.join(__dirname, 'registry-query.cjs');
|
||||
return fs.existsSync(local) ? require(local) : require('../claude/registry-query.cjs');
|
||||
})();
|
||||
|
||||
function readInput() {
|
||||
try {
|
||||
|
|
@ -39,81 +45,8 @@ function readInput() {
|
|||
}
|
||||
}
|
||||
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (
|
||||
fs.existsSync(path.join(candidate, 'gitnexus.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'meta.json'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the index metadata file, preferring `gitnexus.json` (current format)
|
||||
* and falling back to the legacy `meta.json` mirror. Returns `null` if
|
||||
* neither exists or parses.
|
||||
*/
|
||||
function readIndexMeta(gitNexusDir) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'gitnexus.json'), 'utf-8'));
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
function hasGitNexusServerOwner(lbugPath) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(lbugPath, process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -355,35 +288,39 @@ function toolSucceeded(toolResponse) {
|
|||
function buildAfterToolContext(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return null;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return null;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
const toolResponse = input.tool_response || {};
|
||||
const succeeded = toolSucceeded(toolResponse);
|
||||
const pattern = succeeded ? extractPattern(toolName, toolInput) : null;
|
||||
const command = toolName === 'run_shell_command' ? toolInput.command || '' : '';
|
||||
const gitMutation =
|
||||
succeeded && /\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command);
|
||||
// Cheap tool-result guards first. Registry I/O is only for search augment
|
||||
// or a git mutation that might need a stale-index hint.
|
||||
if (!pattern && !gitMutation) return null;
|
||||
|
||||
const repo = resolveHookRepo(cwd);
|
||||
if (!repo) return null;
|
||||
const storagePath = repo.storagePath;
|
||||
const parts = [];
|
||||
|
||||
if (toolSucceeded(toolResponse)) {
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (pattern) {
|
||||
const augmentText = runAugment(gitNexusDir, cwd, pattern);
|
||||
if (augmentText) parts.push(augmentText);
|
||||
}
|
||||
if (pattern) {
|
||||
const augmentText = runAugment(storagePath, repo.lbugPath, cwd, pattern);
|
||||
if (augmentText) parts.push(augmentText);
|
||||
}
|
||||
|
||||
if (toolName === 'run_shell_command' && toolSucceeded(toolResponse)) {
|
||||
const command = toolInput.command || '';
|
||||
if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) {
|
||||
const hint = buildStaleIndexHint(gitNexusDir, cwd);
|
||||
if (hint) {
|
||||
// The hint always reaches the agent via additionalContext (parts). Mirror
|
||||
// it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict
|
||||
// hook runners see no unexpected output on this normal path (#1913). The
|
||||
// claude hook never mirrored this to stderr — this aligns the two adapters.
|
||||
parts.push(hint);
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
}
|
||||
if (gitMutation) {
|
||||
const hint = buildStaleIndexHint(repo.metadata, cwd);
|
||||
if (hint) {
|
||||
// The hint always reaches the agent via additionalContext (parts). Mirror
|
||||
// it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict
|
||||
// hook runners see no unexpected output on this normal path (#1913). The
|
||||
// claude hook never mirrored this to stderr — this aligns the two adapters.
|
||||
parts.push(hint);
|
||||
if (isDebugEnabled()) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -416,11 +353,11 @@ function buildMcpQueryHint(pattern) {
|
|||
* ponytail: per-repo mtime marker, shared across concurrent sessions on the same
|
||||
* repo; add per-session dedup only if that sharing becomes a problem.
|
||||
*/
|
||||
function shouldEmitMcpHint(gitNexusDir) {
|
||||
function shouldEmitMcpHint(storagePath) {
|
||||
const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS;
|
||||
const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw);
|
||||
if (!Number.isFinite(windowMs) || windowMs <= 0) return true;
|
||||
const marker = path.join(gitNexusDir, '.mcp-hint-shown');
|
||||
const marker = path.join(storagePath, '.mcp-hint-shown');
|
||||
try {
|
||||
if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false;
|
||||
} catch {
|
||||
|
|
@ -434,14 +371,14 @@ function shouldEmitMcpHint(gitNexusDir) {
|
|||
return true;
|
||||
}
|
||||
|
||||
function runAugment(gitNexusDir, cwd, pattern) {
|
||||
function runAugment(storagePath, lbugPath, cwd, pattern) {
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. The cheap guards (extractPattern, gitNexusDir lookup) run in
|
||||
// subprocesses. The cheap guards (extractPattern, registry lookup) run in
|
||||
// buildAfterToolContext before this — moving the acquire any earlier would
|
||||
// churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
const release = acquireHookSlot(storagePath);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
|
|
@ -452,7 +389,7 @@ function runAugment(gitNexusDir, cwd, pattern) {
|
|||
return '';
|
||||
}
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
if (hasGitNexusServerOwner(lbugPath)) {
|
||||
// #2396: the MCP server holds the DB write lock, so a competing CLI
|
||||
// `augment` would only contend on it (LadybugDB is single-writer). The
|
||||
// session has the GitNexus MCP tools live — route the augmentation to the
|
||||
|
|
@ -462,7 +399,7 @@ function runAugment(gitNexusDir, cwd, pattern) {
|
|||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
return shouldEmitMcpHint(gitNexusDir) ? buildMcpQueryHint(pattern) : '';
|
||||
return shouldEmitMcpHint(storagePath) ? buildMcpQueryHint(pattern) : '';
|
||||
}
|
||||
const cliPath = resolveCliPath();
|
||||
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
||||
|
|
@ -477,7 +414,7 @@ function runAugment(gitNexusDir, cwd, pattern) {
|
|||
return '';
|
||||
}
|
||||
|
||||
function buildStaleIndexHint(gitNexusDir, cwd) {
|
||||
function buildStaleIndexHint(meta, cwd) {
|
||||
let currentHead = '';
|
||||
try {
|
||||
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||
|
|
@ -495,7 +432,6 @@ function buildStaleIndexHint(gitNexusDir, cwd) {
|
|||
|
||||
let lastCommit = '';
|
||||
let hadEmbeddings = false;
|
||||
const meta = readIndexMeta(gitNexusDir);
|
||||
if (meta) {
|
||||
lastCommit = meta.lastCommit || '';
|
||||
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const {
|
|||
resolveUnixGuardTimeout,
|
||||
} = require('./hook-db-lock-probe.cjs');
|
||||
const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs');
|
||||
const { resolveHookRepo } = require('./registry-query.cjs');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
|
|
@ -33,106 +34,8 @@ function readInput() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the .gitnexus directory by walking up from startDir.
|
||||
* Returns the path to .gitnexus/ or null if not found.
|
||||
*/
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (
|
||||
fs.existsSync(path.join(candidate, 'gitnexus.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'meta.json'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the index metadata file, preferring `gitnexus.json` (current format)
|
||||
* and falling back to the legacy `meta.json` mirror. Returns `null` if
|
||||
* neither exists or parses.
|
||||
*/
|
||||
function readIndexMeta(gitNexusDir) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'gitnexus.json'), 'utf-8'));
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from `startDir` looking for a non-registry `.gitnexus/` folder.
|
||||
* Returns the path to `.gitnexus/` or null if not found within 5 levels.
|
||||
*/
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside
|
||||
* any git working tree — including a *linked* worktree created via
|
||||
* `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the
|
||||
* upward walk from cwd alone misses the index. Returns null when `cwd` is
|
||||
* not inside a git repo or `git` is not available.
|
||||
*
|
||||
* Implementation: `git rev-parse --git-common-dir` resolves to the canonical
|
||||
* `.git/` directory (or `.git/worktrees/...` parent) that is shared across
|
||||
* all linked worktrees. The canonical repo root is its parent directory.
|
||||
*/
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
|
||||
// Fast path: the cwd is inside the canonical repo (most common case).
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
|
||||
// Fallback: cwd may be inside a linked git worktree whose `.gitnexus/`
|
||||
// only lives in the canonical repo root. Resolve the shared git dir
|
||||
// and retry from there.
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
function hasGitNexusServerOwner(lbugPath) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(lbugPath, process.pid);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -374,11 +277,11 @@ function buildMcpQueryHint(pattern) {
|
|||
* ponytail: per-repo mtime marker, shared across concurrent sessions on the same
|
||||
* repo; add per-session dedup only if that sharing becomes a problem.
|
||||
*/
|
||||
function shouldEmitMcpHint(gitNexusDir) {
|
||||
function shouldEmitMcpHint(storagePath) {
|
||||
const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS;
|
||||
const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw);
|
||||
if (!Number.isFinite(windowMs) || windowMs <= 0) return true;
|
||||
const marker = path.join(gitNexusDir, '.mcp-hint-shown');
|
||||
const marker = path.join(storagePath, '.mcp-hint-shown');
|
||||
try {
|
||||
if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false;
|
||||
} catch {
|
||||
|
|
@ -398,8 +301,6 @@ function shouldEmitMcpHint(gitNexusDir) {
|
|||
function handlePreToolUse(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
|
@ -409,12 +310,18 @@ function handlePreToolUse(input) {
|
|||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
// Registry row first (persisted external storagePath wins). Local owned
|
||||
// `.gitnexus` is only the fallback when no matching registry row exists.
|
||||
const repo = resolveHookRepo(cwd);
|
||||
if (!repo) return;
|
||||
const storagePath = repo.storagePath;
|
||||
|
||||
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
|
||||
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
|
||||
// as the augment, or concurrent sessions fan out unbounded probe
|
||||
// subprocesses. Keep the acquire right after the cheap guards above —
|
||||
// moving it earlier would churn slot files on tool calls that never probe.
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
const release = acquireHookSlot(storagePath);
|
||||
if (!release) {
|
||||
// Normal skip path: all per-repo hook slots are held by concurrent
|
||||
// sessions. Stay silent for strict hook runners (issue #1913); surface
|
||||
|
|
@ -427,7 +334,7 @@ function handlePreToolUse(input) {
|
|||
|
||||
let result = '';
|
||||
try {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
if (hasGitNexusServerOwner(repo.lbugPath)) {
|
||||
// #2396: the MCP server holds the DB write lock, so a competing CLI
|
||||
// `augment` would only contend on it (LadybugDB is single-writer). But the
|
||||
// session that triggered this hook has the GitNexus MCP tools live — route
|
||||
|
|
@ -438,7 +345,7 @@ function handlePreToolUse(input) {
|
|||
if (isDebugEnabled()) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
}
|
||||
if (shouldEmitMcpHint(gitNexusDir)) {
|
||||
if (shouldEmitMcpHint(storagePath)) {
|
||||
result = buildMcpQueryHint(pattern);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -476,7 +383,7 @@ function sendHookResponse(hookEventName, message) {
|
|||
* Instead of spawning a full `gitnexus analyze` synchronously (which blocks
|
||||
* the agent for up to 120s and risks KuzuDB corruption on timeout), we do a
|
||||
* lightweight staleness check: compare `git rev-parse HEAD` against the
|
||||
* lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the
|
||||
* lastCommit stored in the registered index metadata. If they differ, notify the
|
||||
* agent so it can decide when to reindex.
|
||||
*/
|
||||
function handlePostToolUse(input) {
|
||||
|
|
@ -492,8 +399,8 @@ function handlePostToolUse(input) {
|
|||
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
const repo = resolveHookRepo(cwd);
|
||||
if (!repo) return;
|
||||
|
||||
// Compare HEAD against last indexed commit — skip if unchanged
|
||||
let currentHead = '';
|
||||
|
|
@ -514,7 +421,7 @@ function handlePostToolUse(input) {
|
|||
|
||||
let lastCommit = '';
|
||||
let hadEmbeddings = false;
|
||||
const meta = readIndexMeta(gitNexusDir);
|
||||
const meta = repo.metadata;
|
||||
if (meta) {
|
||||
lastCommit = meta.lastCommit || '';
|
||||
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
|
||||
|
|
|
|||
410
gitnexus/hooks/claude/registry-query.cjs
Normal file
410
gitnexus/hooks/claude/registry-query.cjs
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { createHash } = require('crypto');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Hooks are copied into editor-specific directories and run without the
|
||||
// package's TypeScript modules. Keep their on-disk names centralized here.
|
||||
const GITNEXUS_DIR = '.gitnexus';
|
||||
const INDEX_METADATA_FILE = 'gitnexus.json';
|
||||
const LEGACY_METADATA_FILE = 'meta.json';
|
||||
const LBUG_DIRECTORY = 'lbug';
|
||||
const BRANCHES_DIRECTORY = 'branches';
|
||||
const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
|
||||
const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT';
|
||||
const STORAGE_SLOT_HASH_LENGTH = 12;
|
||||
const LOCAL_OWNED_PARENT_HOPS = 5;
|
||||
|
||||
function stripWindowsLongPathPrefix(p) {
|
||||
if (process.platform !== 'win32') return p;
|
||||
if (/^\\\\\?\\UNC\\(?=[^\\])/i.test(p)) return `\\\\${p.slice(8)}`;
|
||||
if (/^\\\\\?\\[A-Za-z]:\\/.test(p)) return p.slice(4);
|
||||
return p;
|
||||
}
|
||||
|
||||
function canonicalize(value) {
|
||||
if (typeof value !== 'string' || !value || value.includes('\0') || !path.isAbsolute(value))
|
||||
return null;
|
||||
const resolved = path.resolve(value);
|
||||
try {
|
||||
return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved));
|
||||
} catch {
|
||||
return stripWindowsLongPathPrefix(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
function samePath(left, right) {
|
||||
if (left == null || right == null) return false;
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
function isMissingFile(error) {
|
||||
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
||||
}
|
||||
|
||||
function readMetadataFile(storagePath, filename) {
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(path.join(storagePath, filename), 'utf-8'));
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? { state: 'valid', value }
|
||||
: { state: 'invalid' };
|
||||
} catch (error) {
|
||||
return isMissingFile(error) ? { state: 'absent' } : { state: 'invalid' };
|
||||
}
|
||||
}
|
||||
|
||||
function readIndexMetadata(storagePath) {
|
||||
const primary = readMetadataFile(storagePath, INDEX_METADATA_FILE);
|
||||
if (primary.state === 'valid') return primary.value;
|
||||
if (primary.state !== 'absent') return null;
|
||||
|
||||
const legacy = readMetadataFile(storagePath, LEGACY_METADATA_FILE);
|
||||
return legacy.state === 'valid' ? legacy.value : null;
|
||||
}
|
||||
|
||||
function isOwnedStorage(repoPath, storagePath, repositoryLocal, metadata) {
|
||||
// Repository-local storage remains usable for metadata written before
|
||||
// repoPath was recorded, but an explicit repoPath must never name another
|
||||
// checkout. External storage always requires the complete ownership binding.
|
||||
if (repositoryLocal && (!metadata || typeof metadata.repoPath !== 'string')) {
|
||||
return true;
|
||||
}
|
||||
if (!metadata || typeof metadata.repoPath !== 'string') return false;
|
||||
|
||||
const metadataRepoPath = canonicalize(metadata.repoPath);
|
||||
const expectedRepoPath = canonicalize(repoPath);
|
||||
if (
|
||||
metadataRepoPath == null ||
|
||||
expectedRepoPath == null ||
|
||||
!samePath(metadataRepoPath, expectedRepoPath)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (repositoryLocal) return true;
|
||||
if (typeof metadata.storagePath !== 'string') return false;
|
||||
|
||||
const metadataStoragePath = canonicalize(metadata.storagePath);
|
||||
const expectedStoragePath = canonicalize(storagePath);
|
||||
return (
|
||||
metadataStoragePath != null &&
|
||||
expectedStoragePath != null &&
|
||||
samePath(metadataStoragePath, expectedStoragePath)
|
||||
);
|
||||
}
|
||||
|
||||
function ancestorPaths(cwd) {
|
||||
const paths = [];
|
||||
let current = canonicalize(cwd);
|
||||
while (current) {
|
||||
paths.push(current);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isInsideOrEqual(child, ancestor) {
|
||||
if (child == null || ancestor == null) return false;
|
||||
if (samePath(child, ancestor)) return true;
|
||||
const relative = path.relative(ancestor, child);
|
||||
return (
|
||||
relative !== '' &&
|
||||
relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
function ancestorPathsThrough(cwd, stopAt) {
|
||||
const paths = [];
|
||||
let current = canonicalize(cwd);
|
||||
const stop = canonicalize(stopAt);
|
||||
while (current) {
|
||||
if (stop && !isInsideOrEqual(current, stop)) break;
|
||||
paths.push(current);
|
||||
if (stop && samePath(current, stop)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function currentGitBranch(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const branch = String(result.stdout || '').trim();
|
||||
return branch || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function registryPathsForCwd(cwd) {
|
||||
const fallbackPaths = ancestorPaths(cwd);
|
||||
if (fallbackPaths.length === 0) return { repoPaths: [], branch: null };
|
||||
try {
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (result.error || result.status !== 0) return { repoPaths: fallbackPaths, branch: null };
|
||||
|
||||
const [worktreeRoot, commonDir] = String(result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (!worktreeRoot || !path.isAbsolute(worktreeRoot)) {
|
||||
return { repoPaths: fallbackPaths, branch: null };
|
||||
}
|
||||
|
||||
// Keep ancestor paths of cwd that stay inside this worktree (cwd up to
|
||||
// and including show-toplevel) so a --skip-git subdirectory index can
|
||||
// win via longest-match. Do not walk ancestors outside the worktree —
|
||||
// that would re-attribute a parent index to a nested git checkout.
|
||||
const repoPaths = ancestorPathsThrough(cwd, worktreeRoot);
|
||||
const worktreeCanon = canonicalize(worktreeRoot);
|
||||
if (worktreeCanon && !repoPaths.some((repoPath) => samePath(repoPath, worktreeCanon))) {
|
||||
repoPaths.push(worktreeCanon);
|
||||
}
|
||||
|
||||
// Linked worktrees share the canonical repo's git dir. Include that
|
||||
// parent so the registered main checkout is still discoverable, but do
|
||||
// not walk any further outside this worktree.
|
||||
if (commonDir) {
|
||||
const commonParent = canonicalize(path.dirname(commonDir));
|
||||
if (
|
||||
commonParent &&
|
||||
worktreeCanon &&
|
||||
!samePath(commonParent, worktreeCanon) &&
|
||||
!repoPaths.some((repoPath) => samePath(repoPath, commonParent))
|
||||
) {
|
||||
repoPaths.push(commonParent);
|
||||
}
|
||||
}
|
||||
return {
|
||||
repoPaths,
|
||||
branch: currentGitBranch(cwd),
|
||||
};
|
||||
} catch {
|
||||
return { repoPaths: fallbackPaths, branch: null };
|
||||
}
|
||||
}
|
||||
|
||||
function branchSlug(rawRef) {
|
||||
const sanitized = rawRef.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
|
||||
const safe =
|
||||
!sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized)
|
||||
? 'unknown'
|
||||
: sanitized;
|
||||
const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8);
|
||||
return `${safe}-${hash}`;
|
||||
}
|
||||
|
||||
// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly
|
||||
// (sanitize + sha256 of the canonical repo path, 12-hex suffix).
|
||||
function sanitizeSlotBasename(value) {
|
||||
// Cap first, then walk the tail once — same order as
|
||||
// gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS).
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
const code = sanitized.charCodeAt(end - 1);
|
||||
if (code !== 0x20 && code !== 0x2e) break;
|
||||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
}
|
||||
|
||||
function storageSlotName(repoPath) {
|
||||
const canonical = canonicalize(repoPath);
|
||||
if (!canonical) return null;
|
||||
const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
const basename = sanitizeSlotBasename(path.basename(canonical));
|
||||
const digest = createHash('sha256')
|
||||
.update(identity)
|
||||
.digest('hex')
|
||||
.slice(0, STORAGE_SLOT_HASH_LENGTH);
|
||||
return `${basename}-${digest}`;
|
||||
}
|
||||
|
||||
function envOverridesStorage() {
|
||||
const envPath = process.env[STORAGE_PATH_ENV];
|
||||
const envRoot = process.env[STORAGE_ROOT_ENV];
|
||||
return (
|
||||
(typeof envPath === 'string' && envPath.length > 0) ||
|
||||
(typeof envRoot === 'string' && envRoot.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEntryStoragePath(entry) {
|
||||
const envPath = process.env[STORAGE_PATH_ENV];
|
||||
if (
|
||||
typeof envPath === 'string' &&
|
||||
envPath.length > 0 &&
|
||||
!envPath.includes('\0') &&
|
||||
path.isAbsolute(envPath)
|
||||
) {
|
||||
const resolved = path.resolve(envPath);
|
||||
if (path.isAbsolute(resolved)) return resolved;
|
||||
}
|
||||
|
||||
const envRoot = process.env[STORAGE_ROOT_ENV];
|
||||
if (
|
||||
typeof envRoot === 'string' &&
|
||||
envRoot.length > 0 &&
|
||||
!envRoot.includes('\0') &&
|
||||
path.isAbsolute(envRoot)
|
||||
) {
|
||||
const root = path.resolve(envRoot);
|
||||
const slot = storageSlotName(entry.path);
|
||||
if (slot) {
|
||||
const storagePath = path.join(root, slot);
|
||||
if (samePath(path.dirname(storagePath), root)) return storagePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.storagePath !== undefined) {
|
||||
if (
|
||||
typeof entry.storagePath !== 'string' ||
|
||||
!entry.storagePath ||
|
||||
entry.storagePath.includes('\0') ||
|
||||
!path.isAbsolute(entry.storagePath)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return path.resolve(entry.storagePath);
|
||||
}
|
||||
return path.resolve(path.join(entry.path, GITNEXUS_DIR));
|
||||
}
|
||||
|
||||
function hasLocalIndexSignal(storagePath) {
|
||||
try {
|
||||
return (
|
||||
fs.existsSync(path.join(storagePath, INDEX_METADATA_FILE)) ||
|
||||
fs.existsSync(path.join(storagePath, LBUG_DIRECTORY))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findLocalOwnedRepo(cwd) {
|
||||
// Environment storage overrides win; a leftover repo-local .gitnexus must
|
||||
// not skip the registry scan that applies STORAGE_PATH / STORAGE_ROOT.
|
||||
if (envOverridesStorage()) return null;
|
||||
const { repoPaths, branch } = registryPathsForCwd(cwd);
|
||||
let current = canonicalize(cwd);
|
||||
for (let hops = 0; hops <= LOCAL_OWNED_PARENT_HOPS && current; hops++) {
|
||||
const storagePath = path.join(current, GITNEXUS_DIR);
|
||||
if (hasLocalIndexSignal(storagePath)) {
|
||||
const metadata = readIndexMetadata(storagePath);
|
||||
if (isOwnedStorage(current, storagePath, true, metadata)) {
|
||||
const branchDir =
|
||||
branch != null ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) : null;
|
||||
const indexDir = branchDir && hasLocalIndexSignal(branchDir) ? branchDir : storagePath;
|
||||
return {
|
||||
path: current,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
metadata: indexDir === storagePath ? metadata : readIndexMetadata(indexDir),
|
||||
};
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
// Stay inside this checkout. Registered lookup already stops at
|
||||
// `--show-toplevel`; walking raw parents would adopt `/outer/.gitnexus`
|
||||
// from `/outer/nested-repo`.
|
||||
if (repoPaths.length > 0 && !repoPaths.some((repoPath) => samePath(repoPath, parent))) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findRegisteredRepo(cwd) {
|
||||
const { repoPaths, branch } = registryPathsForCwd(cwd);
|
||||
if (repoPaths.length === 0) return null;
|
||||
|
||||
const home = process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
let entries;
|
||||
try {
|
||||
entries = JSON.parse(fs.readFileSync(path.join(home, 'registry.json'), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entries)) return null;
|
||||
|
||||
let best = null;
|
||||
let bestLen = -1;
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
if (typeof entry.path !== 'string') continue;
|
||||
if (entry.path.includes('\0') || !path.isAbsolute(entry.path)) continue;
|
||||
const registeredPath = canonicalize(entry.path);
|
||||
if (!registeredPath || !repoPaths.some((repoPath) => samePath(repoPath, registeredPath))) {
|
||||
continue;
|
||||
}
|
||||
const storagePath = resolveEntryStoragePath(entry);
|
||||
if (!storagePath) continue;
|
||||
const repositoryLocal = samePath(
|
||||
canonicalize(path.join(entry.path, GITNEXUS_DIR)),
|
||||
canonicalize(storagePath),
|
||||
);
|
||||
const ownershipMetadata = readIndexMetadata(storagePath);
|
||||
if (!isOwnedStorage(entry.path, storagePath, repositoryLocal, ownershipMetadata)) continue;
|
||||
const branchIsIndexed =
|
||||
branch &&
|
||||
Array.isArray(entry.branches) &&
|
||||
entry.branches.some((summary) => summary && summary.branch === branch);
|
||||
const indexDir = branchIsIndexed
|
||||
? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch))
|
||||
: storagePath;
|
||||
if (registeredPath.length > bestLen) {
|
||||
bestLen = registeredPath.length;
|
||||
best = {
|
||||
path: entry.path,
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Registry row wins (including persisted external storagePath); local owned is fallback. */
|
||||
function resolveHookRepo(cwd) {
|
||||
return findRegisteredRepo(cwd) || findLocalOwnedRepo(cwd);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findRegisteredRepo,
|
||||
findLocalOwnedRepo,
|
||||
resolveHookRepo,
|
||||
INDEX_METADATA_FILE,
|
||||
LEGACY_METADATA_FILE,
|
||||
LBUG_DIRECTORY,
|
||||
};
|
||||
|
|
@ -34,6 +34,18 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
|
||||
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
|
||||
|
||||
## Index storage and retention
|
||||
|
||||
Default location is `<repo>/.gitnexus/`. Override with environment variables (also documented in README):
|
||||
|
||||
| Env | Effect |
|
||||
| --- | ------ |
|
||||
| `GITNEXUS_STORAGE_PATH` | One complete external index directory. Wins if both storage vars are set. |
|
||||
| `GITNEXUS_STORAGE_ROOT` | Absolute root; GitNexus creates an isolated `<repo-basename>-<12-hex>/` slot per repository. |
|
||||
| `GITNEXUS_CONTENT_RETENTION` | `full` (default) keeps file text; `symbol` keeps snippets; `none` keeps the graph only. |
|
||||
|
||||
`list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`. MCP `include_content` may still return symbol spans when retention is `symbol`.
|
||||
|
||||
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
|
||||
|
||||
### status — Check index freshness
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { isIndexLockGuardTimeout } from '../storage/index-lock.js';
|
||||
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
|
||||
import { ANALYZE_STORAGE_REQUIREMENTS, requireStoragePath } from '../storage/storage-resolver.js';
|
||||
import { GITNEXUS_DIR } from '../storage/repo-meta.js';
|
||||
import {
|
||||
loadAnalyzeConfigStrict,
|
||||
|
|
@ -414,6 +415,13 @@ export async function watchCommandWithRunnerIdentity(
|
|||
return;
|
||||
}
|
||||
const repoPath = await fs.realpath(requestedRepoPath);
|
||||
try {
|
||||
await requireStoragePath(repoPath, ANALYZE_STORAGE_REQUIREMENTS);
|
||||
} catch (error) {
|
||||
cliError(` ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const baselineEnvironment: WatchEnvironmentBaseline = {
|
||||
maxFileSize: process.env.GITNEXUS_MAX_FILE_SIZE,
|
||||
workerTimeout: process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import {
|
|||
WAL_RECOVERY_SUGGESTION,
|
||||
} from '../core/lbug/lbug-config.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
getGlobalRegistryPath,
|
||||
RegistryNameCollisionError,
|
||||
AnalysisNotFinalizedError,
|
||||
|
|
@ -1420,7 +1419,7 @@ const analyzeCommandImpl = async (
|
|||
// run can write meta.json and then fail before registerRepo(); in
|
||||
// that half-finalized state, runFullAnalysis returns alreadyUpToDate
|
||||
// on the next invocation unless we check the registry here too.
|
||||
await assertAnalysisFinalized(repoPath);
|
||||
await assertAnalysisFinalized(repoPath, result.storagePath);
|
||||
// The fast path skips context regeneration, but a changed `.gitnexusrc`
|
||||
// defaultBranch / `--default-branch` must still take effect. Surgically
|
||||
// refresh just the `base_ref` line in AGENTS.md/CLAUDE.md in place,
|
||||
|
|
@ -1492,7 +1491,7 @@ const analyzeCommandImpl = async (
|
|||
// success so the silent-finalize state surfaces with a non-zero
|
||||
// exit code and an actionable error instead of being mistaken for
|
||||
// a healthy index.
|
||||
await assertAnalysisFinalized(repoPath);
|
||||
await assertAnalysisFinalized(repoPath, result.storagePath);
|
||||
|
||||
// Skill generation (CLI-only, uses pipeline result from analysis).
|
||||
// Gated so `--index-only --skills` skips community skill writes too
|
||||
|
|
@ -1523,10 +1522,9 @@ const analyzeCommandImpl = async (
|
|||
(count: number) => count >= 5,
|
||||
).length;
|
||||
}
|
||||
const { storagePath: sp } = getStoragePaths(repoPath);
|
||||
await generateAIContextFiles(
|
||||
repoPath,
|
||||
sp,
|
||||
result.storagePath,
|
||||
result.repoName,
|
||||
{
|
||||
files: s.files ?? 0,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ import {
|
|||
findRepo,
|
||||
unregisterRepo,
|
||||
listRegisteredRepos,
|
||||
assertSafeStoragePath,
|
||||
getStoragePaths,
|
||||
removeBranchIndex,
|
||||
UnsafeStoragePathError,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js';
|
||||
import {
|
||||
cleanParkedLbugSidecars,
|
||||
inspectLbugSidecars,
|
||||
|
|
@ -47,14 +46,28 @@ export const cleanCommand = async (options?: {
|
|||
console.log(t('clean.branchNotIndexed', { branch: options.branch }));
|
||||
return;
|
||||
}
|
||||
const { storagePath, lbugPath } = getStoragePaths(repo.repoPath, summary.branch);
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireDeletableStoragePath({
|
||||
path: repo.repoPath,
|
||||
storagePath: repo.storagePath,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to clean branch index: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const { lbugPath } = getStoragePaths(repo.repoPath, summary.branch, storagePath);
|
||||
const branchDir = path.dirname(lbugPath);
|
||||
// Safety guard: the target MUST live under <repo>/.gitnexus/branches/.
|
||||
// assertSafeStoragePath only validates the flat `<repo>/.gitnexus`, so this
|
||||
// is a dedicated branches-sub-dir check before any destructive fs.rm.
|
||||
// Safety guard: the target MUST live under the validated
|
||||
// storage slot's `branches/` directory before any destructive fs.rm.
|
||||
const branchesRoot = path.join(storagePath, 'branches') + path.sep;
|
||||
if (!branchDir.startsWith(branchesRoot)) {
|
||||
logger.error(`Refusing to clean branch index outside .gitnexus/branches: ${branchDir}`);
|
||||
logger.error(
|
||||
`Refusing to clean branch index outside the validated storage slot: ${branchDir}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!options.force) {
|
||||
|
|
@ -81,7 +94,20 @@ export const cleanCommand = async (options?: {
|
|||
return;
|
||||
}
|
||||
|
||||
const lbugPath = path.join(repo.storagePath, 'lbug');
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireDeletableStoragePath({
|
||||
path: repo.repoPath,
|
||||
storagePath: repo.storagePath,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to clean sidecars: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const { lbugPath } = getStoragePaths(repo.repoPath, undefined, storagePath);
|
||||
const state = await inspectLbugSidecars(lbugPath);
|
||||
// Single roster authority (this shipping review, FIX 5): the aggregate
|
||||
// covers both parked-sidecar families — the timestamped missing-shadow
|
||||
|
|
@ -121,45 +147,44 @@ export const cleanCommand = async (options?: {
|
|||
|
||||
// --all flag: clean all indexed repos
|
||||
if (options?.all) {
|
||||
const entries = await listRegisteredRepos();
|
||||
if (!options?.force) {
|
||||
const entries = await listRegisteredRepos();
|
||||
if (entries.length === 0) {
|
||||
const deletableEntries = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await requireDeletableStoragePath(entry);
|
||||
deletableEntries.push(entry);
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to preview ${entry.name}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (deletableEntries.length === 0) {
|
||||
console.log(t('common.notIndexed'));
|
||||
return;
|
||||
}
|
||||
console.log(t('clean.deleteAll', { count: entries.length }));
|
||||
for (const entry of entries) {
|
||||
console.log(t('clean.deleteAll', { count: deletableEntries.length }));
|
||||
for (const entry of deletableEntries) {
|
||||
console.log(` - ${entry.name} (${entry.path})`);
|
||||
}
|
||||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await listRegisteredRepos();
|
||||
for (const entry of entries) {
|
||||
// Safety guard (#1003 review — @magyargergo): same rationale as
|
||||
// remove.ts. `~/.gitnexus/registry.json` is user-writable, so a
|
||||
// corrupted or hand-edited entry could point storagePath at the
|
||||
// repo root, an empty string, or anywhere else — and
|
||||
// fs.rm(recursive: true) on any of those would be catastrophic.
|
||||
// Skip poisoned entries without touching disk, but keep going
|
||||
// through the rest of the registry (preserves the existing
|
||||
// per-repo error-tolerance semantics of `clean --all`).
|
||||
try {
|
||||
assertSafeStoragePath(entry);
|
||||
const storagePath = await requireDeletableStoragePath(entry);
|
||||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(entry.path);
|
||||
console.log(t('clean.deletedRepo', { name: entry.name, storagePath }));
|
||||
} catch (err) {
|
||||
if (err instanceof UnsafeStoragePathError) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to clean ${entry.name}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(entry.storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(entry.path);
|
||||
console.log(t('clean.deletedRepo', { name: entry.name, storagePath: entry.storagePath }));
|
||||
} catch (err) {
|
||||
logger.error({ err }, `Failed to delete ${entry.name}:`);
|
||||
}
|
||||
}
|
||||
|
|
@ -176,18 +201,31 @@ export const cleanCommand = async (options?: {
|
|||
}
|
||||
|
||||
const repoName = repo.repoPath.split(/[/\\]/).pop() || repo.repoPath;
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireDeletableStoragePath({
|
||||
path: repo.repoPath,
|
||||
storagePath: repo.storagePath,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to clean ${repoName}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!options?.force) {
|
||||
console.log(t('clean.deleteCurrent', { repoName }));
|
||||
console.log(` ${t('common.path')}: ${repo.storagePath}`);
|
||||
console.log(` ${t('common.path')}: ${storagePath}`);
|
||||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(repo.storagePath, { recursive: true, force: true });
|
||||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(repo.repoPath);
|
||||
console.log(t('common.deleted', { target: repo.storagePath }));
|
||||
console.log(t('common.deleted', { target: storagePath }));
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete:');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -358,5 +358,5 @@ export const en = {
|
|||
'help.identityCache.environment':
|
||||
'\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.',
|
||||
'help.analyze.environment':
|
||||
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated <repo-basename>-<canonical-path-hash>/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -331,5 +331,5 @@ export const zhCN = {
|
|||
'help.identityCache.environment':
|
||||
'\n分析器身份缓存:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n 由操作员明确信任的持久缓存,用于跨进程快速查询状态。目录必须预先存在、位于 GitNexus 包/构建根目录之外,且路径中不得包含符号链接或 junction。缺少 POSIX 所有权 API 的平台默认保持故障关闭。',
|
||||
'help.analyze.environment':
|
||||
'\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
||||
'\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
||||
} satisfies EnglishMessages;
|
||||
|
|
|
|||
|
|
@ -16,13 +16,17 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import {
|
||||
getStoragePaths,
|
||||
INDEX_METADATA_FILE,
|
||||
loadMeta,
|
||||
saveMeta,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
|
||||
import {
|
||||
getIndexStorageRequirements,
|
||||
requireStoragePath,
|
||||
StorageRequirementError,
|
||||
} from '../storage/storage-resolver.js';
|
||||
|
||||
export interface IndexOptions {
|
||||
force?: boolean;
|
||||
|
|
@ -69,46 +73,39 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
|
|||
return;
|
||||
}
|
||||
|
||||
const { storagePath, lbugPath } = getStoragePaths(repoPath);
|
||||
|
||||
// ── Verify index exists (metadata file, legacy metadata, or restorable DB) ─
|
||||
let hasMetadataIndex = false;
|
||||
let hasLegacyIndex = false;
|
||||
let hasLbugIndex = false;
|
||||
|
||||
let storagePath: string;
|
||||
try {
|
||||
await fs.access(path.join(storagePath, INDEX_METADATA_FILE));
|
||||
hasMetadataIndex = true;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await fs.access(path.join(storagePath, 'meta.json'));
|
||||
hasLegacyIndex = true;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await fs.access(lbugPath);
|
||||
hasLbugIndex = true;
|
||||
} catch {}
|
||||
|
||||
if (!hasMetadataIndex && !hasLegacyIndex && !hasLbugIndex) {
|
||||
console.log(` No GitNexus index found.`);
|
||||
console.log(` Expected gitnexus.json, .gitnexus/meta.json, or LadybugDB at: ${storagePath}`);
|
||||
console.log(' Run `gitnexus analyze` to build the index first.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Verify lbug database exists ───────────────────────────────────
|
||||
if (!hasLbugIndex) {
|
||||
console.log(` Index exists but contains no LadybugDB database.`);
|
||||
console.log(' Run `gitnexus analyze` to build the index.\n');
|
||||
storagePath = await requireStoragePath(repoPath, getIndexStorageRequirements(!!options?.force));
|
||||
} catch (error) {
|
||||
if (!(error instanceof StorageRequirementError)) {
|
||||
console.log(` ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const inspection = error.inspection;
|
||||
if (inspection.state === 'missing' || inspection.state === 'empty') {
|
||||
console.log(` No GitNexus index found.`);
|
||||
console.log(
|
||||
` Expected gitnexus.json, .gitnexus/meta.json, or LadybugDB at: ${inspection.storagePath}`,
|
||||
);
|
||||
console.log(' Run `gitnexus analyze` to build the index first.\n');
|
||||
} else if (inspection.state === 'unowned' && !options?.force) {
|
||||
console.log(` gitnexus.json or .gitnexus/meta.json is missing.`);
|
||||
console.log(' Use --force to register anyway (stats will be empty),');
|
||||
console.log(' or run `gitnexus analyze` to rebuild properly.\n');
|
||||
} else if (!inspection.hasCodeIndexDB) {
|
||||
console.log(` Index exists but contains no LadybugDB database.`);
|
||||
console.log(' Run `gitnexus analyze` to build the index.\n');
|
||||
} else {
|
||||
console.log(` ${error.message}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Load or reconstruct meta ──────────────────────────────────────
|
||||
let meta = await loadMeta(storagePath);
|
||||
let reconstructedMeta = false;
|
||||
|
||||
if (!meta) {
|
||||
if (!options?.force) {
|
||||
|
|
@ -122,9 +119,27 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
|
|||
// --force: build a minimal meta so the repo can be registered
|
||||
meta = {
|
||||
repoPath,
|
||||
storagePath,
|
||||
lastCommit: '',
|
||||
indexedAt: new Date().toISOString(),
|
||||
};
|
||||
reconstructedMeta = true;
|
||||
}
|
||||
|
||||
// `index --force` is the explicit adoption path for an existing external
|
||||
// database whose legacy metadata predates storagePath binding, and for a
|
||||
// repository-local slot whose metadata still names another checkout.
|
||||
if (options?.force) {
|
||||
const adoptedRepoPath = path.resolve(repoPath);
|
||||
const adoptedStoragePath = path.resolve(storagePath);
|
||||
const ownershipChanged =
|
||||
path.resolve(meta.repoPath) !== adoptedRepoPath ||
|
||||
meta.storagePath === undefined ||
|
||||
path.resolve(meta.storagePath) !== adoptedStoragePath;
|
||||
if (ownershipChanged) {
|
||||
meta = { ...meta, repoPath: adoptedRepoPath, storagePath: adoptedStoragePath };
|
||||
reconstructedMeta = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Register in global registry ───────────────────────────────────
|
||||
|
|
@ -135,8 +150,11 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
|
|||
if (!meta.remoteUrl && isGitRepo(repoPath)) {
|
||||
meta.remoteUrl = getRemoteUrl(repoPath);
|
||||
}
|
||||
await registerRepo(repoPath, meta);
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
if (reconstructedMeta) {
|
||||
await saveMeta(storagePath, meta);
|
||||
}
|
||||
await registerRepo(repoPath, meta, { storagePath });
|
||||
await ensureGitNexusIgnored(repoPath, storagePath);
|
||||
|
||||
const projectName = path.basename(repoPath);
|
||||
const { stats } = meta;
|
||||
|
|
|
|||
|
|
@ -290,7 +290,8 @@ program
|
|||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show index status for current repo')
|
||||
.description('Show index status for the current repo or a registered index')
|
||||
.option('-r, --repo <name>', 'Registered repository alias or path (works after checkout removal)')
|
||||
.option('--json', 'Emit machine-readable index and analyzer provenance')
|
||||
.addHelpText('after', () => t('help.identityCache.environment'))
|
||||
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
|
||||
|
|
@ -412,7 +413,10 @@ program
|
|||
.option('-c, --context <text>', 'Task context to improve ranking')
|
||||
.option('-g, --goal <text>', 'What you want to find')
|
||||
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
|
||||
.option('--content', 'Include full symbol source code')
|
||||
.option(
|
||||
'--content',
|
||||
'Include retained symbol source text (reports availability when disabled by retention)',
|
||||
)
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'queryCommand'));
|
||||
|
||||
program
|
||||
|
|
@ -423,7 +427,10 @@ program
|
|||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option('-l, --limit <n>', 'Max callers/callees/processes to return')
|
||||
.option('--content', 'Include full symbol source code')
|
||||
.option(
|
||||
'--content',
|
||||
'Include retained symbol source text (reports availability when disabled by retention)',
|
||||
)
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
|
||||
program
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ import {
|
|||
parseOwnerRepoFromRemote,
|
||||
} from 'gitnexus-shared';
|
||||
import { getGitRoot, getRemoteOriginUrl, getCurrentCommit } from '../storage/git.js';
|
||||
import { hasIndex } from '../storage/repo-manager.js';
|
||||
import {
|
||||
requireStoragePath,
|
||||
STATUS_STORAGE_REQUIREMENTS,
|
||||
StorageRequirementError,
|
||||
isUnusableIndexInspection,
|
||||
} from '../storage/storage-resolver.js';
|
||||
import { cliInfo, cliError } from './cli-message.js';
|
||||
|
||||
export interface PublishOptions {
|
||||
|
|
@ -95,9 +100,18 @@ export const publishCommand = async (
|
|||
// Publishing without an index is almost always a mistake — the
|
||||
// registry's nightly sync would fetch a stale or missing graph file
|
||||
// and mark the entry `missing`. Refuse loudly with a fix-it hint.
|
||||
if (!(await hasIndex(repoPath))) {
|
||||
try {
|
||||
await requireStoragePath(repoPath, STATUS_STORAGE_REQUIREMENTS);
|
||||
} catch (error) {
|
||||
if (!(error instanceof StorageRequirementError)) throw error;
|
||||
const inspection = error.inspection;
|
||||
if (!isUnusableIndexInspection(inspection)) {
|
||||
cliError(`[understand-quickly] ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
cliError(
|
||||
`[understand-quickly] no GitNexus index found at ${repoPath}/.gitnexus.\n` +
|
||||
`[understand-quickly] no usable GitNexus index found for ${repoPath}.\n` +
|
||||
'Run `gitnexus analyze` first, then re-run `gitnexus publish`.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
|
|
|
|||
|
|
@ -36,12 +36,11 @@ import { t } from './i18n/index.js';
|
|||
import {
|
||||
readRegistry,
|
||||
resolveRegistryEntry,
|
||||
assertSafeStoragePath,
|
||||
unregisterRepo,
|
||||
RegistryNotFoundError,
|
||||
RegistryAmbiguousTargetError,
|
||||
UnsafeStoragePathError,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js';
|
||||
|
||||
export const removeCommand = async (target: string, options?: { force?: boolean }) => {
|
||||
// Read the registry snapshot once and pass it to the resolver — this
|
||||
|
|
@ -80,18 +79,13 @@ export const removeCommand = async (target: string, options?: { force?: boolean
|
|||
return;
|
||||
}
|
||||
|
||||
// Safety guard (#1003 review — @magyargergo): refuse to proceed if
|
||||
// the registry entry's `storagePath` isn't the canonical
|
||||
// `<entry.path>/.gitnexus` subfolder. `~/.gitnexus/registry.json` is
|
||||
// user-writable, so a corrupted or hand-edited entry could point
|
||||
// storagePath at the repo root, an empty string (→ cwd), a parent
|
||||
// dir, or anywhere else; `fs.rm(recursive: true, force: true)` on
|
||||
// any of those would be a runtime disaster. Bail before touching
|
||||
// disk, with an actionable hint for recovering a broken registry.
|
||||
// Validate immediately before deletion. `--force` skips confirmation only;
|
||||
// it does not bypass the ownership and dangerous-path checks.
|
||||
let storagePath: string;
|
||||
try {
|
||||
assertSafeStoragePath(entry);
|
||||
storagePath = await requireDeletableStoragePath(entry);
|
||||
} catch (err) {
|
||||
if (err instanceof UnsafeStoragePathError) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
cliError(t('common.error', { message: err.message }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -104,7 +98,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean
|
|||
// orphaned — `listRegisteredRepos({ validate: true })` prunes those on
|
||||
// next read, so the failure is self-healing.
|
||||
try {
|
||||
await fs.rm(entry.storagePath, { recursive: true, force: true });
|
||||
await fs.rm(storagePath, { recursive: true, force: true });
|
||||
await unregisterRepo(entry.path);
|
||||
console.log(t('remove.removed', { name: entry.name }));
|
||||
console.log(` ${t('common.path')}: ${entry.path}`);
|
||||
|
|
|
|||
|
|
@ -450,6 +450,7 @@ const HOOK_HELPERS = [
|
|||
'hook-db-lock-probe.cjs',
|
||||
'win-rm-list-json.ps1',
|
||||
'resolve-analyze-cmd.cjs',
|
||||
'registry-query.cjs',
|
||||
] as const;
|
||||
|
||||
// win-rm-list-json.ps1 is best-effort: it is read (not require()'d) by
|
||||
|
|
|
|||
|
|
@ -5,7 +5,22 @@
|
|||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { findRepo, getStoragePaths, loadMeta, hasKuzuIndex } from '../storage/repo-manager.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
hasKuzuIndex,
|
||||
readRegistryStrict,
|
||||
resolveRegistryEntry,
|
||||
RegistryNotFoundError,
|
||||
RegistryAmbiguousTargetError,
|
||||
} from '../storage/repo-manager.js';
|
||||
import {
|
||||
requireRegisteredStoragePath,
|
||||
requireStoragePath,
|
||||
STATUS_STORAGE_REQUIREMENTS,
|
||||
StorageRequirementError,
|
||||
isUnusableIndexInspection,
|
||||
} from '../storage/storage-resolver.js';
|
||||
import {
|
||||
getCurrentCommit,
|
||||
getCurrentBranch,
|
||||
|
|
@ -20,6 +35,11 @@ import {
|
|||
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
|
||||
import { getFtsDisabledReason, FTS_DISABLED_MESSAGE } from '../core/search/fts-policy.js';
|
||||
import { detectIndexContentDrift, type IndexContentDrift } from '../core/index-content-drift.js';
|
||||
import {
|
||||
checkoutIsDirectory,
|
||||
contentRetentionFromMeta,
|
||||
isFullSourceAvailable,
|
||||
} from '../core/content-retention.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
/** How many drifted paths the report names before summarizing the rest. */
|
||||
|
|
@ -82,11 +102,141 @@ const printDriftDetail = (drift: Extract<IndexContentDrift, { kind: 'drifted' }>
|
|||
}
|
||||
};
|
||||
|
||||
const isExpectedUnindexedStatus = (error: StorageRequirementError): boolean =>
|
||||
isUnusableIndexInspection(error.inspection);
|
||||
|
||||
const printNotIndexed = (repoPath: string, storagePath: string, json: boolean): void => {
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
repository: repoPath,
|
||||
storagePath,
|
||||
error: 'not-indexed',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.log(t('status.repoNotIndexed'));
|
||||
console.log(t('common.runAnalyzeShort'));
|
||||
}
|
||||
};
|
||||
|
||||
export interface StatusOptions {
|
||||
json?: boolean;
|
||||
/** Resolve a registered index without requiring its original checkout to remain on disk. */
|
||||
repo?: string;
|
||||
}
|
||||
|
||||
export const statusCommand = async (options: StatusOptions = {}) => {
|
||||
if (options.repo) {
|
||||
let entry;
|
||||
try {
|
||||
entry = resolveRegistryEntry(await readRegistryStrict(), options.repo);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
if (err instanceof RegistryNotFoundError || err instanceof RegistryAmbiguousTargetError) {
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({ schemaVersion: 1, repository: options.repo, error: 'not-indexed' }),
|
||||
);
|
||||
} else {
|
||||
console.log(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS);
|
||||
} catch (err) {
|
||||
if (!(err instanceof StorageRequirementError) || !isExpectedUnindexedStatus(err)) throw err;
|
||||
if (err.inspection.state === 'owned' && !err.inspection.hasCodeIndexDB) {
|
||||
const staleKuzu = await hasKuzuIndex(err.inspection.storagePath);
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
repository: entry.path,
|
||||
storagePath: err.inspection.storagePath,
|
||||
error: staleKuzu ? 'stale-kuzu-index' : 'not-indexed',
|
||||
}),
|
||||
);
|
||||
} else if (staleKuzu) {
|
||||
console.log(t('status.staleKuzu'));
|
||||
console.log(t('status.rebuildLadybug'));
|
||||
} else {
|
||||
console.log(`No usable code index at ${err.inspection.storagePath}`);
|
||||
}
|
||||
} else {
|
||||
printNotIndexed(entry.path, err.inspection.storagePath, Boolean(options.json));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = await loadMeta(storagePath);
|
||||
if (!meta) {
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
repository: entry.path,
|
||||
storagePath,
|
||||
error: 'not-indexed',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
console.log(`No readable index metadata at ${storagePath}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRunnerIdentity = resolveAnalyzerRunnerIdentity(import.meta.url);
|
||||
const runnerIdentityIsCurrent = analyzerRunnerIdentitiesEqual(
|
||||
meta.runnerIdentity,
|
||||
currentRunnerIdentity,
|
||||
);
|
||||
const incompleteReasons = getIndexIncompleteReasons(meta);
|
||||
const sourceAvailable = isFullSourceAvailable(
|
||||
contentRetentionFromMeta(meta),
|
||||
await checkoutIsDirectory(entry.path),
|
||||
);
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
repository: entry.path,
|
||||
storagePath,
|
||||
sourceAvailable,
|
||||
index: {
|
||||
indexedAt: meta.indexedAt,
|
||||
commit: meta.lastCommit,
|
||||
runnerIdentity: meta.runnerIdentity ?? null,
|
||||
runnerIdentityStatus: runnerIdentityIsCurrent ? 'current' : 'stale-or-unknown',
|
||||
incompleteReasons,
|
||||
contentRetention: meta.contentRetention ?? 'full',
|
||||
},
|
||||
current: sourceAvailable ? { commit: getCurrentCommit(entry.path) } : null,
|
||||
// Without a checkout GitNexus can prove the index is readable, but cannot
|
||||
// certify that it is current relative to source. Keep that distinction in
|
||||
// the machine-readable status instead of reporting a false all-clear.
|
||||
status: sourceAvailable ? 'registered' : 'source-unavailable',
|
||||
};
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(payload));
|
||||
} else {
|
||||
console.log(`Repository: ${entry.path}`);
|
||||
console.log(`Index storage: ${storagePath}`);
|
||||
console.log(`Indexed: ${new Date(meta.indexedAt).toLocaleString()}`);
|
||||
console.log(`Indexed commit: ${meta.lastCommit?.slice(0, 7)}`);
|
||||
console.log(
|
||||
sourceAvailable
|
||||
? 'Status: registered index (use status without --repo for working-tree freshness)'
|
||||
: 'Status: source checkout unavailable; graph index remains queryable through the registry',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
|
||||
if (!isGitRepo(cwd)) {
|
||||
|
|
@ -98,23 +248,33 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const repo = await findRepo(cwd);
|
||||
if (!repo) {
|
||||
// Check if there's a stale KuzuDB index that needs migration
|
||||
const repoRoot = getGitRoot(cwd) ?? cwd;
|
||||
const { storagePath } = getStoragePaths(repoRoot);
|
||||
const staleKuzu = await hasKuzuIndex(storagePath);
|
||||
const repoPath = getGitRoot(cwd);
|
||||
if (!repoPath) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ schemaVersion: 1, error: 'not-git-repository' }));
|
||||
return;
|
||||
}
|
||||
console.log(t('status.notGitRepo'));
|
||||
return;
|
||||
}
|
||||
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireStoragePath(repoPath, STATUS_STORAGE_REQUIREMENTS);
|
||||
} catch (err) {
|
||||
if (!(err instanceof StorageRequirementError) || !isExpectedUnindexedStatus(err)) throw err;
|
||||
const inspection = err.inspection;
|
||||
const staleKuzu = await hasKuzuIndex(inspection.storagePath);
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
repository: repoRoot,
|
||||
repository: repoPath,
|
||||
storagePath: inspection.storagePath,
|
||||
error: staleKuzu ? 'stale-kuzu-index' : 'not-indexed',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (staleKuzu) {
|
||||
} else if (staleKuzu) {
|
||||
console.log(t('status.staleKuzu'));
|
||||
console.log(t('status.rebuildLadybug'));
|
||||
} else {
|
||||
|
|
@ -124,6 +284,18 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const meta = await loadMeta(storagePath);
|
||||
if (!meta) {
|
||||
printNotIndexed(repoPath, storagePath, Boolean(options.json));
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = {
|
||||
repoPath,
|
||||
storagePath,
|
||||
meta,
|
||||
};
|
||||
|
||||
const currentCommit = getCurrentCommit(repo.repoPath);
|
||||
const currentBranch = getCurrentBranch(repo.repoPath);
|
||||
|
||||
|
|
@ -135,7 +307,7 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
let activeMeta = repo.meta;
|
||||
let workspaceLagsBranch = false;
|
||||
if (currentBranch && repo.meta.branch && currentBranch !== repo.meta.branch) {
|
||||
const { metaPath } = getStoragePaths(repo.repoPath, currentBranch);
|
||||
const { metaPath } = getStoragePaths(repo.repoPath, currentBranch, repo.storagePath);
|
||||
const branchMeta = await loadMeta(path.dirname(metaPath));
|
||||
if (branchMeta) activeMeta = branchMeta;
|
||||
else workspaceLagsBranch = true;
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ import readline from 'readline';
|
|||
import { execSync, execFileSync } from 'child_process';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { getGitRoot, isGitRepo } from '../storage/git.js';
|
||||
import { getStoragePaths, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
loadCLIConfig,
|
||||
saveCLIConfig,
|
||||
} from '../storage/repo-manager.js';
|
||||
requireStoragePath,
|
||||
STATUS_STORAGE_REQUIREMENTS,
|
||||
StorageRequirementError,
|
||||
isUnusableIndexInspection,
|
||||
} from '../storage/storage-resolver.js';
|
||||
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
|
||||
import {
|
||||
MINIMAX_MODEL_IDS,
|
||||
|
|
@ -183,15 +184,23 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
|||
}
|
||||
|
||||
// ── Check for existing index ────────────────────────────────────────
|
||||
const { storagePath, lbugPath } = getStoragePaths(repoPath);
|
||||
const meta = await loadMeta(storagePath);
|
||||
|
||||
if (!meta) {
|
||||
console.log(' Error: No GitNexus index found.');
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireStoragePath(repoPath, STATUS_STORAGE_REQUIREMENTS);
|
||||
} catch (error) {
|
||||
if (!(error instanceof StorageRequirementError)) throw error;
|
||||
const inspection = error.inspection;
|
||||
if (!isUnusableIndexInspection(inspection)) {
|
||||
console.log(` Error: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(` Error: No GitNexus index found at ${error.inspection.storagePath}.`);
|
||||
console.log(' Run `gitnexus analyze` first to index this repository.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const { lbugPath } = getStoragePaths(repoPath, undefined, storagePath);
|
||||
|
||||
let timeoutSeconds: number | undefined;
|
||||
let retries: number | undefined;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@
|
|||
|
||||
import path from 'path';
|
||||
import { listRegisteredRepos } from '../../storage/repo-manager.js';
|
||||
import {
|
||||
requireRegisteredStoragePath,
|
||||
STATUS_STORAGE_REQUIREMENTS,
|
||||
} from '../../storage/storage-resolver.js';
|
||||
import { LBUG_DIRECTORY } from '../../storage/storage-constants.js';
|
||||
import { BRANCHES_DIR, branchSlug } from '../../storage/branch-index.js';
|
||||
import { getCurrentBranch } from '../../storage/git.js';
|
||||
import { escapeCypherString } from '../lbug/cypher-escape.js';
|
||||
|
||||
/**
|
||||
|
|
@ -44,19 +51,15 @@ async function findRepoForCwd(cwd: string): Promise<{
|
|||
const repoResolved = path.resolve(entry.path);
|
||||
const normalizedRepo = isWindows ? repoResolved.toLowerCase() : repoResolved;
|
||||
|
||||
// Check if cwd is inside repo OR repo is inside cwd
|
||||
// Must match at a path separator boundary to avoid false positives
|
||||
// (e.g. /projects/gitnexusv2 should NOT match /projects/gitnexus)
|
||||
// Exact path, or cwd inside the repo at a separator boundary.
|
||||
// Parent-directory invocation must not attach to a nested registered checkout.
|
||||
let matched = false;
|
||||
if (normalizedCwd === normalizedRepo) {
|
||||
matched = true;
|
||||
} else {
|
||||
const repoPrefix = normalizedRepo.endsWith(sep) ? normalizedRepo : normalizedRepo + sep;
|
||||
const cwdPrefix = normalizedCwd.endsWith(sep) ? normalizedCwd : normalizedCwd + sep;
|
||||
if (normalizedCwd.startsWith(repoPrefix)) {
|
||||
matched = true;
|
||||
} else if (normalizedRepo.startsWith(cwdPrefix)) {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,10 +71,20 @@ async function findRepoForCwd(cwd: string): Promise<{
|
|||
|
||||
if (!bestMatch) return null;
|
||||
|
||||
const storagePath = await requireRegisteredStoragePath(bestMatch, STATUS_STORAGE_REQUIREMENTS);
|
||||
const branch = getCurrentBranch(bestMatch.path);
|
||||
const branchIsIndexed =
|
||||
Boolean(branch) &&
|
||||
Array.isArray(bestMatch.branches) &&
|
||||
bestMatch.branches.some((summary) => summary.branch === branch);
|
||||
const indexDir =
|
||||
branchIsIndexed && branch
|
||||
? path.join(storagePath, BRANCHES_DIR, branchSlug(branch))
|
||||
: storagePath;
|
||||
return {
|
||||
name: bestMatch.name,
|
||||
storagePath: bestMatch.storagePath,
|
||||
lbugPath: path.join(bestMatch.storagePath, 'lbug'),
|
||||
storagePath,
|
||||
lbugPath: path.join(indexDir, LBUG_DIRECTORY),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
|
|||
92
gitnexus/src/core/content-retention.ts
Normal file
92
gitnexus/src/core/content-retention.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import type { KnowledgeGraph } from './graph/types.js';
|
||||
import {
|
||||
CONTENT_RETENTION_SCHEMA_VERSION,
|
||||
type ContentRetention,
|
||||
type FtsProfile,
|
||||
type RepoMeta,
|
||||
} from '../storage/repo-meta.js';
|
||||
|
||||
export const CONTENT_RETENTION_ENV = 'GITNEXUS_CONTENT_RETENTION';
|
||||
|
||||
export const contentRetentionFromEnvironment = (): ContentRetention => {
|
||||
const raw = process.env[CONTENT_RETENTION_ENV];
|
||||
if (raw === undefined || raw.trim() === '') return 'full';
|
||||
const value = raw.trim();
|
||||
if (value === 'full' || value === 'symbol' || value === 'none') return value;
|
||||
throw new Error(
|
||||
`Invalid ${CONTENT_RETENTION_ENV} "${raw}". Expected one of: full, symbol, none.`,
|
||||
);
|
||||
};
|
||||
|
||||
export const contentRetentionFromMeta = (
|
||||
meta: Pick<RepoMeta, 'contentRetention'> | null | undefined,
|
||||
): ContentRetention => {
|
||||
const retention = meta?.contentRetention;
|
||||
if (retention === undefined || retention === 'full') return 'full';
|
||||
if (retention === 'symbol' || retention === 'none') return retention;
|
||||
// Legacy metadata has no field; an explicit unknown value is corrupt and must not expose text.
|
||||
return 'none';
|
||||
};
|
||||
|
||||
export const ftsProfileForContentRetention = (retention: ContentRetention): FtsProfile => {
|
||||
switch (retention) {
|
||||
case 'symbol':
|
||||
return 'symbol-no-file-content';
|
||||
case 'none':
|
||||
return 'name-only';
|
||||
default:
|
||||
return 'full';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy metadata predates retention fields and is therefore semantically full.
|
||||
* It remains incrementally readable under the default profile; explicit newer
|
||||
* stamps must match exactly because an FTS/layout change requires a fresh DB.
|
||||
*/
|
||||
export const contentRetentionMismatch = (
|
||||
meta: Pick<RepoMeta, 'contentRetention' | 'contentRetentionSchemaVersion' | 'ftsProfile'>,
|
||||
requested: ContentRetention,
|
||||
): boolean => {
|
||||
if (meta.contentRetention === undefined) return requested !== 'full';
|
||||
return (
|
||||
meta.contentRetention !== requested ||
|
||||
meta.contentRetentionSchemaVersion !== CONTENT_RETENTION_SCHEMA_VERSION ||
|
||||
meta.ftsProfile !== ftsProfileForContentRetention(requested)
|
||||
);
|
||||
};
|
||||
|
||||
/** True when `repoPath` exists and is a directory (uploads / `--allow-non-git` included). */
|
||||
export const checkoutIsDirectory = async (repoPath: string): Promise<boolean> => {
|
||||
try {
|
||||
return (await fs.stat(repoPath)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Full-file HTTP/MCP source is available only for `full` retention plus a live checkout. */
|
||||
export const isFullSourceAvailable = (
|
||||
retention: ContentRetention,
|
||||
checkoutIsDir: boolean,
|
||||
): boolean => retention === 'full' && checkoutIsDir;
|
||||
|
||||
/** Remove text that the active index profile is not allowed to persist. */
|
||||
export const applyContentRetention = (graph: KnowledgeGraph, retention: ContentRetention): void => {
|
||||
if (retention === 'full') return;
|
||||
|
||||
graph.forEachNode((node) => {
|
||||
if (retention === 'symbol') {
|
||||
if (node.label === 'File') delete node.properties.content;
|
||||
// BasicBlocks hold statement source, not a symbol snippet.
|
||||
if (node.label === 'BasicBlock') delete node.properties.text;
|
||||
return;
|
||||
}
|
||||
|
||||
delete node.properties.content;
|
||||
|
||||
delete node.properties.description;
|
||||
if (node.label === 'BasicBlock') delete node.properties.text;
|
||||
});
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Stats } from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
|
@ -16,6 +17,7 @@ import { recordedMatchStages, recordedRepoList } from './completeness.js';
|
|||
import {
|
||||
closeLbugConnection,
|
||||
openLbugConnection,
|
||||
sleep,
|
||||
type LbugConnectionHandle,
|
||||
} from '../lbug/lbug-config.js';
|
||||
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
|
|
@ -667,6 +669,47 @@ export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promi
|
|||
await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(persisted, null, 2));
|
||||
}
|
||||
|
||||
const sameFileStamp = (
|
||||
left: { size: number; mtimeMs: number },
|
||||
right: { size: number; mtimeMs: number },
|
||||
): boolean => left.size === right.size && left.mtimeMs === right.mtimeMs;
|
||||
|
||||
const stampMatchesStat = (
|
||||
stat: { size: number; mtimeMs: number },
|
||||
meta: Pick<BridgeMeta, 'bridgeSize' | 'bridgeMtimeMs'>,
|
||||
): boolean =>
|
||||
stat.size === meta.bridgeSize &&
|
||||
// Do not `Math.round`: two same-size databases written in the same
|
||||
// millisecond must still fail if their filesystem mtimes differ.
|
||||
stat.mtimeMs === meta.bridgeMtimeMs;
|
||||
|
||||
/**
|
||||
* LadybugDB can still flush WAL/shadow into the main file after `close` and
|
||||
* the atomic rename. Stamping the first `stat` then loses the exact-equality
|
||||
* check the moment that flush lands.
|
||||
*
|
||||
* Wait a quiet interval before every sample — including the first — so an
|
||||
* initially-stable pair is not stamped on the same tick as close/rename. Two
|
||||
* consecutive agreeing stats after that interval are treated as settled. A
|
||||
* flush that arrives later than the retry budget can still miss; this is a
|
||||
* best-effort wait, not a lock on the file.
|
||||
*/
|
||||
const BRIDGE_SETTLE_MS = 10;
|
||||
const BRIDGE_SETTLE_ATTEMPTS = 10;
|
||||
|
||||
const statSettledBridgeFile = async (filePath: string): Promise<Stats> => {
|
||||
let current: Stats | undefined;
|
||||
for (let i = 0; i < BRIDGE_SETTLE_ATTEMPTS; i++) {
|
||||
await sleep(BRIDGE_SETTLE_MS);
|
||||
const next = await fsp.stat(filePath);
|
||||
if (current && sameFileStamp(next, current)) {
|
||||
return next;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current ?? (await fsp.stat(filePath));
|
||||
};
|
||||
|
||||
/**
|
||||
* Does `meta` still describe the `bridge.lbug` sitting next to it?
|
||||
*
|
||||
|
|
@ -712,7 +755,7 @@ export async function bridgeMetaMatchesFile(groupDir: string, meta: BridgeMeta):
|
|||
if (!stampedSize || !stampedMtime) return false;
|
||||
try {
|
||||
const stat = await fsp.stat(path.join(groupDir, 'bridge.lbug'));
|
||||
return stat.size === meta.bridgeSize && stat.mtimeMs === meta.bridgeMtimeMs;
|
||||
return stampMatchesStat(stat, meta);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1373,7 +1416,7 @@ export async function writeBridgeUnlocked(
|
|||
// still belongs together (`bridgeMetaMatchesFile`). A stale meta cannot match
|
||||
// a freshly renamed database, and a sync that fails before the swap leaves a
|
||||
// matching pair untouched.
|
||||
const finalStat = await fsp.stat(finalPath);
|
||||
const finalStat = await statSettledBridgeFile(finalPath);
|
||||
await writeBridgeMeta(groupDir, {
|
||||
version: BRIDGE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ import {
|
|||
RegistryAmbiguousTargetError,
|
||||
type RegistryEntry,
|
||||
} from '../../storage/repo-manager.js';
|
||||
import {
|
||||
requireRegisteredStoragePath,
|
||||
STATUS_STORAGE_REQUIREMENTS,
|
||||
} from '../../storage/storage-resolver.js';
|
||||
import { loadMeta } from '../../storage/repo-meta.js';
|
||||
import { LBUG_DIRECTORY } from '../../storage/storage-constants.js';
|
||||
import type {
|
||||
GroupConfig,
|
||||
RepoHandle,
|
||||
|
|
@ -346,15 +352,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
const includeEx = new IncludeExtractor();
|
||||
|
||||
for (const [groupPath, regName] of Object.entries(config.repos)) {
|
||||
const handle = await resolve(regName, groupPath);
|
||||
if (!handle) {
|
||||
missingRepos.push(groupPath);
|
||||
continue;
|
||||
}
|
||||
resolvedRepoPaths.set(groupPath, handle.repoPath);
|
||||
|
||||
const poolId = handle.id;
|
||||
const lbugPath = path.join(handle.storagePath, 'lbug');
|
||||
let lbugPath = '';
|
||||
// Staged per repo, not appended straight to `autoContracts`. Extractors
|
||||
// run in sequence and any one of them can throw; appending as we go left
|
||||
// a repo whose HTTP extractor succeeded and whose gRPC extractor failed
|
||||
|
|
@ -363,6 +361,27 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
// output is now all-or-nothing, which is what that message describes.
|
||||
const repoContracts: StoredContract[] = [];
|
||||
try {
|
||||
let handle = await resolve(regName, groupPath);
|
||||
if (!handle) {
|
||||
missingRepos.push(groupPath);
|
||||
continue;
|
||||
}
|
||||
resolvedRepoPaths.set(groupPath, handle.repoPath);
|
||||
|
||||
// Validate the registry-selected slot immediately before it is
|
||||
// opened. A foreign or incomplete external slot is a registered but
|
||||
// unreadable member, so the existing per-member degradation path
|
||||
// remains the user-visible behavior.
|
||||
if (!opts?.resolveRepoHandle) {
|
||||
const storagePath = await requireRegisteredStoragePath(
|
||||
{ path: handle.repoPath, storagePath: handle.storagePath },
|
||||
STATUS_STORAGE_REQUIREMENTS,
|
||||
);
|
||||
handle = { ...handle, storagePath };
|
||||
}
|
||||
|
||||
const poolId = handle.id;
|
||||
lbugPath = path.join(handle.storagePath, LBUG_DIRECTORY);
|
||||
await initLbug(poolId, lbugPath);
|
||||
// No pin here: contract extraction below uses `executor` while this
|
||||
// repo is freshly initialized and live, and completes before the next
|
||||
|
|
@ -444,10 +463,9 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
}
|
||||
}
|
||||
|
||||
const metaPath = path.join(handle.storagePath, 'meta.json');
|
||||
try {
|
||||
const raw = await fs.readFile(metaPath, 'utf-8');
|
||||
const m = JSON.parse(raw) as { indexedAt?: string; lastCommit?: string };
|
||||
const m = await loadMeta(handle.storagePath);
|
||||
if (!m) throw new Error('Index metadata is unavailable.');
|
||||
repoSnapshots[groupPath] = {
|
||||
indexedAt: m.indexedAt || '',
|
||||
lastCommit: m.lastCommit || '',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import path from 'path';
|
|||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { NodeTableName, RELATION_SCHEMA } from './schema.js';
|
||||
import type { ContentRetention } from '../../storage/repo-meta.js';
|
||||
import { VALID_NODE_TABLES, parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js';
|
||||
import { parseTruthyEnv } from '../ingestion/utils/env.js';
|
||||
import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js';
|
||||
|
|
@ -294,7 +295,16 @@ const formatFtsDescription = (description: string): string =>
|
|||
// 0-based line invariant. Kept as a named alias to read intent at the use site.
|
||||
const EXACT_SYMBOL_CONTENT_LABELS = SYMBOL_NODE_LABELS;
|
||||
|
||||
const extractContent = async (node: GraphNode, contentCache: FileContentCache): Promise<string> => {
|
||||
const extractContent = async (
|
||||
node: GraphNode,
|
||||
contentCache: FileContentCache,
|
||||
contentRetention: ContentRetention,
|
||||
): Promise<string> => {
|
||||
// File content is intentionally lazy-read for full indexes. Do not let this
|
||||
// compatibility path recreate source text that the selected profile forbids.
|
||||
if (contentRetention === 'none' || (contentRetention === 'symbol' && node.label === 'File')) {
|
||||
return '';
|
||||
}
|
||||
const filePath = node.properties.filePath;
|
||||
const prepared = await contentCache.get(filePath);
|
||||
const content = prepared.content;
|
||||
|
|
@ -476,6 +486,7 @@ export const streamAllCSVsToDisk = async (
|
|||
repoPath: string,
|
||||
csvDir: string,
|
||||
onNodePhaseComplete?: (nodeFiles: Map<NodeTableName, { csvPath: string; rows: number }>) => void,
|
||||
contentRetention: ContentRetention = 'full',
|
||||
): Promise<StreamedCSVResult> => {
|
||||
// Deterministic (id-sorted) node/relationship row order when enabled;
|
||||
// default off = today's graph-insertion order (byte-identical).
|
||||
|
|
@ -626,7 +637,7 @@ export const streamAllCSVsToDisk = async (
|
|||
let pending: Promise<void> | undefined;
|
||||
switch (node.label) {
|
||||
case 'File': {
|
||||
const content = await extractContent(node, contentCache);
|
||||
const content = await extractContent(node, contentCache, contentRetention);
|
||||
pending = fileWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
|
|
@ -681,7 +692,7 @@ export const streamAllCSVsToDisk = async (
|
|||
break;
|
||||
}
|
||||
case 'Method': {
|
||||
const content = await extractContent(node, contentCache);
|
||||
const content = await extractContent(node, contentCache, contentRetention);
|
||||
pending = methodWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
|
|
@ -699,7 +710,7 @@ export const streamAllCSVsToDisk = async (
|
|||
break;
|
||||
}
|
||||
case 'Section': {
|
||||
const content = await extractContent(node, contentCache);
|
||||
const content = await extractContent(node, contentCache, contentRetention);
|
||||
pending = sectionWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
|
|
@ -801,7 +812,7 @@ export const streamAllCSVsToDisk = async (
|
|||
// Code element nodes (Function, Class, Interface, CodeElement)
|
||||
const writer = codeWriterMap[node.label];
|
||||
if (writer) {
|
||||
const content = await extractContent(node, contentCache);
|
||||
const content = await extractContent(node, contentCache, contentRetention);
|
||||
const row = [
|
||||
escapeCSVField(node.id),
|
||||
escapeCSVField(node.properties.name || ''),
|
||||
|
|
@ -822,7 +833,7 @@ export const streamAllCSVsToDisk = async (
|
|||
// Multi-language node types (Struct, Impl, Trait, Macro, etc.)
|
||||
const mlWriter = multiLangWriters.get(node.label);
|
||||
if (mlWriter) {
|
||||
const content = await extractContent(node, contentCache);
|
||||
const content = await extractContent(node, contentCache, contentRetention);
|
||||
pending = mlWriter.addRow(
|
||||
[
|
||||
escapeCSVField(node.id),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { escapeCypherString } from './cypher-escape.js';
|
|||
import { withConnLock } from './conn-lock.js';
|
||||
import { isWalDriverActive } from './wal-driver-state.js';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import type { ContentRetention } from '../../storage/repo-meta.js';
|
||||
import {
|
||||
NODE_TABLES,
|
||||
REL_TABLE_NAME,
|
||||
|
|
@ -1146,6 +1147,8 @@ export const loadGraphToLbug = async (
|
|||
* which holds one CSV per pair and would silently drop one of them).
|
||||
*/
|
||||
graphEmitManifest?: GraphEmitManifest,
|
||||
/** Content profile for CSV emission; default preserves the historical full index. */
|
||||
contentRetention: ContentRetention = 'full',
|
||||
) => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
|
|
@ -1226,8 +1229,8 @@ export const loadGraphToLbug = async (
|
|||
let csvResult: StreamedCSVResult;
|
||||
try {
|
||||
csvResult = SERIAL
|
||||
? await streamAllCSVsToDisk(graph, repoPath, csvDir)
|
||||
: await streamAllCSVsToDisk(graph, repoPath, csvDir, beginNodeCopy);
|
||||
? await streamAllCSVsToDisk(graph, repoPath, csvDir, undefined, contentRetention)
|
||||
: await streamAllCSVsToDisk(graph, repoPath, csvDir, beginNodeCopy, contentRetention);
|
||||
} catch (emitErr) {
|
||||
// Relationship emit failed. In overlap mode a node COPY may be in flight —
|
||||
// settle it (the .catch above means this never rejects) before rethrowing so
|
||||
|
|
|
|||
|
|
@ -90,6 +90,13 @@ import {
|
|||
initialiseSearchFTSStemmer,
|
||||
verifySearchFTSIndexes,
|
||||
} from './search/fts-indexes.js';
|
||||
import { getFtsIndexes } from './search/fts-schema.js';
|
||||
import {
|
||||
applyContentRetention,
|
||||
contentRetentionFromEnvironment,
|
||||
contentRetentionMismatch,
|
||||
ftsProfileForContentRetention,
|
||||
} from './content-retention.js';
|
||||
import {
|
||||
cjkSegmentationModeMismatch,
|
||||
getSearchFTSCjkSegmentation,
|
||||
|
|
@ -127,11 +134,19 @@ import {
|
|||
isRepoRegistered,
|
||||
cleanupOldKuzuFiles,
|
||||
reconcileMetadataFiles,
|
||||
ensureStoragePathWritable,
|
||||
isMissingFilesystemError,
|
||||
INDEX_METADATA_FILE,
|
||||
CONTENT_RETENTION_SCHEMA_VERSION,
|
||||
type AnalyzerRunnerIdentity,
|
||||
type ContentRetention,
|
||||
type RepoMeta,
|
||||
} from '../storage/repo-manager.js';
|
||||
import {
|
||||
ANALYZE_FORCE_STORAGE_REQUIREMENTS,
|
||||
ANALYZE_STORAGE_REQUIREMENTS,
|
||||
requireStoragePath,
|
||||
} from '../storage/storage-resolver.js';
|
||||
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
|
||||
import {
|
||||
DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
|
||||
|
|
@ -540,6 +555,8 @@ export function analyzeFailureMayHaveMutatedLiveIndex(error: unknown): boolean {
|
|||
export interface AnalyzeResult {
|
||||
repoName: string;
|
||||
repoPath: string;
|
||||
/** The exact storage slot selected and validated for this analysis. */
|
||||
storagePath: string;
|
||||
stats: {
|
||||
files?: number;
|
||||
nodes?: number;
|
||||
|
|
@ -1023,8 +1040,14 @@ interface WriteTarget {
|
|||
async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Promise<WriteTarget> {
|
||||
// `storagePath` is ALWAYS the flat `.gitnexus` — content-addressed caches
|
||||
// (parse-cache, parsedfile-store) and kuzu-migration cleanup live there and
|
||||
// are shared across branches (#2106 KTD7).
|
||||
const { storagePath } = getStoragePaths(repoPath);
|
||||
// are shared across branches (#2106 KTD7). Always re-run requireStoragePath:
|
||||
// a cached path string must not skip ownership (STORAGE_PATH can move to a
|
||||
// foreign slot while the lock is waited out). `--force` may adopt a
|
||||
// repository-local foreign slot; the non-force set stays ANALYZE_STORAGE.
|
||||
const storagePath = await requireStoragePath(
|
||||
repoPath,
|
||||
options.force ? ANALYZE_FORCE_STORAGE_REQUIREMENTS : ANALYZE_STORAGE_REQUIREMENTS,
|
||||
);
|
||||
const repoHasGit = hasGitDir(repoPath);
|
||||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
// Normalize the auto-detected branch the same way an explicit `--branch` is
|
||||
|
|
@ -1045,8 +1068,10 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr
|
|||
);
|
||||
}
|
||||
const branchLabel = options.branch ?? checkedOutBranch;
|
||||
const placement = options.branch ? await resolveBranchPlacement(repoPath, branchLabel) : {};
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch);
|
||||
const placement = options.branch
|
||||
? await resolveBranchPlacement(repoPath, branchLabel, storagePath)
|
||||
: {};
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch, storagePath);
|
||||
return {
|
||||
storagePath,
|
||||
repoHasGit,
|
||||
|
|
@ -1100,6 +1125,7 @@ export async function runFullAnalysis(
|
|||
}
|
||||
initialiseSearchFTSStemmer();
|
||||
initialiseSearchFTSCjkSegmentation();
|
||||
const contentRetention = contentRetentionFromEnvironment();
|
||||
// Scope the degraded-parse log throttle to this run (module-level counter
|
||||
// would otherwise stay saturated on a reused process).
|
||||
resetDegradedParseCounter();
|
||||
|
|
@ -1135,6 +1161,9 @@ export async function runFullAnalysis(
|
|||
// checkout) still releases the held lock via `finally` (no leak).
|
||||
const MAX_RELOCK = 3;
|
||||
for (let attempt = 0; attempt < MAX_RELOCK; attempt++) {
|
||||
// Never pass the pre-lock storagePath as already-validated: requireStoragePath
|
||||
// must run again under the lock so a now-foreign slot aborts (and finally
|
||||
// still releases the lock).
|
||||
const fresh = await resolveWriteTarget(repoPath, options);
|
||||
if (fresh.metaDir === writeTarget.metaDir) {
|
||||
writeTarget = fresh; // same slot — adopt the freshly-read commit/branch/placement
|
||||
|
|
@ -1160,6 +1189,7 @@ export async function runFullAnalysis(
|
|||
options,
|
||||
callbacks,
|
||||
writeTarget,
|
||||
contentRetention,
|
||||
runnerIdentityAtBootstrap,
|
||||
);
|
||||
} finally {
|
||||
|
|
@ -1172,6 +1202,7 @@ async function runFullAnalysisInner(
|
|||
options: AnalyzeOptions,
|
||||
callbacks: AnalyzeCallbacks,
|
||||
writeTarget: WriteTarget,
|
||||
contentRetention: ContentRetention,
|
||||
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
|
||||
): Promise<AnalyzeResult> {
|
||||
const ftsDisabledReason = resolveFtsDisableReason(options.skipFts);
|
||||
|
|
@ -1192,6 +1223,13 @@ async function runFullAnalysisInner(
|
|||
// does not own the flat slot. See resolveWriteTarget for the full contract.
|
||||
const { storagePath, repoHasGit, currentCommit, branchLabel, placement, lbugPath, metaDir } =
|
||||
writeTarget;
|
||||
let storageWritable: Promise<void> | undefined;
|
||||
const ensureWritableStorage = (): Promise<void> => {
|
||||
storageWritable ??= ensureStoragePathWritable(storagePath);
|
||||
return storageWritable;
|
||||
};
|
||||
const ftsProfile = ftsProfileForContentRetention(contentRetention);
|
||||
const ftsIndexes = getFtsIndexes(ftsProfile);
|
||||
let coldParseRebuildDir: string | undefined;
|
||||
|
||||
// Start each analyze with a clean buffer-pool hint: any pre-pipeline DB open
|
||||
|
|
@ -1205,13 +1243,13 @@ async function runFullAnalysisInner(
|
|||
log('Migrating from KuzuDB to LadybugDB — rebuilding index...');
|
||||
}
|
||||
|
||||
// Keep gitnexus.json and the legacy meta.json mirror in sync (fresher
|
||||
// indexedAt wins; nothing is deleted). Best-effort: loadMeta has its own
|
||||
// legacy fallback, so a reconciliation failure (read-only mount, full disk)
|
||||
// must never abort the analyze run — a repo that indexed fine read-only
|
||||
// before the rename must keep doing so.
|
||||
// Keep gitnexus.json and the legacy meta.json mirror in sync. Use the
|
||||
// ownership-validated write target rather than resolving storage again from
|
||||
// the registry while holding the index lock. Best-effort: loadMeta has its
|
||||
// own legacy fallback, so a reconciliation failure (read-only mount, full
|
||||
// disk) must never abort the analyze run.
|
||||
try {
|
||||
await reconcileMetadataFiles(repoPath);
|
||||
await reconcileMetadataFiles(repoPath, storagePath);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
log(`Metadata reconciliation failed (non-critical${code ? `, ${code}` : ''}); continuing.`);
|
||||
|
|
@ -1231,7 +1269,31 @@ async function runFullAnalysisInner(
|
|||
? withExplicitFtsDisablement(loadedMeta, ftsDisabledReason)
|
||||
: undefined;
|
||||
|
||||
// Claim a fresh, ownership-validated slot before the pipeline writes caches.
|
||||
// A later registry-name collision or pipeline failure can otherwise leave
|
||||
// cache files without metadata, which must be treated as unowned on the next
|
||||
// invocation. This marker deliberately has no DB/freshness receipt, so read
|
||||
// paths still reject it until a successful analyze writes the final metadata.
|
||||
if (!existingMeta && !(await loadMeta(storagePath))) {
|
||||
await saveMeta(storagePath, {
|
||||
repoPath,
|
||||
storagePath,
|
||||
lastCommit: '',
|
||||
indexedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── FTS-only repair path ────────────────────────────────────────────
|
||||
if (
|
||||
options.repairFts &&
|
||||
existingMeta &&
|
||||
contentRetentionMismatch(existingMeta, contentRetention)
|
||||
) {
|
||||
log(
|
||||
'content retention or FTS profile changed; forcing a full rebuild before rebuilding search indexes.',
|
||||
);
|
||||
options = { ...options, force: true, repairFts: false };
|
||||
}
|
||||
if (options.repairFts) {
|
||||
if (!existingMeta) {
|
||||
throw new Error(
|
||||
|
|
@ -1281,6 +1343,7 @@ async function runFullAnalysisInner(
|
|||
'Run `gitnexus analyze` (full) to rebuild from scratch.',
|
||||
);
|
||||
}
|
||||
await ensureWritableStorage();
|
||||
try {
|
||||
await initAnalysisLbug(lbugPath);
|
||||
// Gate on FTS availability BEFORE touching any index. createSearchFTSIndexes
|
||||
|
|
@ -1322,6 +1385,7 @@ async function runFullAnalysisInner(
|
|||
}
|
||||
progress('fts', 85, 'Repairing search indexes...');
|
||||
const repairFailures = await createSearchFTSIndexes({
|
||||
indexes: ftsIndexes,
|
||||
onIndexStart: options.verbose
|
||||
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
|
||||
: undefined,
|
||||
|
|
@ -1329,7 +1393,7 @@ async function runFullAnalysisInner(
|
|||
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
|
||||
: undefined,
|
||||
});
|
||||
const missing = await verifySearchFTSIndexes(executeQuery);
|
||||
const missing = await verifySearchFTSIndexes(executeQuery, ftsIndexes);
|
||||
if (missing.length > 0) {
|
||||
// #2889: name WHY each index is missing when the build itself said so.
|
||||
// Repair now rebuilds every table it can before reporting, so the tables
|
||||
|
|
@ -1338,14 +1402,16 @@ async function runFullAnalysisInner(
|
|||
// only ever list "missing", never a reason. Same sentence the analyze
|
||||
// degrade path prints, so one failure does not read two ways.
|
||||
const reasons =
|
||||
repairFailures.length > 0 ? ` ${summarizeFtsIndexBuildFailures(repairFailures)}.` : '';
|
||||
repairFailures.length > 0
|
||||
? ` ${summarizeFtsIndexBuildFailures(repairFailures, ftsIndexes)}.`
|
||||
: '';
|
||||
throw new Error(
|
||||
`FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}.${reasons} ` +
|
||||
'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' +
|
||||
'if that also fails, verify FTS extension availability via `gitnexus doctor`.',
|
||||
);
|
||||
}
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
await ensureGitNexusIgnored(repoPath, storagePath);
|
||||
// #2767: stamp ONLY capabilities.fts so a long-lived MCP session's
|
||||
// ensureInitialized() has an explicit, correctly-scoped signal that FTS
|
||||
// changed — indexedAt/lastCommit/runnerIdentity/stats are copied through
|
||||
|
|
@ -1387,6 +1453,7 @@ async function runFullAnalysisInner(
|
|||
name: options.registryName,
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
branch: placement.branch,
|
||||
storagePath,
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
|
@ -1395,6 +1462,7 @@ async function runFullAnalysisInner(
|
|||
getInferredRepoName(repoPath) ??
|
||||
path.basename(resolveRepoIdentityRoot(repoPath)),
|
||||
repoPath,
|
||||
storagePath,
|
||||
stats: existingMeta.stats ?? {},
|
||||
ftsRepairedOnly: true,
|
||||
};
|
||||
|
|
@ -1498,6 +1566,7 @@ async function runFullAnalysisInner(
|
|||
// rebuild wipe that would discard it. Park the WAL/shadow sidecars aside
|
||||
// now, while nothing is open, so every open in this run is replay-free.
|
||||
// The rebuild wipes the DB regardless, so no committed data is at stake.
|
||||
await ensureWritableStorage();
|
||||
const { removed, failed } = await quarantineSidecarsForDirtyRecovery(lbugPath, log);
|
||||
if (removed.length > 0) {
|
||||
log(
|
||||
|
|
@ -1552,6 +1621,18 @@ async function runFullAnalysisInner(
|
|||
options = { ...options, force: true };
|
||||
}
|
||||
|
||||
// Retention controls the DB's persisted text and FTS columns. Incremental
|
||||
// writeback only touches changed files, so changing it in place would leave
|
||||
// old source text and index pages behind. Rebuild the database instead.
|
||||
if (existingMeta && contentRetentionMismatch(existingMeta, contentRetention)) {
|
||||
const recorded = existingMeta.contentRetention ?? 'full (legacy)';
|
||||
log(
|
||||
`content retention changed (index built with ${recorded}, this run uses ${contentRetention}); ` +
|
||||
'forcing a full rebuild so stored text and FTS indexes are recreated.',
|
||||
);
|
||||
options = { ...options, force: true };
|
||||
}
|
||||
|
||||
// ── schema mismatch forces full rebuild (#2289 P1, #2798) ─────────
|
||||
// Mirrors the pdg-mode block above: an index whose tables were created from
|
||||
// a different DDL cannot be reconciled by an incremental top-up — a
|
||||
|
|
@ -1873,6 +1954,7 @@ async function runFullAnalysisInner(
|
|||
name: options.registryName,
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
branch: placement.branch,
|
||||
storagePath,
|
||||
});
|
||||
if (!placement.branch) {
|
||||
try {
|
||||
|
|
@ -1917,7 +1999,7 @@ async function runFullAnalysisInner(
|
|||
// date" run must not fail over it; read-only storage — the
|
||||
// documented Docker :ro workflow (#1549) — degrades to a warning.
|
||||
try {
|
||||
await adoptFlatBranchLabel(repoPath, branchLabel);
|
||||
await adoptFlatBranchLabel(repoPath, branchLabel, storagePath);
|
||||
await saveMeta(metaDir, { ...existingMeta, branch: branchLabel });
|
||||
} catch (err) {
|
||||
// EACCES/EPERM also arise from ownership problems and transient
|
||||
|
|
@ -1938,7 +2020,7 @@ async function runFullAnalysisInner(
|
|||
);
|
||||
}
|
||||
}
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
await ensureGitNexusIgnored(repoPath, storagePath);
|
||||
return {
|
||||
// `resolveRepoIdentityRoot` collapses worktree roots to the
|
||||
// canonical repo basename (#1259) but leaves arbitrary subdirs
|
||||
|
|
@ -1948,6 +2030,7 @@ async function runFullAnalysisInner(
|
|||
getInferredRepoName(repoPath) ??
|
||||
path.basename(resolveRepoIdentityRoot(repoPath)),
|
||||
repoPath,
|
||||
storagePath,
|
||||
stats: existingMeta.stats ?? {},
|
||||
alreadyUpToDate: true,
|
||||
...(ftsDisabledReason ? { ftsSkipped: true, ftsSkipReason: ftsDisabledReason } : {}),
|
||||
|
|
@ -1957,6 +2040,8 @@ async function runFullAnalysisInner(
|
|||
}
|
||||
}
|
||||
|
||||
await ensureWritableStorage();
|
||||
|
||||
// ── Cache embeddings from existing index before rebuild ────────────
|
||||
// Four modes:
|
||||
// --embeddings -> load cache, restore, then generate any new ones
|
||||
|
|
@ -2124,8 +2209,8 @@ async function runFullAnalysisInner(
|
|||
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
|
||||
// Streaming/chunked PDG emit (#2202) — gated to full-rebuild runs
|
||||
// (force === true) so the incremental writeback never reads back an
|
||||
// offloaded BasicBlock layer. Memory-only; byte-identical output.
|
||||
streamPdgEmit: resolveStreamPdgEmit(options),
|
||||
// offloaded BasicBlock layer. Non-full retention profiles must keep
|
||||
// BasicBlock source text in memory until the retention pass below.
|
||||
pdgEmitChunkSize: resolvePdgEmitChunkSize(options),
|
||||
// Streamed structural emit (#2680) — same full-rebuild gate as the PDG
|
||||
// toggle above, for the same incremental-writeback reason.
|
||||
|
|
@ -2142,6 +2227,10 @@ async function runFullAnalysisInner(
|
|||
springActuatorPath: options.springActuatorPath,
|
||||
asyncApiSpecPath: options.asyncApiSpecPath,
|
||||
springActuatorScanExclusions,
|
||||
// Streaming/chunked PDG emit must remain disabled for non-full content
|
||||
// retention profiles because BasicBlock source text is stripped only
|
||||
// after all semantic phases have completed.
|
||||
streamPdgEmit: contentRetention === 'full' && resolveStreamPdgEmit(options),
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
|
|
@ -2162,6 +2251,11 @@ async function runFullAnalysisInner(
|
|||
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
|
||||
progress('lbug', 60, 'Loading into LadybugDB...');
|
||||
|
||||
// Parsing and graph construction always see the original source. Apply the
|
||||
// retention boundary only after all semantic phases have completed and
|
||||
// before any graph rows, FTS values, or embeddings are persisted.
|
||||
applyContentRetention(pipelineResult.graph, contentRetention);
|
||||
|
||||
// Compute current per-file content hashes from the pipeline's File nodes.
|
||||
// Used both to drive the incremental DB writeback (when eligible) and to
|
||||
// populate meta.json.fileHashes for the next run.
|
||||
|
|
@ -2978,11 +3072,19 @@ async function runFullAnalysisInner(
|
|||
await wipeLbugDbFiles(buildPath);
|
||||
await initAnalysisLbug(buildPath);
|
||||
walCheckpointDriver = startWalCheckpointDriver();
|
||||
await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
|
||||
lbugMsgCount++;
|
||||
const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
|
||||
progress('lbug', pct, msg);
|
||||
});
|
||||
await loadGraphToLbug(
|
||||
pipelineResult.graph,
|
||||
pipelineResult.repoPath,
|
||||
storagePath,
|
||||
(msg) => {
|
||||
lbugMsgCount++;
|
||||
const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
|
||||
progress('lbug', pct, msg);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
contentRetention,
|
||||
);
|
||||
} else {
|
||||
// 1a. Drop every FTS index before touching a single row (#2589).
|
||||
// `deleteNodesForFiles` below DETACH DELETEs rows out of tables
|
||||
|
|
@ -3047,7 +3149,7 @@ async function runFullAnalysisInner(
|
|||
const derivedSnapshot = preserveDerivedLayer
|
||||
? await snapshotDerivedRelsForFiles(filesToDelete, [...tablesWithRows])
|
||||
: [];
|
||||
await dropSearchFTSIndexes(indexCatalogRows, incrementalFtsRebuildTables);
|
||||
await dropSearchFTSIndexes(indexCatalogRows, ftsIndexes, incrementalFtsRebuildTables);
|
||||
// 1b. Remove the write set's existing rows — batched (#2409): one
|
||||
// DETACH DELETE per table per 200-file chunk. The former per-file
|
||||
// loop issued a count + delete per table per FILE — ~13k
|
||||
|
|
@ -3147,11 +3249,19 @@ async function runFullAnalysisInner(
|
|||
effectiveWriteCount: effectiveWriteSet.size,
|
||||
deleteCount: filesToDelete.length,
|
||||
});
|
||||
await loadGraphToLbug(subgraph, pipelineResult.repoPath, storagePath, (msg) => {
|
||||
lbugMsgCount++;
|
||||
const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
|
||||
progress('lbug', pct, msg);
|
||||
});
|
||||
await loadGraphToLbug(
|
||||
subgraph,
|
||||
pipelineResult.repoPath,
|
||||
storagePath,
|
||||
(msg) => {
|
||||
lbugMsgCount++;
|
||||
const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
|
||||
progress('lbug', pct, msg);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
contentRetention,
|
||||
);
|
||||
if (preserveDerivedLayer && derivedSnapshot.length > 0) {
|
||||
await restoreDerivedRels(derivedSnapshot);
|
||||
}
|
||||
|
|
@ -3180,6 +3290,7 @@ async function runFullAnalysisInner(
|
|||
},
|
||||
pipelineResult.pdgEmitManifest,
|
||||
pipelineResult.graphEmitManifest,
|
||||
contentRetention,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3222,6 +3333,7 @@ async function runFullAnalysisInner(
|
|||
// pre-existing row (#2544/#2546) must not discard this run's otherwise-
|
||||
// successful graph/embeddings work — only keyword search degrades.
|
||||
const ftsResult = await buildSearchIndexesOrDegrade(executeQuery, {
|
||||
indexes: ftsIndexes,
|
||||
tables: incrementalFtsRebuildTables,
|
||||
onIndexStart: options.verbose
|
||||
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
|
||||
|
|
@ -3951,8 +4063,12 @@ async function runFullAnalysisInner(
|
|||
// honesty contract silently decays to "whatever interpolates".
|
||||
const meta: RepoMeta = {
|
||||
repoPath,
|
||||
storagePath,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
contentRetention,
|
||||
contentRetentionSchemaVersion: CONTENT_RETENTION_SCHEMA_VERSION,
|
||||
ftsProfile,
|
||||
runnerIdentity,
|
||||
// Persist only normalized repo-relative exclusions, never absolute paths
|
||||
// or payloads. Keep them after runtime enrichment is disabled so a later
|
||||
|
|
@ -4147,6 +4263,7 @@ async function runFullAnalysisInner(
|
|||
// primary/flat run (placement.branch === undefined) refreshes the
|
||||
// top-level fields (#2106).
|
||||
branch: placement.branch,
|
||||
storagePath,
|
||||
});
|
||||
|
||||
// ── #2354: the flat workspace slot has adopted this run's branch ──────
|
||||
|
|
@ -4161,7 +4278,7 @@ async function runFullAnalysisInner(
|
|||
// already-stamped meta label).
|
||||
if (!placement.branch && branchLabel) {
|
||||
try {
|
||||
await adoptFlatBranchLabel(repoPath, branchLabel);
|
||||
await adoptFlatBranchLabel(repoPath, branchLabel, storagePath);
|
||||
} catch (e) {
|
||||
log(
|
||||
`Warning: could not sync the workspace branch label (${(e as Error).message}); continuing.`,
|
||||
|
|
@ -4170,7 +4287,7 @@ async function runFullAnalysisInner(
|
|||
}
|
||||
|
||||
// Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
await ensureGitNexusIgnored(repoPath, storagePath);
|
||||
|
||||
// ── Generate AI context files (best-effort) ───────────────────────
|
||||
let aggregatedClusterCount = 0;
|
||||
|
|
@ -4335,6 +4452,7 @@ async function runFullAnalysisInner(
|
|||
return {
|
||||
repoName: projectName,
|
||||
repoPath,
|
||||
storagePath,
|
||||
stats: meta.stats,
|
||||
pipelineResult,
|
||||
...(graphWriteCollapsed ? { graphWriteCollapsed } : {}),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
import { getFtsCapability } from '../lbug/extension-loader.js';
|
||||
import { FTS_DISABLED_MESSAGE, type FtsDisabledReason } from './fts-policy.js';
|
||||
import { classifyExtensionLoadError } from '../lbug/extension-load-error.js';
|
||||
import { FTS_INDEXES } from './fts-schema.js';
|
||||
import { FTS_INDEXES, type FTSIndexDefinition } from './fts-schema.js';
|
||||
|
||||
/**
|
||||
* Strip filesystem paths from a LadybugDB error before it reaches the HTTP
|
||||
|
|
@ -161,6 +161,7 @@ export const SUPPORTED_FTS_STEMMERS: ReadonlySet<string> = new Set<string>([
|
|||
]);
|
||||
|
||||
export interface CreateSearchFTSIndexesOptions {
|
||||
indexes?: readonly FTSIndexDefinition[];
|
||||
onIndexStart?: (table: string, indexName: string) => void;
|
||||
onIndexReady?: (table: string, indexName: string) => void;
|
||||
/**
|
||||
|
|
@ -229,9 +230,12 @@ export function getSearchFTSStemmer(): string {
|
|||
* THIS connection with no index created or dropped since — the same freshness
|
||||
* contract, and the same one-shared-`SHOW_INDEXES`-read purpose, as the gates in
|
||||
* `lbug-adapter.ts`. Omit it to have the sweep read the catalog itself.
|
||||
* @param indexes FTS definitions for the active content-retention profile.
|
||||
* @param tables When set, restrict the sweep to these node tables.
|
||||
*/
|
||||
export async function dropSearchFTSIndexes(
|
||||
indexRows?: IndexCatalogSnapshot,
|
||||
indexes: readonly FTSIndexDefinition[] = FTS_INDEXES,
|
||||
tables?: ReadonlySet<string>,
|
||||
): Promise<void> {
|
||||
// One catalog read for the whole sweep, decided PER CONFIGURED INDEX on
|
||||
|
|
@ -253,7 +257,7 @@ export async function dropSearchFTSIndexes(
|
|||
// so an index left over from an older, differently-named set was never dropped
|
||||
// whether the sweep ran or not.
|
||||
const rows = await resolveGateRows(indexRows);
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
for (const { table, indexName } of indexes) {
|
||||
if (tables && !tables.has(table)) continue;
|
||||
// Skip only what the catalog POSITIVELY proves absent. Without this, a
|
||||
// machine whose FTS extension cannot load, analyzing a DB that never carried
|
||||
|
|
@ -330,7 +334,7 @@ export async function createSearchFTSIndexes(
|
|||
): Promise<FtsIndexBuildFailure[]> {
|
||||
const stemmer = getSearchFTSStemmer();
|
||||
const failures: FtsIndexBuildFailure[] = [];
|
||||
for (const { table, indexName, properties } of FTS_INDEXES) {
|
||||
for (const { table, indexName, properties } of options?.indexes ?? FTS_INDEXES) {
|
||||
if (options?.tables && !options.tables.has(table)) continue;
|
||||
options?.onIndexStart?.(table, indexName);
|
||||
// Drop first so the live `properties` always win. `createFTSIndex` is
|
||||
|
|
@ -379,12 +383,16 @@ export async function createSearchFTSIndexes(
|
|||
* Anything heading for a network response has to pass it through
|
||||
* {@link redactPaths} first, the same rule the query-side warnings follow.
|
||||
*/
|
||||
export const summarizeFtsIndexBuildFailures = (failures: readonly FtsIndexBuildFailure[]): string =>
|
||||
`FTS index build failed for ${failures.length} of ${FTS_INDEXES.length} tables: ` +
|
||||
export const summarizeFtsIndexBuildFailures = (
|
||||
failures: readonly FtsIndexBuildFailure[],
|
||||
indexes: readonly FTSIndexDefinition[] = FTS_INDEXES,
|
||||
): string =>
|
||||
`FTS index build failed for ${failures.length} of ${indexes.length} tables: ` +
|
||||
failures.map((f) => `${f.table}.${f.indexName} (${f.error})`).join(', ');
|
||||
|
||||
export async function verifySearchFTSIndexes(
|
||||
executeQuery: (cypher: string) => Promise<unknown[]>,
|
||||
indexes: readonly FTSIndexDefinition[] = FTS_INDEXES,
|
||||
): Promise<string[]> {
|
||||
// Read the catalog once and check each configured index both EXISTS and
|
||||
// covers its expected columns. A queryability-only probe (CALL QUERY_FTS_INDEX
|
||||
|
|
@ -414,7 +422,7 @@ export async function verifySearchFTSIndexes(
|
|||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const { table, indexName, properties } of FTS_INDEXES) {
|
||||
for (const { table, indexName, properties } of indexes) {
|
||||
const actual = propsByIndex.get(indexName);
|
||||
// Absent from the catalog, or present but not covering every expected column.
|
||||
if (!actual || !properties.every((p) => actual.includes(p))) {
|
||||
|
|
@ -522,7 +530,8 @@ export async function buildSearchIndexesOrDegrade(
|
|||
// name+content-only index is invisible to the build (it succeeds) yet still
|
||||
// means description search is broken (#2299).
|
||||
const failures = await createSearchFTSIndexes(options);
|
||||
const missing = await verifySearchFTSIndexes(executeQuery);
|
||||
const indexes = options?.indexes ?? FTS_INDEXES;
|
||||
const missing = await verifySearchFTSIndexes(executeQuery, indexes);
|
||||
if (failures.length === 0 && missing.length === 0) return { ok: true };
|
||||
|
||||
// A table that failed to build is necessarily missing too — report it once,
|
||||
|
|
@ -530,7 +539,7 @@ export async function buildSearchIndexesOrDegrade(
|
|||
const named = new Set(failures.map((f) => `${f.table}.${f.indexName}`));
|
||||
const unexplained = missing.filter((name) => !named.has(name));
|
||||
const error = [
|
||||
failures.length > 0 ? summarizeFtsIndexBuildFailures(failures) : '',
|
||||
failures.length > 0 ? summarizeFtsIndexBuildFailures(failures, indexes) : '',
|
||||
// Structural incompleteness with no thrown error — classified capability
|
||||
// (degrade) below, matching prior behavior; a broken *write* surfaces as
|
||||
// a thrown IO/checkpoint error and is classified integrity there.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { FtsProfile } from '../../storage/repo-meta.js';
|
||||
|
||||
export interface FTSIndexDefinition {
|
||||
readonly table: string;
|
||||
readonly indexName: string;
|
||||
|
|
@ -47,3 +49,16 @@ export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
|
|||
{ table: 'Static', indexName: 'static_fts', properties: FTS_PROPERTIES },
|
||||
{ table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES },
|
||||
];
|
||||
|
||||
const NAME_ONLY_PROPERTIES = ['name'] as const;
|
||||
|
||||
/** Return the FTS definitions compatible with one persisted content profile. */
|
||||
export const getFtsIndexes = (profile: FtsProfile = 'full'): readonly FTSIndexDefinition[] => {
|
||||
if (profile === 'full') return FTS_INDEXES;
|
||||
if (profile === 'symbol-no-file-content') {
|
||||
return FTS_INDEXES.map((index) =>
|
||||
index.table === 'File' ? { ...index, properties: NAME_ONLY_PROPERTIES } : index,
|
||||
);
|
||||
}
|
||||
return FTS_INDEXES.map((index) => ({ ...index, properties: NAME_ONLY_PROPERTIES }));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ import {
|
|||
import { realpathSync } from 'fs';
|
||||
import {
|
||||
listRegisteredRepos,
|
||||
cleanupOldKuzuFiles,
|
||||
canonicalizePath,
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
|
|
@ -98,6 +97,11 @@ import {
|
|||
isSupportedCjkSegmentationMode,
|
||||
MAX_CJK_SEGMENTATION_QUERY_LENGTH,
|
||||
} from '../../core/search/cjk-segmentation.js';
|
||||
import {
|
||||
checkoutIsDirectory,
|
||||
contentRetentionFromMeta,
|
||||
isFullSourceAvailable,
|
||||
} from '../../core/content-retention.js';
|
||||
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
|
||||
import {
|
||||
stalenessPayload,
|
||||
|
|
@ -173,6 +177,29 @@ const VALUE_CANDIDATE_TYPES: ReadonlySet<string> = new Set(['Const', 'Variable',
|
|||
*/
|
||||
const CANDIDATE_WINDOW = 20;
|
||||
|
||||
/**
|
||||
* `content` is an index capability rather than a promise that every symbol has
|
||||
* text. Retention `none` intentionally omits it, while `symbol` retains only
|
||||
* symbol spans. Keep this response additive and emit it only when requested so
|
||||
* callers relying on the legacy response shape remain compatible.
|
||||
*/
|
||||
const requestedContentAvailability = (
|
||||
requested: boolean,
|
||||
meta: Awaited<ReturnType<typeof loadMeta>>,
|
||||
) => {
|
||||
if (!requested) return undefined;
|
||||
const profile = contentRetentionFromMeta(meta);
|
||||
return {
|
||||
requested: true as const,
|
||||
profile,
|
||||
available: profile !== 'none',
|
||||
scope: profile,
|
||||
...(profile === 'none'
|
||||
? { reason: 'Source-derived content is not retained by this index.' }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The pieces every ambiguous-resolution payload shares, derived once.
|
||||
*
|
||||
|
|
@ -1348,6 +1375,9 @@ export interface RepoListing {
|
|||
branch?: string;
|
||||
/** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
|
||||
branches?: Array<Omit<BranchSummary, 'stats'>>;
|
||||
storagePath?: string;
|
||||
contentRetention?: 'full' | 'symbol' | 'none';
|
||||
sourceAvailable?: boolean;
|
||||
}
|
||||
|
||||
/** Continuation metadata for the paginated `list_repos` MCP tool (#2119). */
|
||||
|
|
@ -1791,15 +1821,6 @@ export class LocalBackend {
|
|||
const storagePath = entry.storagePath;
|
||||
const lbugPath = path.join(storagePath, 'lbug');
|
||||
|
||||
// Clean up any leftover KuzuDB files from before the LadybugDB migration.
|
||||
// If kuzu exists but lbug doesn't, warn so the user knows to re-analyze.
|
||||
const kuzu = await cleanupOldKuzuFiles(storagePath);
|
||||
if (kuzu.found && kuzu.needsReindex) {
|
||||
logger.error(
|
||||
`GitNexus: "${entry.name}" has a stale KuzuDB index. Run: gitnexus analyze ${entry.path}`,
|
||||
);
|
||||
}
|
||||
|
||||
const handle: RepoHandle = {
|
||||
id,
|
||||
name: entry.name,
|
||||
|
|
@ -1843,7 +1864,7 @@ export class LocalBackend {
|
|||
// memory registry snapshot; no disk I/O on this hot path (#2106 R3).
|
||||
for (const entry of entries) {
|
||||
for (const b of entry.branches ?? []) {
|
||||
liveLbugPaths.add(getStoragePaths(entry.path, b.branch).lbugPath);
|
||||
liveLbugPaths.add(getStoragePaths(entry.path, b.branch, entry.storagePath).lbugPath);
|
||||
}
|
||||
}
|
||||
// initializedRepos is the authoritative set of OPENED pool keys (flat AND
|
||||
|
|
@ -2095,7 +2116,7 @@ export class LocalBackend {
|
|||
const summary =
|
||||
handle.branch !== branch ? handle.branches?.find((b) => b.branch === branch) : undefined;
|
||||
if (summary) {
|
||||
const { lbugPath } = getStoragePaths(handle.repoPath, branch);
|
||||
const { lbugPath } = getStoragePaths(handle.repoPath, branch, handle.storagePath);
|
||||
// The lbug is the artifact the pool opens, so its presence is the
|
||||
// serviceability truth — a half-deleted dir can outlive its meta.json
|
||||
// while the lbug is gone, and vice versa (#2364 review F1 arm ii).
|
||||
|
|
@ -2455,12 +2476,22 @@ export class LocalBackend {
|
|||
// Check staleness for all repos in parallel instead of sequentially.
|
||||
// Each check spawns an async `git rev-list` — with 200 repos the sync
|
||||
// variant took ~50 s; parallel async brings it under a second (#1363).
|
||||
const stalenessResults = await Promise.all(
|
||||
handles.map((h) => checkStalenessAsync(h.repoPath, h.lastCommit)),
|
||||
const listing = await Promise.all(
|
||||
handles.map(async (h) => {
|
||||
const [stale, meta] = await Promise.all([
|
||||
checkStalenessAsync(h.repoPath, h.lastCommit),
|
||||
loadMeta(h.storagePath).catch(() => null),
|
||||
]);
|
||||
const contentRetention = contentRetentionFromMeta(meta);
|
||||
const sourceAvailable = isFullSourceAvailable(
|
||||
contentRetention,
|
||||
contentRetention === 'full' ? await checkoutIsDirectory(h.repoPath) : false,
|
||||
);
|
||||
return { h, stale, source: { contentRetention, sourceAvailable } };
|
||||
}),
|
||||
);
|
||||
|
||||
return handles.map((h, i) => {
|
||||
const stale = stalenessResults[i];
|
||||
return listing.map(({ h, stale, source }) => {
|
||||
const selfNorm = norm(h.repoPath);
|
||||
const siblings = h.remoteUrl
|
||||
? (byRemote.get(h.remoteUrl) ?? []).filter((e) => norm(e.repoPath) !== selfNorm)
|
||||
|
|
@ -2490,6 +2521,9 @@ export class LocalBackend {
|
|||
lastCommit: b.lastCommit,
|
||||
}))
|
||||
: undefined,
|
||||
storagePath: h.storagePath,
|
||||
contentRetention: source.contentRetention,
|
||||
sourceAvailable: source.sourceAvailable,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -2902,7 +2936,16 @@ export class LocalBackend {
|
|||
|
||||
const processLimit = params.limit || 5;
|
||||
const maxSymbolsPerProcess = params.max_symbols || 10;
|
||||
const includeContent = params.include_content ?? false;
|
||||
const requestedContent = params.include_content ?? false;
|
||||
// Do not trust a lingering graph property when the metadata contract says
|
||||
// source-derived text is unavailable. A full rebuild normally removes the
|
||||
// column values; this guard keeps a partially migrated/corrupt index from
|
||||
// disclosing text merely because a caller asked for it. `query` already
|
||||
// reads this metadata for CJK and embedding-dimension drift diagnostics,
|
||||
// so keep that legacy read unconditional.
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none';
|
||||
const contentAvailability = requestedContentAvailability(requestedContent, meta);
|
||||
const searchQuery = rawQuery.trim();
|
||||
|
||||
// Per-phase timing instrumentation (#553). Records wall time for each
|
||||
|
|
@ -2918,7 +2961,6 @@ export class LocalBackend {
|
|||
// each so both get independent wall-time records without fighting
|
||||
// over a single `current` phase slot.
|
||||
const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
const ftsDisabledReason = getFtsDisabledReason(meta?.capabilities?.fts);
|
||||
const [bm25SearchResult, semanticResults] = await Promise.all([
|
||||
timer.time('bm25', this.bm25Search(repo, searchQuery, searchLimit, ftsDisabledReason)),
|
||||
|
|
@ -3400,6 +3442,7 @@ export class LocalBackend {
|
|||
process_symbols: dedupedSymbols,
|
||||
definitions: definitions.slice(0, 20), // cap standalone definitions
|
||||
timing,
|
||||
...(contentAvailability ? { contentAvailability } : {}),
|
||||
...(warnings.length > 0 && { warning: warnings.join(' ') }),
|
||||
...((enrichmentDegraded || ftsPartial) && { partial: true }),
|
||||
};
|
||||
|
|
@ -4416,6 +4459,12 @@ export class LocalBackend {
|
|||
await this.ensureInitialized(repo);
|
||||
|
||||
const { name, uid, file_path, kind, include_content } = params;
|
||||
const requestedContent = include_content ?? false;
|
||||
// Content retention matters only to the opt-in content response. Avoid a
|
||||
// metadata dependency for the long-standing default context operation.
|
||||
const meta = requestedContent ? await loadMeta(path.dirname(repo.lbugPath)) : null;
|
||||
const contentAvailability = requestedContentAvailability(requestedContent, meta);
|
||||
const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none';
|
||||
|
||||
if (!name && !uid) {
|
||||
return { error: 'Either "name" or "uid" parameter is required.' };
|
||||
|
|
@ -4423,18 +4472,22 @@ export class LocalBackend {
|
|||
|
||||
const outcome = await this.resolveSymbolCandidates(
|
||||
repo,
|
||||
{ uid, name, include_content },
|
||||
{ uid, name, include_content: includeContent },
|
||||
{ file_path, kind },
|
||||
);
|
||||
|
||||
if (outcome.kind === 'not_found') {
|
||||
return { error: `Symbol '${name || uid}' not found` };
|
||||
return {
|
||||
error: `Symbol '${name || uid}' not found`,
|
||||
...(contentAvailability ? { contentAvailability } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (outcome.kind === 'ambiguous') {
|
||||
const { atLeast, showing, fields } = ambiguityReport(outcome, outcome.candidates.length);
|
||||
return {
|
||||
status: 'ambiguous',
|
||||
...(contentAvailability ? { contentAvailability } : {}),
|
||||
message: `Found ${atLeast}${outcome.total} symbols matching '${name}'${showing}. Use uid, file_path, or kind to disambiguate.`,
|
||||
...fields,
|
||||
candidates: outcome.candidates.map((c) => ({
|
||||
|
|
@ -4806,6 +4859,7 @@ export class LocalBackend {
|
|||
|
||||
return {
|
||||
status: 'found',
|
||||
...(contentAvailability ? { contentAvailability } : {}),
|
||||
symbol: {
|
||||
uid: sym.id || sym[0],
|
||||
name: sym.name || sym[1],
|
||||
|
|
@ -4813,7 +4867,7 @@ export class LocalBackend {
|
|||
filePath: sym.filePath || sym[3],
|
||||
startLine: toDisplayLine(sym.startLine ?? sym[4]),
|
||||
endLine: toDisplayLine(sym.endLine ?? sym[5]),
|
||||
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
|
||||
...(includeContent && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
|
||||
...(methodMetadata ? { methodMetadata } : {}),
|
||||
...(beanMetadata ? { bean: beanMetadata } : {}),
|
||||
...(aopMetadata ? { aop: aopMetadata } : {}),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ import { checkStaleness } from './staleness.js';
|
|||
import { loadMeta } from '../storage/repo-manager.js';
|
||||
import { ANALYZER_RUNNER_IDENTITY_SCHEMA_VERSION } from '../core/analyzer-identity.js';
|
||||
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
|
||||
import {
|
||||
checkoutIsDirectory,
|
||||
contentRetentionFromMeta,
|
||||
isFullSourceAvailable,
|
||||
} from '../core/content-retention.js';
|
||||
|
||||
export interface ResourceDefinition {
|
||||
uri: string;
|
||||
|
|
@ -362,9 +367,18 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro
|
|||
// receipt intact lets agents compare every identity field without parsing a
|
||||
// lossy human rendering; null explicitly means legacy/unknown provenance.
|
||||
lines.push('');
|
||||
const contentRetention = contentRetentionFromMeta(freshMeta);
|
||||
const sourceAvailable = isFullSourceAvailable(
|
||||
contentRetention,
|
||||
repo.repoPath ? await checkoutIsDirectory(repo.repoPath) : false,
|
||||
);
|
||||
|
||||
lines.push('index:');
|
||||
lines.push(` commit: ${JSON.stringify(lastCommit)}`);
|
||||
lines.push(` indexed_at: ${JSON.stringify(freshMeta?.indexedAt ?? null)}`);
|
||||
lines.push(` storage_path: ${JSON.stringify(repo.storagePath)}`);
|
||||
lines.push(` content_retention: ${JSON.stringify(contentRetention)}`);
|
||||
lines.push(` source_available: ${JSON.stringify(sourceAvailable)}`);
|
||||
lines.push(` runner_identity: ${JSON.stringify(freshMeta?.runnerIdentity ?? null)}`);
|
||||
lines.push(` incomplete_reasons: ${JSON.stringify(incompleteReasons)}`);
|
||||
lines.push(` spring_actuator: ${JSON.stringify(freshMeta?.springActuator ?? null)}`);
|
||||
|
|
|
|||
|
|
@ -181,7 +181,8 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W
|
|||
},
|
||||
include_content: {
|
||||
type: 'boolean',
|
||||
description: 'Include full symbol source code (default: false)',
|
||||
description:
|
||||
'Include source text retained for matching symbols (default: false). The response reports contentAvailability; indexes built with content retention "none" explicitly report unavailable content.',
|
||||
default: false,
|
||||
},
|
||||
maxTokens: {
|
||||
|
|
@ -328,7 +329,8 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
|
|||
},
|
||||
include_content: {
|
||||
type: 'boolean',
|
||||
description: 'Include full symbol source code (default: false)',
|
||||
description:
|
||||
'Include source text retained for this symbol (default: false). The response reports contentAvailability; indexes built with content retention "none" explicitly report unavailable content.',
|
||||
default: false,
|
||||
},
|
||||
maxTokens: {
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@ import { existsSync, statSync } from 'node:fs';
|
|||
import { fork } from 'child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { INDEX_METADATA_FILE } from '../storage/repo-manager.js';
|
||||
import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
getStoragePath,
|
||||
INDEX_METADATA_FILE,
|
||||
listRegisteredRepos,
|
||||
registryPathEquals,
|
||||
} from '../storage/repo-manager.js';
|
||||
ANALYZE_FORCE_STORAGE_REQUIREMENTS,
|
||||
ANALYZE_STORAGE_REQUIREMENTS,
|
||||
requireStoragePath,
|
||||
} from '../storage/storage-resolver.js';
|
||||
import { BRANCHES_DIR, branchSlug } from '../storage/branch-index.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { autoHeapCapMb } from '../core/ingestion/utils/effective-ram.js';
|
||||
|
|
@ -71,8 +71,8 @@ const MAX_WORKER_RETRIES = 2;
|
|||
/**
|
||||
* The worker reports `complete` over IPC before its on-disk finalization
|
||||
* (LadybugDB checkpoint + native handle release + metadata write) is visible
|
||||
* at `getStoragePath(targetPath)` — observed up to ~6.5s behind the IPC
|
||||
* message. Opening the database inside that window is what the pre-IPC
|
||||
* at the ownership-validated storage path — observed up to ~6.5s behind the
|
||||
* IPC message. Opening the database inside that window is what the pre-IPC
|
||||
* ordering was meant to prevent and is actively dangerous: reads fail with
|
||||
* binder errors or return an empty graph, the open can quarantine the
|
||||
* in-flight WAL, and the native layer racing the rewrite has crashed the
|
||||
|
|
@ -81,28 +81,14 @@ const MAX_WORKER_RETRIES = 2;
|
|||
const FINALIZE_SETTLE_TIMEOUT_MS = 60_000;
|
||||
const FINALIZE_SETTLE_POLL_MS = 200;
|
||||
|
||||
/**
|
||||
* Look up the analyzed repo's registered storage path. The request's
|
||||
* user-provided path is used only as a comparison key; the filesystem probes
|
||||
* below run against the registry's own `storagePath` — the server-owned
|
||||
* record readers resolve through, and not a user-controlled value
|
||||
* (CodeQL js/path-injection).
|
||||
*/
|
||||
const registeredStoragePath = async (targetPath: string): Promise<string | null> => {
|
||||
const target = canonicalizePath(path.resolve(targetPath));
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = entries.find((e) => registryPathEquals(canonicalizePath(e.path), target));
|
||||
return entry?.storagePath ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the directory this run's index actually landed in.
|
||||
*
|
||||
* `registerRepo` always records the FLAT `.gitnexus` as `entry.storagePath`,
|
||||
* but a pinned `--branch` run whose label differs from the flat slot's owner
|
||||
* writes `lbug`/`gitnexus.json` under `branches/<slug>/` instead. Probing the
|
||||
* flat path for such a run watches files it never rewrote, so the gate below
|
||||
* would spin to its timeout on a perfectly successful analysis (#3199 review).
|
||||
* `requireStoragePath` / `registerRepo` record the FLAT storage slot, but a
|
||||
* pinned `--branch` run whose label differs from the flat slot's owner writes
|
||||
* `lbug`/`gitnexus.json` under `branches/<slug>/` instead. Probing the flat
|
||||
* path for such a run watches files it never rewrote, so the gate below would
|
||||
* spin to its timeout on a perfectly successful analysis (#3199 review).
|
||||
*
|
||||
* `isPrimaryBranch` is the worker's own report of `!placement.branch`, so this
|
||||
* follows the placement core actually chose rather than recomputing it here
|
||||
|
|
@ -125,44 +111,72 @@ const settleDirFor = (
|
|||
* the previous index in place while it works), and no transient WAL/shadow/
|
||||
* checkpoint sidecars remain (the worker's native close has finished).
|
||||
*
|
||||
* Never rejects. Timing out logs and proceeds (pre-gate behavior) rather
|
||||
* than failing a job whose analysis genuinely succeeded. The `alreadyUpToDate`
|
||||
* fast path never rewrites `lbug` (see `run-analyze.ts`) and skips this wait
|
||||
* at the `complete` handler so it does not hold the analyze slot for 60s.
|
||||
* `storagePath` is the ownership-validated path from `requireStoragePath`,
|
||||
* not the request's user-provided repo path (CodeQL js/path-injection).
|
||||
*
|
||||
* Never rejects. Returns `true` once the index is settled. Timing out logs
|
||||
* a warning and returns `false` — the caller must fail the job without
|
||||
* publishing. The `alreadyUpToDate` fast path never rewrites `lbug` (see
|
||||
* `run-analyze.ts`) and is treated as settled without waiting so it does
|
||||
* not hold the analyze slot for 60s of polling.
|
||||
*/
|
||||
const waitForSettledIndex = async (
|
||||
targetPath: string,
|
||||
storagePath: string,
|
||||
jobStartMs: number,
|
||||
branch?: string,
|
||||
isPrimaryBranch?: boolean,
|
||||
): Promise<void> => {
|
||||
const settled = (storagePath: string): boolean => {
|
||||
): Promise<boolean> => {
|
||||
const settled = (probePath: string): boolean => {
|
||||
try {
|
||||
const lbugStat = statSync(path.join(storagePath, 'lbug'));
|
||||
const metaStat = statSync(path.join(storagePath, INDEX_METADATA_FILE));
|
||||
return (
|
||||
lbugStat.mtimeMs >= jobStartMs &&
|
||||
metaStat.mtimeMs >= jobStartMs &&
|
||||
['lbug.wal', 'lbug.shadow', 'lbug.wal.checkpoint'].every(
|
||||
(f) => !existsSync(path.join(storagePath, f)),
|
||||
)
|
||||
);
|
||||
// Inline path.relative barriers at every filesystem sink. CodeQL tracks
|
||||
// `storagePath` from the HTTP analyze `path` through requireStoragePath
|
||||
// and does not treat that helper as a js/path-injection sanitizer.
|
||||
const storageRoot = path.resolve(storagePath);
|
||||
const probeRoot = path.resolve(probePath);
|
||||
const probeRel = path.relative(storageRoot, probeRoot);
|
||||
if (probeRel.startsWith('..') || path.isAbsolute(probeRel)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lbugPath = path.resolve(probeRoot, LBUG_DIRECTORY);
|
||||
const lbugRel = path.relative(storageRoot, lbugPath);
|
||||
if (lbugRel.startsWith('..') || path.isAbsolute(lbugRel)) {
|
||||
return false;
|
||||
}
|
||||
const lbugStat = statSync(lbugPath);
|
||||
|
||||
const metaPath = path.resolve(probeRoot, INDEX_METADATA_FILE);
|
||||
const metaRel = path.relative(storageRoot, metaPath);
|
||||
if (metaRel.startsWith('..') || path.isAbsolute(metaRel)) {
|
||||
return false;
|
||||
}
|
||||
const metaStat = statSync(metaPath);
|
||||
|
||||
if (lbugStat.mtimeMs < jobStartMs || metaStat.mtimeMs < jobStartMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ['lbug.wal', 'lbug.shadow', 'lbug.wal.checkpoint'].every((name) => {
|
||||
const sidePath = path.resolve(probeRoot, name);
|
||||
const sideRel = path.relative(storageRoot, sidePath);
|
||||
if (sideRel.startsWith('..') || path.isAbsolute(sideRel)) {
|
||||
return false;
|
||||
}
|
||||
return !existsSync(sidePath);
|
||||
});
|
||||
} catch {
|
||||
return false; // not written yet
|
||||
}
|
||||
};
|
||||
const deadline = Date.now() + FINALIZE_SETTLE_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
// Re-resolved each round: the worker registers the repo as part of the
|
||||
// finalization this gate is waiting out.
|
||||
const storagePath = await registeredStoragePath(targetPath);
|
||||
if (storagePath && settled(settleDirFor(storagePath, branch, isPrimaryBranch))) return;
|
||||
if (settled(settleDirFor(storagePath, branch, isPrimaryBranch))) return true;
|
||||
if (Date.now() > deadline) {
|
||||
logger.warn(
|
||||
{ targetPath },
|
||||
'analyze finalization not visible after timeout; completing job anyway',
|
||||
{ storagePath },
|
||||
'analyze finalization not visible after timeout; not publishing',
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, FINALIZE_SETTLE_POLL_MS));
|
||||
}
|
||||
|
|
@ -171,22 +185,37 @@ const waitForSettledIndex = async (
|
|||
export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
||||
const { jobManager, backend, acquireRepoLock, releaseRepoLock, closeDbHandle } = deps;
|
||||
|
||||
return function launchAnalysisWorker(
|
||||
return async function launchAnalysisWorker(
|
||||
job: { id: string },
|
||||
targetPath: string,
|
||||
opts: LaunchOptions,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// For waitForSettledIndex: files (re)written by this job have mtimes at or
|
||||
// after this instant. Taken before the fork so no worker write predates it.
|
||||
const jobStartMs = Date.now();
|
||||
// Acquire shared repo lock (keyed on storagePath to match embed handler)
|
||||
const analyzeLockKey = getStoragePath(targetPath);
|
||||
const analyzeLockKey = await requireStoragePath(
|
||||
targetPath,
|
||||
opts.force ? ANALYZE_FORCE_STORAGE_REQUIREMENTS : ANALYZE_STORAGE_REQUIREMENTS,
|
||||
);
|
||||
// Acquire shared repo lock only after ownership validation. The same
|
||||
// resolved path is retained for finalization instead of being looked up
|
||||
// again through the registry after the worker exits.
|
||||
const lockErr = acquireRepoLock(analyzeLockKey);
|
||||
if (lockErr) {
|
||||
jobManager.updateJob(job.id, { status: 'failed', error: lockErr });
|
||||
return;
|
||||
}
|
||||
|
||||
// One launch, one release. `releaseRepoLock` is Set.delete (idempotent),
|
||||
// and this flag also stops error / exit / child.error / complete-finally
|
||||
// from racing a second drop if a late terminal message lands mid-settle.
|
||||
let lockReleased = false;
|
||||
const releaseLockOnce = (): void => {
|
||||
if (lockReleased) return;
|
||||
lockReleased = true;
|
||||
releaseRepoLock(analyzeLockKey);
|
||||
};
|
||||
|
||||
jobManager.updateJob(job.id, { repoPath: targetPath, status: 'analyzing' });
|
||||
|
||||
// ── Worker fork with auto-retry ──────────────────────────────
|
||||
|
|
@ -245,7 +274,13 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
progress: { phase: msg.phase, percent: msg.percent, message: msg.message },
|
||||
});
|
||||
} else if (msg.type === 'complete') {
|
||||
releaseRepoLock(analyzeLockKey);
|
||||
// Hold the write lock through settle AND the collapse/publish
|
||||
// decision. Release in `finally` so timeout / collapse / init
|
||||
// failure / complete each drop it exactly once. alreadyUpToDate
|
||||
// skips the mtime wait (resolved `true` immediately) so this
|
||||
// does not occupy the slot for 60s of polling — the lock still
|
||||
// drops only after that short path finishes.
|
||||
//
|
||||
// Before marking complete: (1) wait for the worker's on-disk
|
||||
// finalization to settle (see waitForSettledIndex), (2) evict the
|
||||
// cached DB handle — same invalidation DELETE /api/repo performs, a
|
||||
|
|
@ -262,12 +297,33 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
// ftsRepairedOnly DOES rewrite `lbug` (initLbug + createSearchFTSIndexes)
|
||||
// so it still waits.
|
||||
const settle = msg.result.alreadyUpToDate
|
||||
? Promise.resolve()
|
||||
: waitForSettledIndex(targetPath, jobStartMs, opts.branch, msg.result.isPrimaryBranch);
|
||||
? Promise.resolve(true)
|
||||
: waitForSettledIndex(
|
||||
analyzeLockKey,
|
||||
jobStartMs,
|
||||
opts.branch,
|
||||
msg.result.isPrimaryBranch,
|
||||
);
|
||||
settle
|
||||
.then(() => closeDbHandle())
|
||||
.catch(() => {}) // best-effort: eviction failure must not fail the job
|
||||
.then(() => {
|
||||
.then((settled) => {
|
||||
if (!settled) {
|
||||
// Finalization never became visible. Do not evict the cached
|
||||
// handle (a previously published index should keep being
|
||||
// served) and do not publish. On-disk files stay for a retry.
|
||||
jobManager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
repoName: msg.result.repoName,
|
||||
error:
|
||||
'Analysis finalization not visible after timeout. The index was not published; on-disk files were left for a retry.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return closeDbHandle()
|
||||
.catch(() => {}) // best-effort: eviction failure must not fail the job
|
||||
.then(() => true);
|
||||
})
|
||||
.then((readyToPublish) => {
|
||||
if (!readyToPublish) return;
|
||||
// PARITY WITH THE CLI, which is what the IPC projection was added
|
||||
// for. `analyze-worker-ipc.ts` carries `graphWriteCollapsed`
|
||||
// "so a server-side caller sees the same degraded outcome the CLI
|
||||
|
|
@ -328,7 +384,10 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
// ordering comment above the chain true — the repo really is
|
||||
// queryable when the client receives the SSE complete event.
|
||||
return backend.init().then(() => {
|
||||
jobManager.updateJob(job.id, { status: 'complete', repoName: msg.result.repoName });
|
||||
jobManager.updateJob(job.id, {
|
||||
status: 'complete',
|
||||
repoName: msg.result.repoName,
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -337,9 +396,12 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
status: 'failed',
|
||||
error: 'Server failed to reload after analysis. Try again.',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
releaseLockOnce();
|
||||
});
|
||||
} else if (msg.type === 'error') {
|
||||
releaseRepoLock(analyzeLockKey);
|
||||
releaseLockOnce();
|
||||
// A failed (force) analyze may still have rewritten DB files first.
|
||||
void closeDbHandle().catch(() => {});
|
||||
jobManager.updateJob(job.id, { status: 'failed', error: msg.message });
|
||||
|
|
@ -347,7 +409,7 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
releaseRepoLock(analyzeLockKey);
|
||||
releaseLockOnce();
|
||||
jobManager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: `Worker process error: ${err.message}`,
|
||||
|
|
@ -386,7 +448,7 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
setTimeout(forkWorker, delay);
|
||||
} else {
|
||||
// Exhausted retries — permanent failure
|
||||
releaseRepoLock(analyzeLockKey);
|
||||
releaseLockOnce();
|
||||
jobManager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: `Worker crashed ${MAX_WORKER_RETRIES + 1} times (code ${code})${stderrChunks ? ': ' + stderrChunks.trim().split('\n').pop() : ''}`,
|
||||
|
|
@ -413,6 +475,11 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
});
|
||||
};
|
||||
|
||||
forkWorker();
|
||||
try {
|
||||
forkWorker();
|
||||
} catch (error) {
|
||||
releaseLockOnce();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@ export interface AnalyzeUploadDeps {
|
|||
/** Create (or throw on busy) an analysis job for the given upload dir. */
|
||||
createJob: (params: { repoPath: string }) => UploadJobRef;
|
||||
/** Launch the analyze worker against an already-resolved repo directory. */
|
||||
launch: (job: UploadJobRef, targetPath: string, opts: { registryName: string }) => void;
|
||||
launch: (
|
||||
job: UploadJobRef,
|
||||
targetPath: string,
|
||||
opts: { registryName: string },
|
||||
) => void | Promise<void>;
|
||||
/**
|
||||
* Mark a created job failed. The job occupies the single analysis slot from
|
||||
* createJob onward, so ANY error before launch must release it — otherwise a
|
||||
|
|
@ -129,7 +133,7 @@ export function createAnalyzeUploadHandler(deps: AnalyzeUploadDeps) {
|
|||
.rm(path.join(finalDir, '.gitnexus'), { recursive: true, force: true })
|
||||
.catch(() => {});
|
||||
|
||||
deps.launch(job, finalDir, { registryName: finalName });
|
||||
await deps.launch(job, finalDir, { registryName: finalName });
|
||||
launched = true;
|
||||
|
||||
res.status(202).json({ jobId: job.id, status: job.status });
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export async function runWorkerAnalysis(
|
|||
// registry) — must NOT be reported as a successful analysis. Mirror the CLI's
|
||||
// assertAnalysisFinalized guard so the worker surfaces it as an error instead
|
||||
// of a false `complete` that leaves the repo invisible to list_repos.
|
||||
await deps.assertAnalysisFinalized(repoPath);
|
||||
await deps.assertAnalysisFinalized(repoPath, result.storagePath);
|
||||
|
||||
// Send a JSON-safe projection, NOT the raw result: the IPC channel is
|
||||
// default-JSON serialization and `result.pipelineResult` carries the live
|
||||
|
|
|
|||
|
|
@ -18,10 +18,16 @@ import {
|
|||
loadMeta,
|
||||
saveMeta,
|
||||
listRegisteredRepos,
|
||||
getStoragePath,
|
||||
registryPathEquals,
|
||||
type RegistryEntry,
|
||||
} from '../storage/repo-manager.js';
|
||||
import {
|
||||
requireDeletableStoragePath,
|
||||
requireRegisteredStoragePath,
|
||||
STATUS_STORAGE_REQUIREMENTS,
|
||||
StorageDeletionError,
|
||||
StorageRequirementError,
|
||||
} from '../storage/storage-resolver.js';
|
||||
import {
|
||||
executeQuery,
|
||||
executePrepared,
|
||||
|
|
@ -37,6 +43,12 @@ import { NODE_TABLES, type GraphNode, type GraphRelationship } from 'gitnexus-sh
|
|||
import { searchFTSFromLbug } from '../core/search/bm25-index.js';
|
||||
import { hybridSearch } from '../core/search/hybrid-search.js';
|
||||
import { ftsDegradedWarning } from '../core/search/fts-indexes.js';
|
||||
import {
|
||||
checkoutIsDirectory,
|
||||
contentRetentionFromMeta,
|
||||
isFullSourceAvailable,
|
||||
} from '../core/content-retention.js';
|
||||
import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
|
||||
import { getFtsDisabledReason, type FtsDisabledReason } from '../core/search/fts-policy.js';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { installServeMcpAuth, mountMCPEndpoints } from './mcp-http.js';
|
||||
|
|
@ -644,6 +656,65 @@ export const resolveRegisteredRepoEntry = (
|
|||
);
|
||||
};
|
||||
|
||||
export interface SourceAvailability {
|
||||
available: boolean;
|
||||
reason?: 'content-retention' | 'checkout-missing';
|
||||
contentRetention?: ReturnType<typeof contentRetentionFromMeta>;
|
||||
}
|
||||
|
||||
/** Map a failed storage probe to a catalog status. Missing/empty slots are 404; anything else is 503. */
|
||||
export const storageRequirementToHttp = (
|
||||
err: StorageRequirementError,
|
||||
): { status: 404 | 503; body: { error: string; code: 'index-unavailable'; state: string } } => {
|
||||
const notPresent = err.inspection.state === 'missing' || err.inspection.state === 'empty';
|
||||
return {
|
||||
status: notPresent ? 404 : 503,
|
||||
body: {
|
||||
error: err.message,
|
||||
code: 'index-unavailable',
|
||||
state: err.inspection.state,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const sendStorageRequirementHttp = (
|
||||
err: unknown,
|
||||
res: { status: (code: number) => { json: (body: unknown) => void } },
|
||||
): boolean => {
|
||||
if (!(err instanceof StorageRequirementError)) return false;
|
||||
const mapped = storageRequirementToHttp(err);
|
||||
res.status(mapped.status).json(mapped.body);
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Full-file endpoints require a live checkout; normalized index text is not source-viewer data. */
|
||||
export const getSourceAvailability = async (
|
||||
entry: Pick<RegistryEntry, 'path' | 'storagePath'>,
|
||||
loadedMeta?: Awaited<ReturnType<typeof loadMeta>>,
|
||||
): Promise<SourceAvailability> => {
|
||||
const meta = loadedMeta === undefined ? await loadMeta(entry.storagePath) : loadedMeta;
|
||||
const contentRetention = contentRetentionFromMeta(meta);
|
||||
if (contentRetention !== 'full') {
|
||||
return { available: false, reason: 'content-retention', contentRetention };
|
||||
}
|
||||
return isFullSourceAvailable(contentRetention, await checkoutIsDirectory(entry.path))
|
||||
? { available: true, contentRetention }
|
||||
: { available: false, reason: 'checkout-missing', contentRetention };
|
||||
};
|
||||
|
||||
const sendSourceUnavailable = (
|
||||
res: { status: (code: number) => { json: (body: any) => void } },
|
||||
availability: SourceAvailability,
|
||||
): void => {
|
||||
const reason =
|
||||
availability.reason === 'content-retention' ? 'content retention' : 'source checkout';
|
||||
res.status(410).json({
|
||||
error: `Full source is unavailable because the ${reason} is unavailable.`,
|
||||
code: 'source-unavailable',
|
||||
reason: availability.reason,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a GET /api/file request body. Extracted from createServer's route
|
||||
* registration so it can be unit-tested without spinning up an HTTP server
|
||||
|
|
@ -663,6 +734,7 @@ export const handleFileRequest = async (
|
|||
json: (body: any) => void;
|
||||
},
|
||||
repoPath: string,
|
||||
availability: SourceAvailability = { available: true },
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Type-confusion guard — req.query.path is `string | string[] | ParsedQs`.
|
||||
|
|
@ -676,6 +748,11 @@ export const handleFileRequest = async (
|
|||
}
|
||||
const filePath = assertString(rawFilePath, 'path');
|
||||
|
||||
if (!availability.available) {
|
||||
sendSourceUnavailable(res, availability);
|
||||
return;
|
||||
}
|
||||
|
||||
// Path-injection containment — inline at the sink with the canonical
|
||||
// path.relative idiom that CodeQL's js/path-injection sanitizer
|
||||
// recognizes. assertSafePath in validation.ts performs the equivalent
|
||||
|
|
@ -780,6 +857,7 @@ export const handleQueryRequest = async (
|
|||
);
|
||||
res.json({ result });
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
if (isReadOnlyDbError(err)) {
|
||||
res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
|
||||
return;
|
||||
|
|
@ -923,11 +1001,30 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
*/
|
||||
const HOLD_QUEUE_TIMEOUT_SECS = 300; // 5 minutes
|
||||
|
||||
const validateResolvedRepoEntry = async (
|
||||
entry: RegistryEntry | null,
|
||||
): Promise<RegistryEntry | null> => {
|
||||
if (!entry) return null;
|
||||
await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS);
|
||||
return entry;
|
||||
};
|
||||
|
||||
// Helper: resolve a repo by name from the global registry, or default to first.
|
||||
// Pass `req` to enable early exit if the client disconnects during the hold-queue wait.
|
||||
const resolveRepo = async (repoName?: string, isRetry = false, req?: any): Promise<any> => {
|
||||
const repos = await listRegisteredRepos();
|
||||
// Deletion passes `validateStorage: false` because it has a separate policy that
|
||||
// intentionally permits a missing/empty local slot to be removed.
|
||||
const resolveRepo = async (
|
||||
repoName?: string,
|
||||
isRetry = false,
|
||||
req?: any,
|
||||
options: { validateStorage?: boolean } = {},
|
||||
): Promise<any> => {
|
||||
const repos = await listRegisteredRepos({
|
||||
validate: options.validateStorage !== false,
|
||||
});
|
||||
const found = resolveRegisteredRepoEntry(repos, repoName);
|
||||
const validate = (entry: RegistryEntry | null): Promise<RegistryEntry | null> =>
|
||||
options.validateStorage === false ? Promise.resolve(entry) : validateResolvedRepoEntry(entry);
|
||||
|
||||
const normalizedName = repoName ? repoParamBasename(repoName) : undefined;
|
||||
|
||||
|
|
@ -967,8 +1064,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
if (!currentJob || currentJob.status === 'failed') break;
|
||||
if (currentJob.status === 'complete') {
|
||||
await backend.init();
|
||||
const freshRepos = await listRegisteredRepos();
|
||||
return resolveRegisteredRepoEntry(freshRepos, repoName);
|
||||
const freshRepos = await listRegisteredRepos({
|
||||
validate: options.validateStorage !== false,
|
||||
});
|
||||
return validate(resolveRegisteredRepoEntry(freshRepos, repoName));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
|
@ -989,10 +1088,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
);
|
||||
}
|
||||
await backend.init();
|
||||
return await resolveRepo(repoName, true, req);
|
||||
return await resolveRepo(repoName, true, req, options);
|
||||
}
|
||||
|
||||
return found;
|
||||
return validate(found);
|
||||
};
|
||||
|
||||
// Lightweight healthcheck for Docker/orchestrator probes (#1147).
|
||||
|
|
@ -1034,7 +1133,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// already carries. Web callers hit this on connect/switch, never in a loop.
|
||||
app.get('/api/repos', createRouteLimiter(), async (_req, res) => {
|
||||
try {
|
||||
const repos = await listRegisteredRepos();
|
||||
const repos = await listRegisteredRepos({ validate: true });
|
||||
// Checked in parallel, for the reason `list_repos` already does it that
|
||||
// way: each check spawns an async `git rev-list`, and the sequential
|
||||
// variant took ~50s across 200 repos (#1363). Projecting inside the map
|
||||
|
|
@ -1042,9 +1141,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// array is the shape that silently mispairs them if either is reordered.
|
||||
res.json(
|
||||
await Promise.all(
|
||||
repos.map(async (r) =>
|
||||
projectRepoListEntry(r, await checkStalenessAsync(r.path, r.lastCommit)),
|
||||
),
|
||||
repos.map(async (r) => {
|
||||
const [staleness, availability] = await Promise.all([
|
||||
checkStalenessAsync(r.path, r.lastCommit),
|
||||
getSourceAvailability(r),
|
||||
]);
|
||||
return projectRepoListEntry(r, staleness, {
|
||||
contentRetention: availability.contentRetention ?? 'full',
|
||||
sourceAvailable: availability.available,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err: any) {
|
||||
|
|
@ -1072,9 +1178,18 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
const meta = await loadMeta(entry.storagePath);
|
||||
const staleness = await checkStalenessAsync(entry.path, resolveLastCommit(entry, meta));
|
||||
res.json(projectRepoDetail(entry, meta, staleness));
|
||||
const [staleness, availability] = await Promise.all([
|
||||
checkStalenessAsync(entry.path, resolveLastCommit(entry, meta)),
|
||||
getSourceAvailability(entry, meta),
|
||||
]);
|
||||
res.json(
|
||||
projectRepoDetail(entry, meta, staleness, {
|
||||
contentRetention: availability.contentRetention ?? contentRetentionFromMeta(meta),
|
||||
sourceAvailable: availability.available,
|
||||
}),
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
res.status(500).json({ error: err.message || 'Failed to get repo info' });
|
||||
}
|
||||
});
|
||||
|
|
@ -1090,14 +1205,25 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(400).json({ error: 'Missing repo name' });
|
||||
return;
|
||||
}
|
||||
const entry = await resolveRepo(repoName);
|
||||
const entry = await resolveRepo(repoName, false, undefined, { validateStorage: false });
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireDeletableStoragePath(entry);
|
||||
} catch (err: any) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message || 'Unsafe index storage path' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Acquire repo lock — prevents deleting while analyze/embed is in flight
|
||||
const lockKey = getStoragePath(entry.path);
|
||||
const lockKey = storagePath;
|
||||
const lockErr = acquireRepoLock(lockKey);
|
||||
if (lockErr) {
|
||||
res.status(409).json({ error: lockErr });
|
||||
|
|
@ -1111,7 +1237,6 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
} catch {}
|
||||
|
||||
// 1. Delete the .gitnexus index/storage directory
|
||||
const storagePath = getStoragePath(entry.path);
|
||||
await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
// 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/.
|
||||
|
|
@ -1229,6 +1354,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
);
|
||||
res.json(graph);
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
if (err instanceof ClientDisconnectedError) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1425,6 +1551,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
}
|
||||
res.json(response);
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
res.status(500).json({ error: err.message || 'Search failed' });
|
||||
}
|
||||
});
|
||||
|
|
@ -1432,12 +1559,17 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// Read file — with path traversal guard
|
||||
// Rate-limited (CodeQL js/missing-rate-limiting): per-request fs.readFile.
|
||||
app.get('/api/file', createRouteLimiter(), async (req, res) => {
|
||||
const entry = await resolveRepo(requestedRepo(req));
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
try {
|
||||
const entry = await resolveRepo(requestedRepo(req));
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
await handleFileRequest(req, res, entry.path, await getSourceAvailability(entry));
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
res.status(500).json({ error: err.message || 'Failed to read file' });
|
||||
}
|
||||
await handleFileRequest(req, res, entry.path);
|
||||
});
|
||||
|
||||
// Grep — regex search across file contents in the indexed repo
|
||||
|
|
@ -1452,6 +1584,11 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
const sourceAvailability = await getSourceAvailability(entry);
|
||||
if (!sourceAvailability.available) {
|
||||
sendSourceUnavailable(res, sourceAvailability);
|
||||
return;
|
||||
}
|
||||
// Pattern parsing lives in grep-params.ts (unit-testable without
|
||||
// Express + LadybugDB). Matching runs in a worker so terminate() can
|
||||
// cut a stuck regex.test() when the wall-clock budget expires.
|
||||
|
|
@ -1485,6 +1622,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
|
||||
res.json({ results, ...(timedOut ? { timedOut: true } : {}) });
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' });
|
||||
}
|
||||
});
|
||||
|
|
@ -1709,7 +1847,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
throw new Error('No target path resolved');
|
||||
}
|
||||
|
||||
launchAnalysisWorker(job, targetPath, {
|
||||
await launchAnalysisWorker(job, targetPath, {
|
||||
force,
|
||||
embeddings,
|
||||
dropEmbeddings,
|
||||
|
|
@ -1731,7 +1869,6 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
: {}),
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (targetPath) releaseRepoLock(getStoragePath(targetPath));
|
||||
jobManager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: err.message || 'Analysis failed',
|
||||
|
|
@ -1821,15 +1958,20 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
|
||||
// Re-check the exact registered slot immediately before taking the lock.
|
||||
// The query resolver already validates it, but this closes the gap between
|
||||
// lookup and a long-running metadata-writing job.
|
||||
const storagePath = await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS);
|
||||
|
||||
// Check shared repo lock — prevent concurrent analyze + embed on same repo
|
||||
const repoLockPath = entry.storagePath;
|
||||
const repoLockPath = storagePath;
|
||||
const lockErr = acquireRepoLock(repoLockPath);
|
||||
if (lockErr) {
|
||||
res.status(409).json({ error: lockErr });
|
||||
return;
|
||||
}
|
||||
|
||||
const job = embedJobManager.createJob({ repoPath: entry.storagePath });
|
||||
const job = embedJobManager.createJob({ repoPath: storagePath });
|
||||
embedJobManager.updateJob(job.id, {
|
||||
repoName: entry.name,
|
||||
status: 'analyzing' as any,
|
||||
|
|
@ -1853,8 +1995,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
let partialRunError: string | undefined;
|
||||
let partialRunDetail: AnalyzeJobPartialOutcome | undefined;
|
||||
try {
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const ftsSession = await loadFtsSession(entry.storagePath);
|
||||
const lbugPath = path.join(storagePath, LBUG_DIRECTORY);
|
||||
const ftsSession = await loadFtsSession(storagePath);
|
||||
let embeddingMeta = ftsSession.meta;
|
||||
await withLbugDb(
|
||||
lbugPath,
|
||||
|
|
@ -1903,7 +2045,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// without a fresh read, a concurrent writer's update (e.g. a
|
||||
// --repair-fts capability stamp) would be silently reverted on
|
||||
// every checkpoint save for the job's whole lifetime.
|
||||
const latestMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta;
|
||||
const latestMeta = (await loadMeta(storagePath)) ?? embeddingMeta;
|
||||
// `stats.embeddings` only moves when the caller MEASURED the
|
||||
// live count (the post-flush `onCheckpoint`). The window-start
|
||||
// callback measures nothing and passes nothing: restating the
|
||||
|
|
@ -1922,7 +2064,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
},
|
||||
embeddings,
|
||||
);
|
||||
await saveMeta(entry.storagePath, embeddingMeta);
|
||||
await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS);
|
||||
await saveMeta(storagePath, embeddingMeta);
|
||||
};
|
||||
/**
|
||||
* Count the persisted rows, or report the answer never arrived.
|
||||
|
|
@ -2021,7 +2164,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// `embeddingCheckpoint` is the marker this run's own mid-run
|
||||
// writer saved, which is the only record of the work when the
|
||||
// count query could not answer.
|
||||
const finalMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta;
|
||||
const finalMeta = (await loadMeta(storagePath)) ?? embeddingMeta;
|
||||
const finalizeContext: EmbedRunFinalizeContext = {
|
||||
measuredEmbeddings: persistedEmbeddingCountOrUndefined(measuredEmbeddings),
|
||||
onDisk: finalMeta,
|
||||
|
|
@ -2041,7 +2184,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
{ ...finalMeta, embeddingCheckpoint: outcome.checkpoint },
|
||||
measuredEmbeddings,
|
||||
);
|
||||
await saveMeta(entry.storagePath, embeddingMeta);
|
||||
await requireRegisteredStoragePath(entry, STATUS_STORAGE_REQUIREMENTS);
|
||||
await saveMeta(storagePath, embeddingMeta);
|
||||
},
|
||||
skipFtsOption(ftsSession.skipFts),
|
||||
);
|
||||
|
|
@ -2087,6 +2231,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
|
||||
res.status(202).json({ jobId: job.id, status: 'analyzing' });
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
if (err.message?.includes('already in progress')) {
|
||||
res.status(409).json({ error: err.message });
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,14 @@ import {
|
|||
type StalenessInfo,
|
||||
type StalenessPayload,
|
||||
} from '../core/staleness-status.js';
|
||||
import type { ContentRetention, RepoMeta } from '../storage/repo-meta.js';
|
||||
import type { RegistryEntry } from '../storage/repo-manager.js';
|
||||
import type { RepoMeta } from '../storage/repo-meta.js';
|
||||
|
||||
/** Retention + checkout facts computed by the route (see getSourceAvailability). */
|
||||
export interface RepoProjectionSource {
|
||||
contentRetention: ContentRetention;
|
||||
sourceAvailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staleness through the shared {@link stalenessPayload} builder, so this route
|
||||
|
|
@ -38,13 +44,20 @@ export const stalenessField = (info: StalenessInfo): { staleness?: StalenessPayl
|
|||
};
|
||||
|
||||
/** One entry of `GET /api/repos`. */
|
||||
export const projectRepoListEntry = (entry: RegistryEntry, staleness: StalenessInfo) => ({
|
||||
export const projectRepoListEntry = (
|
||||
entry: RegistryEntry,
|
||||
staleness: StalenessInfo,
|
||||
source: RepoProjectionSource,
|
||||
) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
repoPath: entry.path,
|
||||
storagePath: entry.storagePath,
|
||||
indexedAt: entry.indexedAt,
|
||||
lastCommit: entry.lastCommit,
|
||||
stats: entry.stats,
|
||||
contentRetention: source.contentRetention,
|
||||
sourceAvailable: source.sourceAvailable,
|
||||
// The registry has carried these since #2106; #3199 made them load-bearing
|
||||
// over HTTP, because a branch-pinned analyze now gets its own entry and the
|
||||
// only other way to tell two entries apart is to parse the clone-directory
|
||||
|
|
@ -63,13 +76,17 @@ export const projectRepoDetail = (
|
|||
entry: RegistryEntry,
|
||||
meta: RepoMeta | null | undefined,
|
||||
staleness: StalenessInfo,
|
||||
source: RepoProjectionSource,
|
||||
) => ({
|
||||
name: entry.name,
|
||||
repoPath: entry.path,
|
||||
storagePath: entry.storagePath,
|
||||
indexedAt: meta?.indexedAt ?? entry.indexedAt,
|
||||
stats: meta?.stats ?? entry.stats ?? {},
|
||||
lastCommit: meta?.lastCommit ?? entry.lastCommit,
|
||||
branch: meta?.branch ?? entry.branch,
|
||||
contentRetention: source.contentRetention,
|
||||
sourceAvailable: source.sourceAvailable,
|
||||
...stalenessField(staleness),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@
|
|||
* A crashed/killed process can leave a `.staging-*` directory under
|
||||
* UPLOAD_ROOT (the normal path removes it on success/failure/abort). This
|
||||
* sweep, run once at server startup, removes staging dirs older than a
|
||||
* threshold. Promoted upload dirs are persistent registered repos (like
|
||||
* clones) and are NOT touched here — they are removed via DELETE /api/repo.
|
||||
* threshold. Stale promoted upload directories that are no longer registered
|
||||
* are also removed. Registered promoted dirs stay (DELETE /api/repo).
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fsp from 'fs/promises';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { UPLOAD_ROOT, STAGING_PREFIX } from './upload-paths.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
readRegistryStrictIfPresent,
|
||||
registryPathEquals,
|
||||
} from '../storage/repo-manager.js';
|
||||
|
||||
export interface SweepOptions {
|
||||
/** Remove staging dirs older than this (default 6h). */
|
||||
|
|
@ -21,6 +26,15 @@ export interface SweepOptions {
|
|||
now?: number;
|
||||
}
|
||||
|
||||
const removeSweptDir = async (full: string, removed: string[]): Promise<void> => {
|
||||
try {
|
||||
await fsp.rm(full, { recursive: true, force: true });
|
||||
removed.push(full);
|
||||
} catch {
|
||||
/* Permission or transient errors leave the path unlisted. */
|
||||
}
|
||||
};
|
||||
|
||||
export async function sweepStaleUploads(opts: SweepOptions = {}): Promise<{ removed: string[] }> {
|
||||
const maxAgeMs = opts.maxAgeMs ?? 6 * 60 * 60 * 1000;
|
||||
const root = opts.root ?? UPLOAD_ROOT;
|
||||
|
|
@ -34,6 +48,8 @@ export async function sweepStaleUploads(opts: SweepOptions = {}): Promise<{ remo
|
|||
return { removed }; // root does not exist yet — nothing to sweep
|
||||
}
|
||||
|
||||
const stalePromotedDirs: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const full = path.join(root, entry.name);
|
||||
|
|
@ -43,25 +59,43 @@ export async function sweepStaleUploads(opts: SweepOptions = {}): Promise<{ remo
|
|||
|
||||
if (entry.name.startsWith(STAGING_PREFIX)) {
|
||||
// Transient staging dir orphaned by a crash — always removable.
|
||||
await fsp.rm(full, { recursive: true, force: true }).catch(() => {});
|
||||
removed.push(full);
|
||||
await removeSweptDir(full, removed);
|
||||
} else {
|
||||
// Promoted upload dir. A successfully-analyzed (registered) repo always
|
||||
// has a `.gitnexus` index inside it; a stale promoted dir WITHOUT one is
|
||||
// an orphan from an analysis that failed before registering — remove it.
|
||||
const hasIndex = await fsp
|
||||
.access(path.join(full, '.gitnexus'))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!hasIndex) {
|
||||
await fsp.rm(full, { recursive: true, force: true }).catch(() => {});
|
||||
removed.push(full);
|
||||
}
|
||||
stalePromotedDirs.push(full);
|
||||
}
|
||||
} catch {
|
||||
/* stat race — skip */
|
||||
}
|
||||
}
|
||||
|
||||
// Promoted upload directories are source repositories. Their persistence is
|
||||
// determined by registry membership, not by whether their index currently
|
||||
// happens to be materialized or where that index is stored. Registry failure
|
||||
// must never turn into deletion; staging cleanup above remains independent.
|
||||
//
|
||||
// A missing registry.json is first-run emptiness in readRegistryStrict.
|
||||
// That must not be treated as "nothing is registered" here — we cannot
|
||||
// prove a promoted dir is unregistered when the file is absent.
|
||||
let registeredPaths: string[];
|
||||
try {
|
||||
const entries = await readRegistryStrictIfPresent();
|
||||
if (entries === undefined) return { removed };
|
||||
registeredPaths = entries
|
||||
.filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0)
|
||||
.map((entry) => canonicalizePath(entry.path));
|
||||
} catch {
|
||||
return { removed };
|
||||
}
|
||||
|
||||
for (const full of stalePromotedDirs) {
|
||||
const canonical = canonicalizePath(full);
|
||||
const isRegistered = registeredPaths.some((registeredPath) =>
|
||||
registryPathEquals(registeredPath, canonical),
|
||||
);
|
||||
if (!isRegistered) {
|
||||
await removeSweptDir(full, removed);
|
||||
}
|
||||
}
|
||||
|
||||
return { removed };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,13 +68,13 @@ export const branchSlug = (rawRef: string): string => {
|
|||
export const resolveBranchPlacement = async (
|
||||
repoPath: string,
|
||||
label: string | null,
|
||||
resolvedStoragePath?: string,
|
||||
): Promise<{ branch?: string }> => {
|
||||
// Detached HEAD / non-git / no label → flat (CI-safe, byte-identical).
|
||||
if (!label) return {};
|
||||
// The flat slot only — identical to `getStoragePaths(repoPath).storagePath`,
|
||||
// which is `getStoragePath(repoPath)` verbatim (the `branch` argument only
|
||||
// ever scopes `lbugPath`/`metaPath`, never `storagePath`).
|
||||
const storagePath = getStoragePath(repoPath);
|
||||
// The resolved flat slot. Callers may pass an ownership-validated path
|
||||
// (including external storage); otherwise this falls back to getStoragePath.
|
||||
const storagePath = resolvedStoragePath ?? getStoragePath(repoPath);
|
||||
const flatMeta = await loadMeta(storagePath);
|
||||
// The flat slot's owner is authoritative ONLY when it is a non-empty string.
|
||||
// A corrupt/hand-edited meta (empty string, or a non-string value that slips
|
||||
|
|
|
|||
|
|
@ -250,7 +250,6 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// capture schemas would have shared one PARSE_CACHE_VERSION and the durable
|
||||
// ParsedFile store would have replayed pre-fix ParsedFiles verbatim for one of
|
||||
// them. Only comparing against origin/main at MERGE time surfaces it.
|
||||
// PR #2840 (Objective-C, draft) still claims 44 as well — it must move too.
|
||||
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
|
||||
// 45 -> 46 for the JavaScript bare-identifier read captures (A2), which emit
|
||||
// `@reference.read.identifier` in value positions (call arguments,
|
||||
|
|
@ -664,6 +663,7 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// `route-extractors/` and `workers/` module content — would close the missing-
|
||||
// bump axis without invalidating on unrelated churn, and is the real follow-up.
|
||||
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
|
||||
//
|
||||
// 80 -> 81: ParsedFile and parse-cache shards are one immutable `.v8` envelope
|
||||
// each (no JSON/path/generation siblings). A v80 index still names `.json`
|
||||
// keys and would skip workers while scope-resolution found nothing — the
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@
|
|||
import fs from 'fs/promises';
|
||||
import { realpathSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { getInferredRepoName, resolveRepoIdentityRoot, stripUrlCredentials } from './git.js';
|
||||
import {
|
||||
getGitRoot,
|
||||
getInferredRepoName,
|
||||
resolveRepoIdentityRoot,
|
||||
stripUrlCredentials,
|
||||
} from './git.js';
|
||||
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
||||
import { writeFileAtomic } from './fs-atomic.js';
|
||||
import { getGlobalDir } from './global-dir.js';
|
||||
|
|
@ -41,10 +46,23 @@ import {
|
|||
getStoragePath,
|
||||
isMissingFilesystemError,
|
||||
loadMeta,
|
||||
tryReadMetaFile,
|
||||
type AnalyzerRunnerIdentity,
|
||||
type RepoMeta,
|
||||
} from './repo-meta.js';
|
||||
import { LBUG_DIRECTORY } from './storage-constants.js';
|
||||
import {
|
||||
defaultStoragePath,
|
||||
ensureStoragePathWritable,
|
||||
InvalidStoragePathError,
|
||||
inspectResolvedStorage,
|
||||
inspectRegisteredStorage,
|
||||
inspectStoragePath,
|
||||
isTransientStorageInspection,
|
||||
LIST_STORAGE_REQUIREMENTS,
|
||||
requireDeletableStoragePath,
|
||||
StorageDeletionError,
|
||||
validateConfiguredStoragePath,
|
||||
} from './storage-resolver.js';
|
||||
|
||||
// Re-export the #2106 branch primitives (extracted to branch-index.ts, R10) so
|
||||
// existing `repo-manager` import sites and tests keep working unchanged.
|
||||
|
|
@ -54,9 +72,12 @@ export type { BranchSummary };
|
|||
// Re-export the metadata primitives (extracted to repo-meta.ts) for the same
|
||||
// reason. They moved DOWN a layer so `branch-index.ts` can read the flat slot's
|
||||
// metadata without importing back out of this module — see repo-meta.ts for the
|
||||
// cycle that made the extraction necessary. `LEGACY_METADATA_FILE` and
|
||||
// `tryReadMetaFile` stay module-private here, exactly as before.
|
||||
// cycle that made the extraction necessary. `LEGACY_METADATA_FILE` stays
|
||||
// module-private here, exactly as before.
|
||||
export { getStoragePath, INDEX_METADATA_FILE, isMissingFilesystemError, loadMeta };
|
||||
export { ensureStoragePathWritable, InvalidStoragePathError };
|
||||
export { CONTENT_RETENTION_SCHEMA_VERSION } from './repo-meta.js';
|
||||
export type { ContentRetention, FtsProfile } from './repo-meta.js';
|
||||
export type { AnalyzerRunnerIdentity, RepoMeta };
|
||||
export { getGlobalDir } from './global-dir.js';
|
||||
|
||||
|
|
@ -194,12 +215,16 @@ const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
|
|||
* metaDir is the directory containing the metadata file — both handle the
|
||||
* legacy mirror automatically.
|
||||
*/
|
||||
export const getStoragePaths = (repoPath: string, branch?: string) => {
|
||||
const storagePath = getStoragePath(repoPath);
|
||||
export const getStoragePaths = (
|
||||
repoPath: string,
|
||||
branch?: string,
|
||||
resolvedStoragePath?: string,
|
||||
) => {
|
||||
const storagePath = resolvedStoragePath ?? getStoragePath(repoPath);
|
||||
const baseDir = branch ? path.join(storagePath, BRANCHES_DIR, branchSlug(branch)) : storagePath;
|
||||
return {
|
||||
storagePath,
|
||||
lbugPath: path.join(baseDir, 'lbug'),
|
||||
lbugPath: path.join(baseDir, LBUG_DIRECTORY),
|
||||
metaPath: path.join(baseDir, INDEX_METADATA_FILE), // Branch-specific metadata file
|
||||
};
|
||||
};
|
||||
|
|
@ -288,99 +313,97 @@ export const saveMeta = async (metaDir: string, meta: RepoMeta): Promise<void> =
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a path has a GitNexus index (metadata file or legacy location)
|
||||
*/
|
||||
/** Check whether the resolved storage contains an owned, usable code index. */
|
||||
export const hasIndex = async (repoPath: string): Promise<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;
|
||||
}
|
||||
}
|
||||
const inspection = await inspectResolvedStorage(repoPath);
|
||||
return inspection.state === 'owned' && inspection.hasCodeIndexDB;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load an indexed repo from a path (checks metadata file first, then legacy)
|
||||
*/
|
||||
/** Load an owned index from one already-determined repository path. */
|
||||
export const loadRepo = async (repoPath: string): Promise<IndexedRepo | null> => {
|
||||
const paths = getStoragePaths(repoPath);
|
||||
const inspection = await inspectResolvedStorage(repoPath);
|
||||
// Do not require LadybugDB here: clean needs to locate an owned
|
||||
// metadata-only slot so it can remove interrupted or legacy remnants.
|
||||
if (inspection.state !== 'owned') return null;
|
||||
|
||||
const paths = getStoragePaths(inspection.repoPath, undefined, inspection.storagePath);
|
||||
const meta = await loadMeta(paths.storagePath);
|
||||
if (!meta) return null;
|
||||
|
||||
return {
|
||||
repoPath: path.resolve(repoPath),
|
||||
repoPath: inspection.repoPath,
|
||||
...paths,
|
||||
meta,
|
||||
};
|
||||
};
|
||||
|
||||
/** `indexedAt` as epoch millis; 0 when absent/unparseable (i.e. oldest). */
|
||||
const metaTimestamp = (meta: RepoMeta): number => {
|
||||
const t = Date.parse(meta.indexedAt ?? '');
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
type ReconcileMetadataRead =
|
||||
| { state: 'absent' }
|
||||
| { state: 'invalid'; error: unknown }
|
||||
| { state: 'valid'; meta: RepoMeta };
|
||||
|
||||
const readReconcileMetadata = async (
|
||||
dir: string,
|
||||
filename: typeof INDEX_METADATA_FILE | typeof LEGACY_METADATA_FILE,
|
||||
): Promise<ReconcileMetadataRead> => {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path.join(dir, filename), 'utf-8');
|
||||
} catch (error) {
|
||||
return isMissingFilesystemError(error) ? { state: 'absent' } : { state: 'invalid', error };
|
||||
}
|
||||
|
||||
try {
|
||||
return { state: 'valid', meta: JSON.parse(raw) as RepoMeta };
|
||||
} catch (error) {
|
||||
return { state: 'invalid', error };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconcile `gitnexus.json` and the legacy `meta.json` mirror in one
|
||||
* directory: whichever parses and is fresher (by `indexedAt`) wins and is
|
||||
* re-written to BOTH files via `saveMeta`. Never deletes anything.
|
||||
* Reconcile `gitnexus.json` and the legacy `meta.json` mirror in one directory.
|
||||
* A valid primary is authoritative; legacy metadata is used only when the
|
||||
* primary is provably absent. Never deletes anything.
|
||||
* Returns true when a write occurred.
|
||||
*/
|
||||
const reconcileMetaDir = async (dir: string): Promise<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
|
||||
}
|
||||
}
|
||||
const primary = await readReconcileMetadata(dir, INDEX_METADATA_FILE);
|
||||
if (primary.state === 'invalid') {
|
||||
logger.warn(
|
||||
{ dir, filename: INDEX_METADATA_FILE, err: primary.error },
|
||||
'Primary metadata is unreadable/corrupt; leaving both metadata files unchanged',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (primary && legacy) {
|
||||
if (JSON.stringify(primary) === JSON.stringify(legacy)) return false; // converged
|
||||
// Both parse but differ — the fresher one wins (an older binary may have
|
||||
// re-analyzed and written only meta.json AFTER gitnexus.json was created;
|
||||
// blind-preferring the primary would permanently shadow that fresher
|
||||
// state, silently certifying a stale index as up to date).
|
||||
const winner = metaTimestamp(legacy) > metaTimestamp(primary) ? legacy : primary;
|
||||
await saveMeta(dir, winner);
|
||||
logger.info(
|
||||
{ dir, winner: winner === legacy ? LEGACY_METADATA_FILE : INDEX_METADATA_FILE },
|
||||
'Reconciled diverged metadata files (fresher indexedAt wins, written to both)',
|
||||
);
|
||||
if (primary.state === 'valid') {
|
||||
const legacy = await readReconcileMetadata(dir, LEGACY_METADATA_FILE);
|
||||
if (legacy.state === 'valid' && JSON.stringify(primary.meta) === JSON.stringify(legacy.meta)) {
|
||||
return false;
|
||||
}
|
||||
await saveMeta(dir, primary.meta);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exactly one parses — establish/repair the other so both stay in sync.
|
||||
const survivor = (primary ?? legacy) as RepoMeta;
|
||||
await saveMeta(dir, survivor);
|
||||
return true;
|
||||
const legacy = await readReconcileMetadata(dir, LEGACY_METADATA_FILE);
|
||||
if (legacy.state === 'valid') {
|
||||
await saveMeta(dir, legacy.meta);
|
||||
return true;
|
||||
}
|
||||
if (legacy.state === 'invalid') {
|
||||
logger.warn(
|
||||
{ dir, filename: LEGACY_METADATA_FILE, err: legacy.error },
|
||||
'Legacy metadata is unreadable/corrupt; leaving it unchanged',
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconcile the metadata files for a repo's flat slot and every
|
||||
* `branches/<slug>/` slot. Runs once per `analyze` (see run-analyze.ts).
|
||||
* `branches/<slug>/` slot. `resolvedStoragePath` is the ownership-validated
|
||||
* write target supplied by analyze; callers that omit it retain the historic
|
||||
* repository-path lookup for compatibility.
|
||||
*
|
||||
* This is a best-effort compatibility sync, NOT a one-way migration: the
|
||||
* legacy `meta.json` mirror is kept in sync indefinitely (removal happens at
|
||||
|
|
@ -389,8 +412,11 @@ const reconcileMetaDir = async (dir: string): Promise<boolean> => {
|
|||
* 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);
|
||||
export const reconcileMetadataFiles = async (
|
||||
repoPath: string,
|
||||
resolvedStoragePath?: string,
|
||||
): Promise<boolean> => {
|
||||
const storagePath = resolvedStoragePath ?? getStoragePath(repoPath);
|
||||
let changed = await reconcileMetaDir(storagePath);
|
||||
|
||||
const branchesDir = path.join(storagePath, BRANCHES_DIR);
|
||||
|
|
@ -423,20 +449,10 @@ export const reconcileMetadataFiles = async (repoPath: string): Promise<boolean>
|
|||
return changed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find .gitnexus by walking up from a starting path
|
||||
*/
|
||||
/** Resolve the Git worktree first, then load only that repository's index. */
|
||||
export const findRepo = async (startPath: string): Promise<IndexedRepo | null> => {
|
||||
let current = path.resolve(startPath);
|
||||
const root = path.parse(current).root;
|
||||
|
||||
while (current !== root) {
|
||||
const repo = await loadRepo(current);
|
||||
if (repo) return repo;
|
||||
current = path.dirname(current);
|
||||
}
|
||||
|
||||
return null;
|
||||
const resolved = path.resolve(startPath);
|
||||
return loadRepo(getGitRoot(resolved) ?? resolved);
|
||||
};
|
||||
|
||||
export function isReadOnlyFilesystemError(err: unknown): boolean {
|
||||
|
|
@ -447,8 +463,12 @@ export function isReadOnlyFilesystemError(err: unknown): boolean {
|
|||
/**
|
||||
* Keep .gitnexus/ ignored. It contains local index state and caches.
|
||||
*/
|
||||
export const ensureGitNexusIgnored = async (repoPath: string): Promise<void> => {
|
||||
const gitignorePath = path.join(getStoragePath(repoPath), '.gitignore');
|
||||
export const ensureGitNexusIgnored = async (
|
||||
repoPath: string,
|
||||
resolvedStoragePath?: string,
|
||||
): Promise<void> => {
|
||||
const storagePath = resolvedStoragePath ?? getStoragePath(repoPath);
|
||||
const gitignorePath = path.join(storagePath, '.gitignore');
|
||||
const desired = '*\n';
|
||||
|
||||
// Idempotent fast path: skip the write entirely when the file already has
|
||||
|
|
@ -665,14 +685,7 @@ const isResolvableEntry = (value: unknown): value is RegistryEntry => {
|
|||
* ENOENT is lenient in BOTH modes: no file genuinely means nothing has been
|
||||
* registered yet, and every first-run path depends on that.
|
||||
*/
|
||||
const readRegistryFile = async (strict: boolean): Promise<RegistryEntry[]> => {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8');
|
||||
} catch (err) {
|
||||
if (strict && (err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
return [];
|
||||
}
|
||||
const parseRegistryContents = async (raw: string, strict: boolean): Promise<RegistryEntry[]> => {
|
||||
try {
|
||||
// The parse gets its OWN guarded region, narrower than the checks below,
|
||||
// and the parser's error is DISCARDED rather than rethrown.
|
||||
|
|
@ -704,24 +717,50 @@ const readRegistryFile = async (strict: boolean): Promise<RegistryEntry[]> => {
|
|||
}
|
||||
return [];
|
||||
}
|
||||
// `storagePath` was not present in pre-external-storage registry files.
|
||||
// Normalize only that legacy absence at the read boundary; malformed values
|
||||
// remain visible to the strict destructive-operation safety checks below.
|
||||
const entries = data.map((entry) =>
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
!Array.isArray(entry) &&
|
||||
typeof (entry as Record<string, unknown>).path === 'string' &&
|
||||
(entry as Record<string, unknown>).storagePath === undefined
|
||||
? {
|
||||
...(entry as Record<string, unknown>),
|
||||
storagePath: defaultStoragePath((entry as Record<string, unknown>).path as string),
|
||||
}
|
||||
: entry,
|
||||
) as RegistryEntry[];
|
||||
if (strict) {
|
||||
// Reject the WHOLE registry, never filter the bad rows out. Dropping them
|
||||
// would report the repos they name as unregistered, which is precisely
|
||||
// the unreadable-as-missing answer this mode refuses to give.
|
||||
const bad = data.findIndex((entry) => !isResolvableEntry(entry));
|
||||
const bad = entries.findIndex((entry) => !isResolvableEntry(entry));
|
||||
if (bad !== -1) {
|
||||
throw new Error(
|
||||
`${getGlobalRegistryPath()} entry ${bad} does not identify a repo — name and storagePath must be non-empty strings and path must be a string (registry is corrupt)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return sanitizeEntries(data as RegistryEntry[]);
|
||||
return sanitizeEntries(entries);
|
||||
} catch (err) {
|
||||
if (strict) throw err;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const readRegistryFile = async (strict: boolean): Promise<RegistryEntry[]> => {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8');
|
||||
} catch (err) {
|
||||
if (strict && (err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
return [];
|
||||
}
|
||||
return parseRegistryContents(raw, strict);
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the global registry. Returns empty array if not found — and, note, also
|
||||
* when the file exists but cannot be read or parsed. That is fine for a
|
||||
|
|
@ -752,6 +791,23 @@ export const readRegistry = async (): Promise<RegistryEntry[]> => readRegistryFi
|
|||
*/
|
||||
export const readRegistryStrict = async (): Promise<RegistryEntry[]> => readRegistryFile(true);
|
||||
|
||||
/**
|
||||
* Strict registry read that distinguishes "file is absent" from "file is
|
||||
* empty or unreadable". ENOENT returns `undefined`; corrupt/unreadable
|
||||
* files still throw. Callers that delete based on membership must not treat
|
||||
* a missing file as an empty registry.
|
||||
*/
|
||||
export const readRegistryStrictIfPresent = async (): Promise<RegistryEntry[] | undefined> => {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
||||
throw err;
|
||||
}
|
||||
return parseRegistryContents(raw, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Write the global registry to disk.
|
||||
*
|
||||
|
|
@ -808,6 +864,12 @@ export interface RegisterRepoOptions {
|
|||
* existing branch summaries).
|
||||
*/
|
||||
branch?: string;
|
||||
/**
|
||||
* The storage slot already selected and validated by the caller. Passing it
|
||||
* prevents registry registration from re-resolving configuration after an
|
||||
* analysis or index operation has begun.
|
||||
*/
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -903,13 +965,47 @@ const registerRepoUnlocked = async (
|
|||
// Canonicalisation is applied at COMPARE points only (see below),
|
||||
// which is where the cross-platform divergence actually matters.
|
||||
const resolved = path.resolve(repoPath);
|
||||
const { storagePath } = getStoragePaths(resolved);
|
||||
const storagePath =
|
||||
opts?.storagePath === undefined
|
||||
? getStoragePaths(resolved).storagePath
|
||||
: validateConfiguredStoragePath(opts.storagePath);
|
||||
|
||||
// Canonical form used strictly for comparison — `realpathSync.native`
|
||||
// expands macOS /var → /private/var and Windows 8.3 → long-name,
|
||||
// falling back to `path.resolve` when the path doesn't exist.
|
||||
const canonicalInput = canonicalizePath(repoPath);
|
||||
|
||||
// Production write paths pass the storage slot they already validated. Do
|
||||
// not let a changed environment/registry redirect their registry entry, and
|
||||
// require the metadata receipt to describe that same repository and slot.
|
||||
// The omitted-option path deliberately retains legacy direct-call behavior.
|
||||
if (opts?.storagePath !== undefined) {
|
||||
if (!registryPathEquals(canonicalizePath(meta.repoPath), canonicalInput)) {
|
||||
throw new Error(
|
||||
`Refusing to register ${resolved}: metadata belongs to ${meta.repoPath}, not this repository.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
meta.storagePath === undefined &&
|
||||
!registryPathEquals(
|
||||
canonicalizePath(storagePath),
|
||||
canonicalizePath(defaultStoragePath(resolved)),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to register ${resolved}: external storage metadata must bind storagePath to the selected directory.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
meta.storagePath !== undefined &&
|
||||
!registryPathEquals(canonicalizePath(meta.storagePath), canonicalizePath(storagePath))
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to register ${resolved}: metadata storagePath does not match the selected storage directory.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mutating writes must not treat an unreadable/truncated registry as empty
|
||||
// (#3094): lenient `readRegistry()` returns `[]` on parse failure and would
|
||||
// replace the machine-wide file with only this entry. ENOENT stays empty.
|
||||
|
|
@ -1134,22 +1230,51 @@ export const removeBranchIndex = async (repoPath: string, branch: string): Promi
|
|||
* delete (large sub-index, AV scan, network mount) never blocks every other
|
||||
* registry operation on the machine.
|
||||
*/
|
||||
export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Promise<void> => {
|
||||
export const adoptFlatBranchLabel = async (
|
||||
repoPath: string,
|
||||
branch: string,
|
||||
resolvedStoragePath?: string,
|
||||
): Promise<void> => {
|
||||
const canonicalInput = canonicalizePath(repoPath);
|
||||
const isRegistered = (list: RegistryEntry[]): number =>
|
||||
list.findIndex((e) => registryPathEquals(canonicalizePath(e.path), canonicalInput));
|
||||
// Cheap membership gate only (#2364 review F2): never touch the disk for an
|
||||
// unregistered repo. The mutate below re-reads its own fresh snapshot.
|
||||
if (isRegistered(await readRegistry()) < 0) return; // no-op, disk included (no self-heal)
|
||||
const initialEntries = await readRegistry();
|
||||
const initialIdx = isRegistered(initialEntries);
|
||||
if (initialIdx < 0) return; // no-op, disk included (no self-heal)
|
||||
|
||||
const resolved = path.resolve(repoPath);
|
||||
const { storagePath } = getStoragePaths(resolved);
|
||||
const storagePath =
|
||||
resolvedStoragePath === undefined
|
||||
? getStoragePaths(resolved).storagePath
|
||||
: validateConfiguredStoragePath(resolvedStoragePath);
|
||||
const registeredStoragePath = initialEntries[initialIdx].storagePath;
|
||||
if (!registryPathEquals(canonicalizePath(registeredStoragePath), canonicalizePath(storagePath))) {
|
||||
throw new Error(
|
||||
`Refusing to adopt branch metadata: the registry storage path does not match the selected storage directory.`,
|
||||
);
|
||||
}
|
||||
if (resolvedStoragePath !== undefined) {
|
||||
const inspection = await inspectStoragePath(storagePath, resolved);
|
||||
if (inspection.state !== 'owned') {
|
||||
throw new Error(
|
||||
`Refusing to adopt branch metadata: storage is not owned by this repository (${inspection.state}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Remove a shadowed sub-index directory, mirroring `clean --branch`'s
|
||||
// containment guard: the target MUST live under .gitnexus/branches/.
|
||||
const branchDir = path.join(storagePath, BRANCHES_DIR, branchSlug(branch));
|
||||
const branchesRoot = path.join(storagePath, BRANCHES_DIR) + path.sep;
|
||||
const branchesRoot = path.join(storagePath, BRANCHES_DIR);
|
||||
const branchDir = path.join(branchesRoot, branchSlug(branch));
|
||||
const branchRelativePath = path.relative(branchesRoot, branchDir);
|
||||
const branchDirIsContained =
|
||||
branchRelativePath !== '' &&
|
||||
branchRelativePath !== '..' &&
|
||||
!branchRelativePath.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(branchRelativePath);
|
||||
let dirGone = false;
|
||||
if (branchDir.startsWith(branchesRoot)) {
|
||||
if (branchDirIsContained) {
|
||||
let rmError: NodeJS.ErrnoException | undefined;
|
||||
await fs.rm(branchDir, { recursive: true, force: true }).catch((err: unknown) => {
|
||||
rmError = err as NodeJS.ErrnoException;
|
||||
|
|
@ -1181,6 +1306,8 @@ export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Pr
|
|||
'Could not remove the shadowed branch sub-index; keeping its registry summary so `gitnexus clean --branch` can still target it.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Refusing to adopt branch metadata: branch storage target escapes branches/.');
|
||||
}
|
||||
|
||||
// Re-read AFTER the potentially slow recursive rm, and under the lock: the
|
||||
|
|
@ -1192,6 +1319,9 @@ export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Pr
|
|||
const idx = isRegistered(entries);
|
||||
if (idx < 0) return; // unregistered concurrently → still a no-op
|
||||
const entry = entries[idx];
|
||||
if (!registryPathEquals(canonicalizePath(entry.storagePath), canonicalizePath(storagePath))) {
|
||||
return; // a concurrent registration selected a different slot
|
||||
}
|
||||
const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
|
||||
const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
|
||||
if (entry.branch === branch && !droppedSummary) return; // already coherent
|
||||
|
|
@ -1306,21 +1436,32 @@ export const isRepoRegistered = async (repoPath: string): Promise<boolean> => {
|
|||
* Verify that a successful `analyze` call actually produced an indexed,
|
||||
* registered repo on disk. Two checks, both strictly required:
|
||||
*
|
||||
* 1. `gitnexus.json` must exist at `<repoPath>/.gitnexus/gitnexus.json`
|
||||
* 1. `gitnexus.json` must exist in the storage directory selected for this
|
||||
* analysis (the caller may pass that exact directory).
|
||||
* (the primary metadata file; the legacy `meta.json` mirror is not
|
||||
* sufficient — a finalized analyze always writes the primary).
|
||||
* 2. The global registry (`getGlobalRegistryPath()`) must contain an
|
||||
* entry whose canonical path matches `repoPath`.
|
||||
* entry whose canonical path matches `repoPath`. The registry is read
|
||||
* with {@link readRegistryStrict}: a corrupt or unreadable file throws
|
||||
* rather than being treated as a missing registry-entry.
|
||||
*
|
||||
* Throws {@link AnalysisNotFinalizedError} on the first failure with the
|
||||
* specific missing artifact. Pure read — does not mutate disk state.
|
||||
*
|
||||
* Callers must skip this assertion on the `alreadyUpToDate` early-return
|
||||
* path, where the rebuild was deliberately not run.
|
||||
* The optional `resolvedStoragePath` carries the exact slot used by the
|
||||
* analysis. Omitting it preserves the legacy local-resolution behavior for
|
||||
* direct callers.
|
||||
*/
|
||||
export const assertAnalysisFinalized = async (repoPath: string): Promise<void> => {
|
||||
export const assertAnalysisFinalized = async (
|
||||
repoPath: string,
|
||||
resolvedStoragePath?: string,
|
||||
): Promise<void> => {
|
||||
const resolved = path.resolve(repoPath);
|
||||
const { storagePath, metaPath } = getStoragePaths(resolved);
|
||||
const storagePath =
|
||||
resolvedStoragePath === undefined
|
||||
? getStoragePaths(resolved).storagePath
|
||||
: validateConfiguredStoragePath(resolvedStoragePath);
|
||||
const metaPath = path.join(storagePath, INDEX_METADATA_FILE);
|
||||
|
||||
try {
|
||||
await fs.access(metaPath);
|
||||
|
|
@ -1328,7 +1469,14 @@ export const assertAnalysisFinalized = async (repoPath: string): Promise<void> =
|
|||
throw new AnalysisNotFinalizedError(resolved, storagePath, 'meta', getGlobalRegistryPath());
|
||||
}
|
||||
|
||||
if (!(await isRepoRegistered(resolved))) {
|
||||
const canonicalRepoPath = canonicalizePath(resolved);
|
||||
const canonicalStoragePath = canonicalizePath(storagePath);
|
||||
const registeredAtStoragePath = (await readRegistryStrict()).some(
|
||||
(entry) =>
|
||||
registryPathEquals(canonicalizePath(entry.path), canonicalRepoPath) &&
|
||||
registryPathEquals(canonicalizePath(entry.storagePath), canonicalStoragePath),
|
||||
);
|
||||
if (!registeredAtStoragePath) {
|
||||
throw new AnalysisNotFinalizedError(
|
||||
resolved,
|
||||
storagePath,
|
||||
|
|
@ -1339,13 +1487,12 @@ export const assertAnalysisFinalized = async (repoPath: string): Promise<void> =
|
|||
};
|
||||
|
||||
/**
|
||||
* Thrown by {@link assertSafeStoragePath} when a registry entry's
|
||||
* `storagePath` does NOT point at the expected `<entry.path>/.gitnexus`
|
||||
* subfolder. CLI destructive commands (`remove`, `clean --all`) should
|
||||
* catch this and exit non-zero without deleting anything — the usual
|
||||
* cause is a corrupted or hand-edited `~/.gitnexus/registry.json`, and
|
||||
* proceeding would mean `fs.rm(recursive: true)` on whatever odd path
|
||||
* the entry is pointing at.
|
||||
* Thrown by {@link assertSafeStoragePath} when {@link requireDeletableStoragePath}
|
||||
* rejects the registry entry. Repository-local `.gitnexus` may still be
|
||||
* removed when it is missing, empty, unowned, or foreign; an external slot
|
||||
* is removable only when metadata binds both the repository and that exact
|
||||
* path. CLI destructive commands (`remove`, `clean --all`) should catch this
|
||||
* and exit non-zero without deleting anything.
|
||||
*/
|
||||
export class UnsafeStoragePathError extends Error {
|
||||
readonly kind = 'UnsafeStoragePathError' as const;
|
||||
|
|
@ -1369,9 +1516,10 @@ export class UnsafeStoragePathError extends Error {
|
|||
/**
|
||||
* Guard rail for destructive CLI paths (`remove` #664,
|
||||
* `clean --all` #258, future MCP `remove` tool): verify that a
|
||||
* registry entry's `storagePath` is the canonical `<repo>/.gitnexus`
|
||||
* subfolder of its `path`. If not, throw {@link UnsafeStoragePathError}
|
||||
* so the caller exits without touching disk.
|
||||
* registry entry's `storagePath` names the registered index. Repository-local
|
||||
* indexes are validated by their canonical `<repo>/.gitnexus` path; external
|
||||
* slots must additionally prove their ownership through matching persisted
|
||||
* metadata before a recursive deletion is allowed.
|
||||
*
|
||||
* Why this exists (#1003 review — @magyargergo):
|
||||
* - `~/.gitnexus/registry.json` is a plain-text user-writable file.
|
||||
|
|
@ -1388,22 +1536,23 @@ export class UnsafeStoragePathError extends Error {
|
|||
* the registry field. But `clean --all` DOES iterate the registry
|
||||
* and trust each entry's stored storagePath (same shape as
|
||||
* `remove`), so this helper must be wired into that loop too.
|
||||
* - `server/api.ts` recomputes storagePath from `getStoragePath(entry.path)`
|
||||
* and so is likewise safe-by-construction.
|
||||
* - An external slot is intentionally not constrained under a checkout. Its
|
||||
* own metadata must bind both the source checkout path and the resolved
|
||||
* storage path before it may be removed.
|
||||
*
|
||||
* Pure string check — does NOT require the paths to exist on disk.
|
||||
* Windows: case-insensitive; POSIX: case-sensitive. Matches the
|
||||
* comparison shape used elsewhere in this module.
|
||||
* The resolver preserves the legacy local-path allowance while also rejecting
|
||||
* foreign or malformed metadata. External slots additionally require metadata
|
||||
* ownership so a hand-edited registry cannot redirect a destructive command to
|
||||
* an arbitrary directory.
|
||||
*/
|
||||
export const assertSafeStoragePath = (entry: RegistryEntry): void => {
|
||||
const expected = path.join(path.resolve(entry.path), '.gitnexus');
|
||||
const actual = path.resolve(entry.storagePath);
|
||||
const matches =
|
||||
process.platform === 'win32'
|
||||
? expected.toLowerCase() === actual.toLowerCase()
|
||||
: expected === actual;
|
||||
if (!matches) {
|
||||
throw new UnsafeStoragePathError(entry, expected, actual);
|
||||
export const assertSafeStoragePath = async (entry: RegistryEntry): Promise<void> => {
|
||||
try {
|
||||
await requireDeletableStoragePath(entry);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageDeletionError) {
|
||||
throw new UnsafeStoragePathError(entry, error.expectedStoragePath, error.actualStoragePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1497,81 +1646,102 @@ export const findRegistryEntryByName = (
|
|||
/**
|
||||
* List all registered repos from the global registry.
|
||||
*
|
||||
* With `validate: true`, prunes only entries whose metadata is *provably* gone
|
||||
* (fs.access on both gitnexus.json and legacy meta.json fails with ENOENT or
|
||||
* ENOTDIR) and persists the result on a best-effort basis: the pruned view is
|
||||
* always returned, even when the write fails. Entries that are merely "not provably
|
||||
* absent" — any other fs.access failure (EIO/EAGAIN/EBUSY/EACCES, etc.) — are
|
||||
* KEPT, so a transient I/O storm cannot wipe the registry. A kept entry is
|
||||
* therefore "not confirmed present," not "confirmed present"; downstream DB
|
||||
* opens are independently and lazily guarded.
|
||||
* With `validate: true`, returns only entries whose storage is owned by the
|
||||
* registered repository and contains a LadybugDB index. Entries whose storage
|
||||
* path is provably gone, empty, or has no ownership metadata are pruned and
|
||||
* persisted on a best-effort basis. A storage entry that cannot be inspected
|
||||
* because of a transient filesystem error is kept in the returned view, so an
|
||||
* I/O storm cannot make the registry disappear; it remains unconfirmed until a
|
||||
* later validating read succeeds.
|
||||
*/
|
||||
const mapPool = async <T, R>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<R>,
|
||||
concurrency: number,
|
||||
): Promise<R[]> => {
|
||||
if (items.length === 0) return [];
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index] as T);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
};
|
||||
|
||||
export const listRegisteredRepos = async (opts?: {
|
||||
validate?: boolean;
|
||||
}): Promise<RegistryEntry[]> => {
|
||||
const entries = await readRegistry();
|
||||
if (!opts?.validate) return entries;
|
||||
|
||||
// Validate each entry still has a .gitnexus/ directory with metadata
|
||||
// Validate each entry through the shared storage resolver. The registry's
|
||||
// storagePath is intentional here: GITNEXUS_STORAGE_PATH must not redirect
|
||||
// validation of an explicitly registered repository to another slot.
|
||||
const valid: RegistryEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
// Named to avoid shadowing the exported `hasIndex` function above.
|
||||
let indexFound = false;
|
||||
let firstNonMissingError: NodeJS.ErrnoException | null = null;
|
||||
let lastMissingError: NodeJS.ErrnoException | null = null;
|
||||
|
||||
// Check for new metadata file first
|
||||
try {
|
||||
await fs.access(path.join(entry.storagePath, INDEX_METADATA_FILE));
|
||||
indexFound = true;
|
||||
} catch (err: any) {
|
||||
if (isMissingFilesystemError(err)) lastMissingError = err;
|
||||
else firstNonMissingError = err;
|
||||
}
|
||||
|
||||
// Fall back to legacy meta.json
|
||||
if (!indexFound) {
|
||||
try {
|
||||
await fs.access(path.join(entry.storagePath, LEGACY_METADATA_FILE));
|
||||
indexFound = true;
|
||||
} catch (err: any) {
|
||||
if (isMissingFilesystemError(err)) lastMissingError = err;
|
||||
else if (!firstNonMissingError) firstNonMissingError = err;
|
||||
}
|
||||
}
|
||||
|
||||
if (indexFound) {
|
||||
// Keep the exact inspected registry slot, not just the repository path.
|
||||
// A concurrent analyze may re-register the same checkout into another
|
||||
// external slot while this read-only validation walk is in flight.
|
||||
const prunedSlots = new Set<string>();
|
||||
const registrySlotKey = (entry: RegistryEntry): string =>
|
||||
`${canonicalizePath(entry.path)}\0${canonicalizePath(entry.storagePath)}`;
|
||||
const inspections = await mapPool(entries, inspectRegisteredStorage, 8);
|
||||
for (const [entry, inspection] of entries.map(
|
||||
(entry, i) => [entry, inspections[i]] as [RegistryEntry, (typeof inspections)[number]],
|
||||
)) {
|
||||
const meetsRequirements =
|
||||
LIST_STORAGE_REQUIREMENTS.allowedStates.includes(inspection.state) &&
|
||||
(!LIST_STORAGE_REQUIREMENTS.requireCodeIndexDB || inspection.hasCodeIndexDB);
|
||||
if (meetsRequirements) {
|
||||
valid.push(entry);
|
||||
} else if (
|
||||
inspection.state === 'missing' ||
|
||||
inspection.state === 'empty' ||
|
||||
(inspection.state === 'unowned' &&
|
||||
inspection.reason === 'Storage directory contains data but no valid ownership metadata.')
|
||||
) {
|
||||
// A missing/empty directory or a registry slot with no ownership
|
||||
// metadata is not a usable index. Removing only the registry row is
|
||||
// safe; the storage directory itself is never deleted here.
|
||||
prunedSlots.add(registrySlotKey(entry));
|
||||
} else if (isTransientStorageInspection(inspection)) {
|
||||
// Not provably absent or invalid. Keep the old safety behavior for
|
||||
// EIO/EAGAIN/EBUSY/EACCES-style filesystem failures.
|
||||
valid.push(entry);
|
||||
} else if (!firstNonMissingError && lastMissingError) {
|
||||
// Index genuinely removed — safe to prune
|
||||
} else {
|
||||
// Not provably absent — keep entry to prevent mass registry wipe.
|
||||
// Warn so an I/O storm becomes observable instead of silently
|
||||
// keeping (or, pre-fix, silently wiping) entries.
|
||||
logger.warn(
|
||||
{ name: entry.name, storagePath: entry.storagePath, code: firstNonMissingError?.code },
|
||||
'Keeping registry entry despite fs.access failure (not provably absent); not pruning to avoid mass registry wipe.',
|
||||
{
|
||||
name: entry.name,
|
||||
storagePath: entry.storagePath,
|
||||
state: inspection.state,
|
||||
hasCodeIndexDB: inspection.hasCodeIndexDB,
|
||||
reason: inspection.reason,
|
||||
},
|
||||
'Skipping registry entry during validation because its storage is not a usable owned code index.',
|
||||
);
|
||||
valid.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// If we pruned any entries, save the cleaned registry — under the lock, and
|
||||
// only then. The validation walk above is read-only (an fs.access per entry,
|
||||
// slow on a network mount or a large registry) and the common case prunes
|
||||
// nothing, so holding the global lock across it would serialize every
|
||||
// `gitnexus augment` behind unrelated registry work for no benefit. Re-read
|
||||
// inside the lock and drop the provably-absent paths from that fresh
|
||||
// snapshot, so a concurrent registration in the validation window survives.
|
||||
if (valid.length !== entries.length) {
|
||||
const pruned = new Set(
|
||||
entries.filter((entry) => !valid.includes(entry)).map((entry) => entry.path),
|
||||
);
|
||||
// only then. The validation walk above is read-only and can touch several
|
||||
// files per entry, so holding the global lock across it would serialize
|
||||
// every `gitnexus augment` behind unrelated registry work for no benefit.
|
||||
// Re-read inside the lock and drop only the same provably-absent storage
|
||||
// slots from that fresh snapshot, so a concurrent re-registration of the
|
||||
// same repository path into another slot survives.
|
||||
if (prunedSlots.size > 0) {
|
||||
try {
|
||||
await withRegistryLock(async () => {
|
||||
const fresh = await readRegistry();
|
||||
await writeRegistry(
|
||||
fresh.filter((entry) => !pruned.has(entry.path)),
|
||||
fresh.filter((entry) => !prunedSlots.has(registrySlotKey(entry))),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
|
@ -1581,7 +1751,7 @@ export const listRegisteredRepos = async (opts?: {
|
|||
// — this runs on MCP startup (LocalBackend.init → refreshRepos), where
|
||||
// nothing catches and a rejection reads as "Server disconnected".
|
||||
logger.warn(
|
||||
{ err, prunedCount: pruned.size },
|
||||
{ err, prunedCount: prunedSlots.size },
|
||||
'Could not persist the pruned global registry; continuing with the in-memory pruned view.',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
*
|
||||
* Holds the on-disk shape of a GitNexus index's metadata file
|
||||
* (`.gitnexus/gitnexus.json`, plus its legacy `meta.json` mirror) and the
|
||||
* read-side helpers that locate and parse it. Nothing here writes, and nothing
|
||||
* here knows about the global registry.
|
||||
* read-side helpers that locate and parse it. Nothing here writes. Path
|
||||
* lookup may consult configured or registered storage via `resolveStoragePath`;
|
||||
* registry mutation stays in `repo-manager.ts`.
|
||||
*
|
||||
* Why it is its own module: `repo-manager.ts` owns the registry and the write
|
||||
* side, and `branch-index.ts` (#2106) owns the multi-branch slug/placement
|
||||
|
|
@ -30,14 +31,16 @@ import path from 'path';
|
|||
import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js';
|
||||
import type { NameFallbackSummary } from '../core/ingestion/scope-resolution/name-fallback-summary.js';
|
||||
import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js';
|
||||
import { resolveStoragePath } from './storage-resolver.js';
|
||||
import type { ScopeExtractionFailureSummary } from '../core/ingestion/scope-resolution/scope-extraction-failures.js';
|
||||
import { INDEX_METADATA_FILE, LEGACY_METADATA_FILE } from './storage-constants.js';
|
||||
|
||||
/** The `.gitnexus` directory name, relative to a repo root. */
|
||||
export const GITNEXUS_DIR = '.gitnexus';
|
||||
export const INDEX_METADATA_FILE = 'gitnexus.json';
|
||||
// Dual-written mirror of INDEX_METADATA_FILE, kept for backward compatibility
|
||||
// with consumers that only know the pre-rename filename (see MIGRATION.md).
|
||||
export const LEGACY_METADATA_FILE = 'meta.json';
|
||||
export { GITNEXUS_DIR, INDEX_METADATA_FILE, LEGACY_METADATA_FILE } from './storage-constants.js';
|
||||
|
||||
/** How much source text an index is allowed to persist. */
|
||||
export type ContentRetention = 'full' | 'symbol' | 'none';
|
||||
export type FtsProfile = 'full' | 'symbol-no-file-content' | 'name-only';
|
||||
export const CONTENT_RETENTION_SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Versioned receipt for the analyzer process that produced an index.
|
||||
|
|
@ -85,8 +88,14 @@ export interface AnalyzerRunnerIdentity {
|
|||
|
||||
export interface RepoMeta {
|
||||
repoPath: string;
|
||||
/** Complete index directory selected for this successful analysis. */
|
||||
storagePath?: string;
|
||||
lastCommit: string;
|
||||
indexedAt: string;
|
||||
/** Missing on legacy metadata means the upstream-compatible `full` profile. */
|
||||
contentRetention?: ContentRetention;
|
||||
contentRetentionSchemaVersion?: number;
|
||||
ftsProfile?: FtsProfile;
|
||||
/**
|
||||
* Runtime enrichment mode plus redacted scan exclusions. Payload data and
|
||||
* absolute/external paths are deliberately excluded from metadata.
|
||||
|
|
@ -579,11 +588,12 @@ export interface RepoMeta {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the .gitnexus storage path for a repository.
|
||||
* Used for local metadata and caches that are not committed.
|
||||
* Resolve the configured storage path for a repository.
|
||||
* This can be its repository-local `.gitnexus` directory, an external slot,
|
||||
* or a previously registered storage path.
|
||||
*/
|
||||
export const getStoragePath = (repoPath: string): string => {
|
||||
return path.join(path.resolve(repoPath), GITNEXUS_DIR);
|
||||
return resolveStoragePath(repoPath);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
6
gitnexus/src/storage/storage-constants.ts
Normal file
6
gitnexus/src/storage/storage-constants.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** Shared on-disk names for GitNexus repository indexes. */
|
||||
export const GITNEXUS_DIR = '.gitnexus';
|
||||
export const INDEX_METADATA_FILE = 'gitnexus.json';
|
||||
// Dual-written mirror kept for consumers that still use the pre-rename name.
|
||||
export const LEGACY_METADATA_FILE = 'meta.json';
|
||||
export const LBUG_DIRECTORY = 'lbug';
|
||||
736
gitnexus/src/storage/storage-resolver.ts
Normal file
736
gitnexus/src/storage/storage-resolver.ts
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
||||
import { getGlobalDir } from './global-dir.js';
|
||||
import {
|
||||
GITNEXUS_DIR,
|
||||
INDEX_METADATA_FILE,
|
||||
LEGACY_METADATA_FILE,
|
||||
LBUG_DIRECTORY,
|
||||
} from './storage-constants.js';
|
||||
|
||||
export const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH';
|
||||
export const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT';
|
||||
|
||||
const STORAGE_SLOT_HASH_LENGTH = 12;
|
||||
|
||||
/** File-backend lock sidecars (`index-lock.ts`). Not ownership data. */
|
||||
const INDEX_LOCK_ARTIFACTS = new Set(['analyze.lock', 'analyze.lock.guard']);
|
||||
|
||||
export type StorageState =
|
||||
| 'invalid_param'
|
||||
| 'missing'
|
||||
| 'invalid_storage'
|
||||
| 'empty'
|
||||
| 'unowned'
|
||||
| 'owned'
|
||||
| 'foreign';
|
||||
|
||||
type MetadataFilename = typeof INDEX_METADATA_FILE | typeof LEGACY_METADATA_FILE;
|
||||
|
||||
export interface StorageInspection {
|
||||
repoPath: string;
|
||||
storagePath: string;
|
||||
state: StorageState;
|
||||
hasCodeIndexDB: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface StorageRequirements {
|
||||
allowedStates: readonly StorageState[];
|
||||
requireCodeIndexDB?: boolean;
|
||||
}
|
||||
|
||||
export const ANALYZE_STORAGE_REQUIREMENTS = {
|
||||
allowedStates: ['missing', 'empty', 'owned'],
|
||||
} as const satisfies StorageRequirements;
|
||||
|
||||
export const ANALYZE_FORCE_STORAGE_REQUIREMENTS = {
|
||||
allowedStates: ['missing', 'empty', 'owned', 'unowned', 'foreign'],
|
||||
} as const satisfies StorageRequirements;
|
||||
|
||||
export const INDEX_STORAGE_REQUIREMENTS = {
|
||||
allowedStates: ['owned'],
|
||||
requireCodeIndexDB: true,
|
||||
} as const satisfies StorageRequirements;
|
||||
|
||||
export const INDEX_FORCE_STORAGE_REQUIREMENTS = {
|
||||
allowedStates: ['owned', 'unowned', 'foreign'],
|
||||
requireCodeIndexDB: true,
|
||||
} as const satisfies StorageRequirements;
|
||||
|
||||
export const STATUS_STORAGE_REQUIREMENTS: StorageRequirements = {
|
||||
allowedStates: ['owned'],
|
||||
requireCodeIndexDB: true,
|
||||
};
|
||||
|
||||
export const LIST_STORAGE_REQUIREMENTS = STATUS_STORAGE_REQUIREMENTS;
|
||||
|
||||
export const getIndexStorageRequirements = (force: boolean): StorageRequirements =>
|
||||
force ? INDEX_FORCE_STORAGE_REQUIREMENTS : INDEX_STORAGE_REQUIREMENTS;
|
||||
|
||||
interface RegistryStorageEntry {
|
||||
path?: unknown;
|
||||
storagePath?: unknown;
|
||||
}
|
||||
|
||||
interface OwnershipMetadata {
|
||||
repoPath: string;
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
type MetadataReadResult =
|
||||
| { state: 'absent' }
|
||||
| { state: 'invalid'; reason: string }
|
||||
| { state: 'valid'; value: OwnershipMetadata };
|
||||
|
||||
const TRANSIENT_FILESYSTEM_CODES = new Set([
|
||||
'EACCES',
|
||||
'EAGAIN',
|
||||
'EBUSY',
|
||||
'EIO',
|
||||
'EMFILE',
|
||||
'ENFILE',
|
||||
'EPERM',
|
||||
'EROFS',
|
||||
]);
|
||||
|
||||
export class InvalidStoragePathError extends Error {
|
||||
readonly kind = 'InvalidStoragePathError' as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidStoragePathError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageRequirementError extends Error {
|
||||
readonly kind = 'StorageRequirementError' as const;
|
||||
|
||||
constructor(
|
||||
public readonly inspection: StorageInspection,
|
||||
public readonly requirements: StorageRequirements,
|
||||
) {
|
||||
const storagePath = inspection.storagePath || '<unresolved>';
|
||||
const stateAllowed = requirements.allowedStates.includes(inspection.state);
|
||||
const detail = stateAllowed
|
||||
? `Storage path does not contain a LadybugDB code index: ${storagePath}.`
|
||||
: `Storage path is in state "${inspection.state}" but requires one of: ${requirements.allowedStates.join(', ')}.`;
|
||||
super(inspection.reason ? `${detail} ${inspection.reason}` : detail);
|
||||
this.name = 'StorageRequirementError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when a destructive command cannot prove that a storage path is safe to remove. */
|
||||
export class StorageDeletionError extends Error {
|
||||
readonly kind = 'StorageDeletionError' as const;
|
||||
|
||||
constructor(
|
||||
public readonly expectedStoragePath: string,
|
||||
public readonly actualStoragePath: string,
|
||||
public readonly inspection?: StorageInspection,
|
||||
detail = 'the storage path is not owned by the registered repository',
|
||||
) {
|
||||
super(
|
||||
`Refusing to remove storage path for safety: ${detail}. ` +
|
||||
`Expected "${expectedStoragePath}" or an externally owned index, ` +
|
||||
`but the registry entry has "${actualStoragePath}". ` +
|
||||
`This usually means the registry entry is corrupted or was hand-edited. ` +
|
||||
`Delete the entry manually from ~/.gitnexus/registry.json and re-run analyze.`,
|
||||
);
|
||||
this.name = 'StorageDeletionError';
|
||||
}
|
||||
}
|
||||
|
||||
const registryPath = (): string => path.join(getGlobalDir(), 'registry.json');
|
||||
|
||||
/** CLI "no usable index" — missing/empty, or owned metadata without LadybugDB. */
|
||||
export const isUnusableIndexInspection = (
|
||||
inspection: Pick<StorageInspection, 'state' | 'hasCodeIndexDB'>,
|
||||
): boolean =>
|
||||
inspection.state === 'missing' ||
|
||||
inspection.state === 'empty' ||
|
||||
(inspection.state === 'owned' && !inspection.hasCodeIndexDB);
|
||||
|
||||
const samePath = (left: string, right: string): boolean =>
|
||||
process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
|
||||
const isRepositoryLocalStoragePath = (repoPath: string, storagePath: string): boolean =>
|
||||
samePath(comparablePath(defaultStoragePath(repoPath)), comparablePath(storagePath));
|
||||
|
||||
const isMissingFilesystemError = (error: unknown): boolean => {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
return code === 'ENOENT' || code === 'ENOTDIR';
|
||||
};
|
||||
|
||||
const filesystemErrorDetail = (error: unknown): string => {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
return code ? `${code}: ${(error as Error)?.message ?? String(error)}` : String(error);
|
||||
};
|
||||
|
||||
const resolveRepoPath = (value: string): string => {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new InvalidStoragePathError('Repository path must be non-empty.');
|
||||
}
|
||||
if (value.includes('\0')) {
|
||||
throw new InvalidStoragePathError('Repository path must not contain a NUL character.');
|
||||
}
|
||||
return path.resolve(value);
|
||||
};
|
||||
|
||||
const validateAbsolutePath = (value: string, label: string): string => {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new InvalidStoragePathError(`${label} must be an absolute, non-empty path.`);
|
||||
}
|
||||
if (value.includes('\0')) {
|
||||
throw new InvalidStoragePathError(`${label} must not contain a NUL character.`);
|
||||
}
|
||||
if (!path.isAbsolute(value)) {
|
||||
throw new InvalidStoragePathError(`${label} must be an absolute path.`);
|
||||
}
|
||||
return path.resolve(value);
|
||||
};
|
||||
|
||||
// Mirror registry lookup semantics without importing repo-manager and creating a cycle.
|
||||
const canonicalRegistryPath = (value: string): string => {
|
||||
const resolved = path.resolve(value);
|
||||
try {
|
||||
return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved));
|
||||
} catch {
|
||||
return stripWindowsLongPathPrefix(resolved);
|
||||
}
|
||||
};
|
||||
|
||||
const canonicalRepoPath = (repoPath: string): string =>
|
||||
canonicalRegistryPath(resolveRepoPath(repoPath));
|
||||
|
||||
const comparablePath = (value: string): string => {
|
||||
const canonical = canonicalRegistryPath(value);
|
||||
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
};
|
||||
|
||||
const sanitizeSlotBasename = (value: string): string => {
|
||||
// Linear: a quantified `/[. ]+$/` on attacker-controlled basenames is
|
||||
// js/polynomial-redos (CodeQL #1056). Cap first, then walk the tail once.
|
||||
const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80);
|
||||
let end = sanitized.length;
|
||||
while (end > 0) {
|
||||
const code = sanitized.charCodeAt(end - 1);
|
||||
if (code !== 0x20 && code !== 0x2e) break;
|
||||
end--;
|
||||
}
|
||||
const candidate = sanitized.slice(0, end) || 'repository';
|
||||
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate)
|
||||
? `repository-${candidate}`
|
||||
: candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable slot name for one checkout inside a configured external storage root.
|
||||
* The canonical absolute path prevents symlink aliases from creating duplicate
|
||||
* slots, while the hash keeps same-basename repositories isolated.
|
||||
*/
|
||||
export const storageSlotName = (repoPath: string): string => {
|
||||
const canonical = canonicalRepoPath(repoPath);
|
||||
const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
||||
const basename = sanitizeSlotBasename(path.basename(canonical));
|
||||
const digest = createHash('sha256')
|
||||
.update(identity)
|
||||
.digest('hex')
|
||||
.slice(0, STORAGE_SLOT_HASH_LENGTH);
|
||||
return `${basename}-${digest}`;
|
||||
};
|
||||
|
||||
export const defaultStoragePath = (repoPath: string): string =>
|
||||
path.join(resolveRepoPath(repoPath), GITNEXUS_DIR);
|
||||
|
||||
export const validateConfiguredStoragePath = (value: string): string => {
|
||||
const resolved = validateAbsolutePath(value, 'Storage path');
|
||||
const parent = path.dirname(resolved);
|
||||
const base = path.basename(resolved);
|
||||
if (base.length === 0) {
|
||||
throw new InvalidStoragePathError('Storage path must not be a filesystem root.');
|
||||
}
|
||||
// Rebuild through parent + basename and apply the path.relative idiom
|
||||
// CodeQL's js/path-injection sanitizer recognizes. The reconstructed path
|
||||
// is what callers pass to filesystem APIs.
|
||||
const inspected = path.resolve(parent, base);
|
||||
const rel = path.relative(parent, inspected);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new InvalidStoragePathError('Storage path escaped its parent directory.');
|
||||
}
|
||||
return inspected;
|
||||
};
|
||||
|
||||
/** Resolve one repository's isolated slot under an external storage root. */
|
||||
export const storagePathFromRoot = (rootPath: string, repoPath: string): string => {
|
||||
const root = validateAbsolutePath(rootPath, STORAGE_ROOT_ENV);
|
||||
const storagePath = path.resolve(root, storageSlotName(repoPath));
|
||||
const rel = path.relative(root, storagePath);
|
||||
if (
|
||||
rel === '' ||
|
||||
rel.startsWith('..') ||
|
||||
path.isAbsolute(rel) ||
|
||||
!samePath(path.dirname(storagePath), root)
|
||||
) {
|
||||
throw new InvalidStoragePathError(
|
||||
`Resolved storage path must remain directly inside ${STORAGE_ROOT_ENV}.`,
|
||||
);
|
||||
}
|
||||
return storagePath;
|
||||
};
|
||||
|
||||
const configuredStoragePath = (): string | undefined => {
|
||||
const value = process.env[STORAGE_PATH_ENV];
|
||||
return value === undefined ? undefined : validateConfiguredStoragePath(value);
|
||||
};
|
||||
|
||||
const configuredStorageRoot = (repoPath: string): string | undefined => {
|
||||
const value = process.env[STORAGE_ROOT_ENV];
|
||||
return value === undefined ? undefined : storagePathFromRoot(value, repoPath);
|
||||
};
|
||||
|
||||
const registeredStoragePath = (repoPath: string): string | undefined => {
|
||||
let entries: unknown[];
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(registryPath(), 'utf-8'));
|
||||
if (!Array.isArray(data)) return undefined;
|
||||
entries = data;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedRepoPath = canonicalRegistryPath(repoPath);
|
||||
for (const entry of entries) {
|
||||
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
const registryEntry = entry as RegistryStorageEntry;
|
||||
if (typeof registryEntry.path !== 'string') continue;
|
||||
if (!samePath(canonicalRegistryPath(registryEntry.path), resolvedRepoPath)) continue;
|
||||
if (registryEntry.storagePath === undefined) {
|
||||
// Pre-external-storage rows have no storagePath. Match readRegistry():
|
||||
// fall through to the repository-local default instead of failing closed.
|
||||
return undefined;
|
||||
}
|
||||
if (typeof registryEntry.storagePath !== 'string') {
|
||||
throw new InvalidStoragePathError(
|
||||
`Registered storage path for ${repoPath} must be an absolute, non-empty path.`,
|
||||
);
|
||||
}
|
||||
return validateConfiguredStoragePath(registryEntry.storagePath);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Resolve one repository's complete index directory. */
|
||||
export const resolveStoragePath = (repoPath: string): string => {
|
||||
const resolvedRepoPath = resolveRepoPath(repoPath);
|
||||
const configuredPath = configuredStoragePath();
|
||||
if (configuredPath) return configuredPath;
|
||||
|
||||
const configuredRoot = configuredStorageRoot(resolvedRepoPath);
|
||||
if (configuredRoot) return configuredRoot;
|
||||
|
||||
const registered = registeredStoragePath(resolvedRepoPath);
|
||||
if (registered) return registered;
|
||||
|
||||
return defaultStoragePath(resolvedRepoPath);
|
||||
};
|
||||
|
||||
const readOwnershipMetadata = async (
|
||||
storagePath: string,
|
||||
filename: MetadataFilename,
|
||||
): Promise<MetadataReadResult> => {
|
||||
const storageRoot = path.resolve(storagePath);
|
||||
const metadataPath = path.resolve(storageRoot, filename);
|
||||
// Inline at the readFile sink — CodeQL does not treat a helper return as a
|
||||
// js/path-injection sanitizer across calls (see handleFileRequest).
|
||||
const metadataRel = path.relative(storageRoot, metadataPath);
|
||||
if (metadataRel.startsWith('..') || path.isAbsolute(metadataRel)) {
|
||||
return { state: 'invalid', reason: `${filename} is not contained in the storage directory.` };
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fsp.readFile(metadataPath, 'utf-8');
|
||||
} catch (error) {
|
||||
return isMissingFilesystemError(error)
|
||||
? { state: 'absent' }
|
||||
: {
|
||||
state: 'invalid',
|
||||
reason: `${filename} could not be read: ${filesystemErrorDetail(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { state: 'invalid', reason: `${filename} is not valid JSON.` };
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { state: 'invalid', reason: `${filename} must contain a JSON object.` };
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (typeof record.repoPath !== 'string') {
|
||||
return { state: 'invalid', reason: `${filename} does not contain a valid repoPath.` };
|
||||
}
|
||||
|
||||
let repoPath: string;
|
||||
let storagePathValue: string | undefined;
|
||||
try {
|
||||
repoPath = validateAbsolutePath(record.repoPath, `${filename} repoPath`);
|
||||
if (record.storagePath !== undefined) {
|
||||
if (typeof record.storagePath !== 'string') {
|
||||
return { state: 'invalid', reason: `${filename} contains an invalid storagePath.` };
|
||||
}
|
||||
storagePathValue = validateConfiguredStoragePath(record.storagePath);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
state: 'invalid',
|
||||
reason: error instanceof Error ? error.message : `${filename} contains invalid paths.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { state: 'valid', value: { repoPath, storagePath: storagePathValue } };
|
||||
};
|
||||
|
||||
/** Check for the LadybugDB path independently from metadata ownership. */
|
||||
const inspectCodeIndexDB = async (
|
||||
storagePath: string,
|
||||
): Promise<{ present: boolean; transientCode?: string }> => {
|
||||
const resolved = validateConfiguredStoragePath(storagePath);
|
||||
const lbugPath = path.resolve(resolved, LBUG_DIRECTORY);
|
||||
const lbugRel = path.relative(resolved, lbugPath);
|
||||
if (lbugRel.startsWith('..') || path.isAbsolute(lbugRel)) {
|
||||
return { present: false };
|
||||
}
|
||||
try {
|
||||
await fsp.access(lbugPath);
|
||||
return { present: true };
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
return {
|
||||
present: false,
|
||||
...(code && TRANSIENT_FILESYSTEM_CODES.has(code) ? { transientCode: code } : {}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether an inspection failed because the filesystem could not be read reliably. */
|
||||
export const isTransientStorageInspection = (inspection: StorageInspection): boolean => {
|
||||
const reason = inspection.reason ?? '';
|
||||
return [...TRANSIENT_FILESYSTEM_CODES].some((code) => reason.includes(`${code}:`));
|
||||
};
|
||||
|
||||
/**
|
||||
* Inspect filesystem and metadata facts without deciding whether a command may
|
||||
* read, write, adopt, or delete the slot. Callers apply their scenario policy
|
||||
* to the returned state. `gitnexus.json` remains primary; legacy metadata is
|
||||
* selected only when the primary file is provably absent.
|
||||
*/
|
||||
export const inspectStoragePath = async (
|
||||
storagePath: string,
|
||||
repoPath: string,
|
||||
): Promise<StorageInspection> => {
|
||||
let context: Pick<StorageInspection, 'repoPath' | 'storagePath'>;
|
||||
try {
|
||||
const resolvedRepoPath = resolveRepoPath(repoPath);
|
||||
const resolvedStoragePath = validateConfiguredStoragePath(storagePath);
|
||||
context = {
|
||||
repoPath: resolvedRepoPath,
|
||||
storagePath: resolvedStoragePath,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
repoPath,
|
||||
storagePath,
|
||||
state: 'invalid_param',
|
||||
hasCodeIndexDB: false,
|
||||
reason: error instanceof Error ? error.message : 'Invalid storage parameters.',
|
||||
};
|
||||
}
|
||||
|
||||
const storageParent = path.dirname(context.storagePath);
|
||||
const storageBase = path.basename(context.storagePath);
|
||||
if (storageBase.length === 0) {
|
||||
return {
|
||||
...context,
|
||||
state: 'invalid_param',
|
||||
hasCodeIndexDB: false,
|
||||
reason: 'Storage path must not be a filesystem root.',
|
||||
};
|
||||
}
|
||||
// Rebuild the inspected directory through parent + basename and keep the
|
||||
// path.relative barrier on this SSA value at every filesystem sink.
|
||||
const inspectedStorage = path.resolve(storageParent, storageBase);
|
||||
const inspectedRel = path.relative(storageParent, inspectedStorage);
|
||||
if (inspectedRel.startsWith('..') || path.isAbsolute(inspectedRel)) {
|
||||
return {
|
||||
...context,
|
||||
state: 'invalid_param',
|
||||
hasCodeIndexDB: false,
|
||||
reason: 'Storage path escaped its parent directory.',
|
||||
};
|
||||
}
|
||||
context = { ...context, storagePath: inspectedStorage };
|
||||
|
||||
const repositoryLocal = isRepositoryLocalStoragePath(context.repoPath, inspectedStorage);
|
||||
let directoryEntries: string[];
|
||||
let codeIndex: Awaited<ReturnType<typeof inspectCodeIndexDB>>;
|
||||
let primary: MetadataReadResult;
|
||||
try {
|
||||
const linkStat = await fsp.lstat(inspectedStorage);
|
||||
const targetStat = linkStat.isSymbolicLink() ? await fsp.stat(inspectedStorage) : linkStat;
|
||||
if (!targetStat.isDirectory()) {
|
||||
return {
|
||||
...context,
|
||||
state: 'invalid_storage',
|
||||
hasCodeIndexDB: false,
|
||||
reason: 'Storage path exists but is not a directory.',
|
||||
};
|
||||
}
|
||||
const [entries, codeIndexResult, primaryResult] = await Promise.all([
|
||||
fsp.readdir(inspectedStorage),
|
||||
inspectCodeIndexDB(inspectedStorage),
|
||||
readOwnershipMetadata(inspectedStorage, INDEX_METADATA_FILE),
|
||||
]);
|
||||
directoryEntries = entries;
|
||||
codeIndex = codeIndexResult;
|
||||
primary = primaryResult;
|
||||
} catch (error) {
|
||||
if (isMissingFilesystemError(error)) {
|
||||
return { ...context, state: 'missing', hasCodeIndexDB: false };
|
||||
}
|
||||
return {
|
||||
...context,
|
||||
state: 'invalid_storage',
|
||||
hasCodeIndexDB: false,
|
||||
reason: `Storage directory could not be inspected: ${filesystemErrorDetail(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const hasCodeIndexDB = codeIndex.present;
|
||||
const transientDBReason = codeIndex.transientCode
|
||||
? `${codeIndex.transientCode}: LadybugDB directory could not be inspected.`
|
||||
: undefined;
|
||||
if (primary.state === 'invalid') {
|
||||
return {
|
||||
...context,
|
||||
state: 'invalid_storage',
|
||||
hasCodeIndexDB,
|
||||
reason: primary.reason,
|
||||
};
|
||||
}
|
||||
|
||||
let metadata: OwnershipMetadata;
|
||||
if (primary.state === 'valid') {
|
||||
metadata = primary.value;
|
||||
} else {
|
||||
const legacy = await readOwnershipMetadata(context.storagePath, LEGACY_METADATA_FILE);
|
||||
if (legacy.state === 'invalid') {
|
||||
return {
|
||||
...context,
|
||||
state: 'invalid_storage',
|
||||
hasCodeIndexDB,
|
||||
reason: legacy.reason,
|
||||
};
|
||||
}
|
||||
if (legacy.state === 'absent') {
|
||||
const hasNonLockEntries = directoryEntries.some((name) => !INDEX_LOCK_ARTIFACTS.has(name));
|
||||
return {
|
||||
...context,
|
||||
state: hasNonLockEntries ? 'unowned' : 'empty',
|
||||
hasCodeIndexDB,
|
||||
reason:
|
||||
transientDBReason ??
|
||||
(hasNonLockEntries
|
||||
? 'Storage directory contains data but no valid ownership metadata.'
|
||||
: undefined),
|
||||
};
|
||||
}
|
||||
metadata = legacy.value;
|
||||
}
|
||||
|
||||
const repoMatches = samePath(comparablePath(metadata.repoPath), comparablePath(context.repoPath));
|
||||
const storageMatches =
|
||||
metadata.storagePath !== undefined &&
|
||||
samePath(comparablePath(metadata.storagePath), comparablePath(context.storagePath));
|
||||
|
||||
let state: StorageState;
|
||||
let reason: string | undefined;
|
||||
if (!repoMatches || (metadata.storagePath !== undefined && !storageMatches)) {
|
||||
state = 'foreign';
|
||||
reason = 'Storage metadata identifies a different repository or storage directory.';
|
||||
} else if (!repositoryLocal && metadata.storagePath === undefined) {
|
||||
state = 'unowned';
|
||||
reason = 'External storage metadata does not bind the index to this storage directory.';
|
||||
} else {
|
||||
state = 'owned';
|
||||
}
|
||||
|
||||
return {
|
||||
...context,
|
||||
state,
|
||||
hasCodeIndexDB,
|
||||
reason: reason ?? transientDBReason,
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve and inspect a repo-initiated storage lookup in one operation. */
|
||||
export const inspectResolvedStorage = async (repoPath: string): Promise<StorageInspection> => {
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = resolveStoragePath(repoPath);
|
||||
} catch (error) {
|
||||
return {
|
||||
repoPath,
|
||||
storagePath: '',
|
||||
state: 'invalid_param',
|
||||
hasCodeIndexDB: false,
|
||||
reason: error instanceof Error ? error.message : 'Storage path could not be resolved.',
|
||||
};
|
||||
}
|
||||
return inspectStoragePath(storagePath, repoPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Inspect a registry-selected slot without allowing an environment override to
|
||||
* redirect the entry to another repository's storage.
|
||||
*/
|
||||
export const inspectRegisteredStorage = async (entry: {
|
||||
path: string;
|
||||
storagePath: string;
|
||||
}): Promise<StorageInspection> => inspectStoragePath(entry.storagePath, entry.path);
|
||||
|
||||
const requireInspectedStoragePath = (
|
||||
inspection: StorageInspection,
|
||||
requirements: StorageRequirements,
|
||||
): string => {
|
||||
if (!requirements.allowedStates.includes(inspection.state)) {
|
||||
throw new StorageRequirementError(inspection, requirements);
|
||||
}
|
||||
// `foreign` is only adoptable for this checkout's own `.gitnexus`. An
|
||||
// external slot that names another repository stays rejected even when the
|
||||
// caller opted into `foreign` (analyze/index --force).
|
||||
if (
|
||||
inspection.state === 'foreign' &&
|
||||
!isRepositoryLocalStoragePath(inspection.repoPath, inspection.storagePath)
|
||||
) {
|
||||
throw new StorageRequirementError(inspection, {
|
||||
...requirements,
|
||||
allowedStates: requirements.allowedStates.filter((state) => state !== 'foreign'),
|
||||
});
|
||||
}
|
||||
if (requirements.requireCodeIndexDB && !inspection.hasCodeIndexDB) {
|
||||
throw new StorageRequirementError(inspection, requirements);
|
||||
}
|
||||
return inspection.storagePath;
|
||||
};
|
||||
|
||||
/** Resolve and validate storage selected from a repository path. */
|
||||
export const requireStoragePath = async (
|
||||
repoPath: string,
|
||||
requirements: StorageRequirements,
|
||||
): Promise<string> =>
|
||||
requireInspectedStoragePath(await inspectResolvedStorage(repoPath), requirements);
|
||||
|
||||
/** Validate the exact storage path persisted in a registry entry. */
|
||||
export const requireRegisteredStoragePath = async (
|
||||
entry: { path: string; storagePath: string },
|
||||
requirements: StorageRequirements,
|
||||
): Promise<string> =>
|
||||
requireInspectedStoragePath(await inspectRegisteredStorage(entry), requirements);
|
||||
|
||||
const isPathAncestor = (ancestor: string, child: string): boolean => {
|
||||
const relative = path.relative(ancestor, child);
|
||||
return (
|
||||
relative === '' ||
|
||||
(relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a registry entry for a destructive operation.
|
||||
*
|
||||
* Repository-local `.gitnexus` is a path-owned namespace, so it remains
|
||||
* removable when it is missing, empty, contains data without metadata, or
|
||||
* carries foreign metadata (this checkout's directory, another repo's stamp).
|
||||
* External storage has no such physical ownership proof and therefore must
|
||||
* contain metadata binding both the repository and the exact storage path.
|
||||
* External foreign or malformed metadata is never removable.
|
||||
*/
|
||||
export const requireDeletableStoragePath = async (entry: {
|
||||
path: string;
|
||||
storagePath: string;
|
||||
}): Promise<string> => {
|
||||
let repoPath: string;
|
||||
let actualStoragePath: string;
|
||||
try {
|
||||
repoPath = resolveRepoPath(entry.path);
|
||||
actualStoragePath = validateConfiguredStoragePath(entry.storagePath);
|
||||
} catch (error) {
|
||||
const rawStoragePath =
|
||||
typeof entry.storagePath === 'string'
|
||||
? path.resolve(entry.storagePath)
|
||||
: String(entry.storagePath);
|
||||
const fallbackRepoPath =
|
||||
typeof entry.path === 'string' && entry.path.length > 0 && !entry.path.includes('\0')
|
||||
? entry.path
|
||||
: process.cwd();
|
||||
throw new StorageDeletionError(
|
||||
defaultStoragePath(fallbackRepoPath),
|
||||
rawStoragePath,
|
||||
undefined,
|
||||
error instanceof Error ? error.message : 'the registry storage path is invalid',
|
||||
);
|
||||
}
|
||||
|
||||
const expectedStoragePath = defaultStoragePath(repoPath);
|
||||
const storageIsLocal = isRepositoryLocalStoragePath(repoPath, actualStoragePath);
|
||||
const comparableStorage = comparablePath(actualStoragePath);
|
||||
const comparableRepo = comparablePath(repoPath);
|
||||
const comparableRoot = comparablePath(path.parse(actualStoragePath).root);
|
||||
if (
|
||||
samePath(comparableStorage, comparableRoot) ||
|
||||
samePath(comparableStorage, comparableRepo) ||
|
||||
isPathAncestor(comparableStorage, comparableRepo)
|
||||
) {
|
||||
throw new StorageDeletionError(
|
||||
expectedStoragePath,
|
||||
actualStoragePath,
|
||||
undefined,
|
||||
'the target is the repository, one of its parents, or a filesystem root',
|
||||
);
|
||||
}
|
||||
|
||||
const inspection = await inspectRegisteredStorage({
|
||||
path: repoPath,
|
||||
storagePath: actualStoragePath,
|
||||
});
|
||||
const allowedStates: readonly StorageState[] = storageIsLocal
|
||||
? ['missing', 'empty', 'unowned', 'owned', 'foreign']
|
||||
: ['owned'];
|
||||
if (!allowedStates.includes(inspection.state)) {
|
||||
throw new StorageDeletionError(
|
||||
expectedStoragePath,
|
||||
actualStoragePath,
|
||||
inspection,
|
||||
`the storage inspection state is "${inspection.state}"`,
|
||||
);
|
||||
}
|
||||
return actualStoragePath;
|
||||
};
|
||||
|
||||
/** Ensure a selected index directory is usable before an analysis takes its lock. */
|
||||
export const ensureStoragePathWritable = async (storagePath: string): Promise<void> => {
|
||||
const resolved = validateConfiguredStoragePath(storagePath);
|
||||
await fsp.mkdir(resolved, { recursive: true });
|
||||
const stat = await fsp.stat(resolved);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new InvalidStoragePathError(`Index storage path is not a directory: ${resolved}`);
|
||||
}
|
||||
await fsp.access(resolved, fs.constants.R_OK | fs.constants.W_OK);
|
||||
};
|
||||
|
|
@ -23,7 +23,7 @@ import path from 'path';
|
|||
import { cleanupTempDir, cleanupTempDirSync } from '../helpers/test-db.js';
|
||||
import os from 'os';
|
||||
import {
|
||||
runHook,
|
||||
runHook as spawnHook,
|
||||
parseHookOutput,
|
||||
createGitNexusPathEntry,
|
||||
createHookToolDir,
|
||||
|
|
@ -37,6 +37,7 @@ let tempHome: string;
|
|||
let installedHook: string;
|
||||
let tmpDir: string;
|
||||
let gitNexusDir: string;
|
||||
let registryHome: string;
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
|
||||
|
|
@ -76,6 +77,7 @@ beforeAll(async () => {
|
|||
'hook-db-lock-probe.cjs',
|
||||
'win-rm-list-json.ps1',
|
||||
'resolve-analyze-cmd.cjs',
|
||||
'registry-query.cjs',
|
||||
]) {
|
||||
const helperPath = path.join(path.dirname(installedHook), helper);
|
||||
if (!fs.existsSync(helperPath)) {
|
||||
|
|
@ -90,6 +92,19 @@ beforeAll(async () => {
|
|||
initGitRepo(tmpDir, { name: 'Test', email: 'test@test.com' });
|
||||
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello');
|
||||
commitAll(tmpDir, 'init');
|
||||
|
||||
registryHome = path.join(tempHome, 'gitnexus-home');
|
||||
fs.mkdirSync(registryHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(registryHome, 'registry.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'antigravity-e2e',
|
||||
path: tmpDir,
|
||||
storagePath: gitNexusDir,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -99,6 +114,18 @@ afterAll(async () => {
|
|||
if (tmpDir) cleanupTempDirSync(tmpDir);
|
||||
});
|
||||
|
||||
function runHook(
|
||||
hookPath: string,
|
||||
input: Record<string, any>,
|
||||
cwd?: string,
|
||||
options: { env?: NodeJS.ProcessEnv } = {},
|
||||
) {
|
||||
return spawnHook(hookPath, input, cwd, {
|
||||
...options,
|
||||
env: { ...(options.env ?? process.env), GITNEXUS_HOME: registryHome },
|
||||
});
|
||||
}
|
||||
|
||||
describe('antigravity hook adapter e2e', () => {
|
||||
describe('AfterTool — stale-index hint after git mutations', () => {
|
||||
// #1913: by default the hint reaches the agent via additionalContext (stdout
|
||||
|
|
|
|||
|
|
@ -57,6 +57,16 @@ vi.mock('../../src/storage/repo-manager.js', () => ({
|
|||
listRegisteredRepos: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/storage/storage-resolver.js')>();
|
||||
return {
|
||||
...actual,
|
||||
requireRegisteredStoragePath: vi.fn(
|
||||
async (entry: { storagePath: string }) => entry.storagePath,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
let augment: (pattern: string, cwd?: string) => Promise<string>;
|
||||
let augmentNoFts: (pattern: string, cwd?: string) => Promise<string>;
|
||||
|
||||
|
|
@ -109,6 +119,17 @@ withTestLbugDB(
|
|||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('silently degrades when the registered storage path fails validation', async () => {
|
||||
const { requireRegisteredStoragePath } =
|
||||
await import('../../src/storage/storage-resolver.js');
|
||||
const resolverMock = vi.mocked(requireRegisteredStoragePath);
|
||||
resolverMock.mockRejectedValueOnce(new Error('foreign storage'));
|
||||
|
||||
const result = await augment('login', handle.dbPath);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
// ─── Negative-safety: fallback must stay gated on !ftsAvailable ───
|
||||
//
|
||||
// When FTS is available but happens to return zero BM25 hits, the
|
||||
|
|
@ -164,7 +185,7 @@ withTestLbugDB(
|
|||
}
|
||||
});
|
||||
|
||||
it('matches CWD at root level and repo in sub-directory (repo at /src, CWD at /)', async () => {
|
||||
it('does not attach a nested registered checkout from a parent cwd (repo at /src, CWD at /)', async () => {
|
||||
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
|
||||
const rootPath = path.resolve('/');
|
||||
const subDir = path.join(rootPath, 'src');
|
||||
|
|
@ -181,8 +202,7 @@ withTestLbugDB(
|
|||
|
||||
try {
|
||||
const result = await augment('login', rootPath);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result).toContain('[GitNexus]');
|
||||
expect(result).toBe('');
|
||||
} finally {
|
||||
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
|
|
@ -226,7 +246,7 @@ withTestLbugDB(
|
|||
}
|
||||
});
|
||||
|
||||
it('matches Windows sub-directory repo and drive-root CWD (repo at C:\\src, CWD at C:\\)', async () => {
|
||||
it('does not attach a nested Windows checkout from a drive-root cwd (repo at C:\\src, CWD at C:\\)', async () => {
|
||||
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
|
||||
|
||||
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
|
|
@ -241,7 +261,7 @@ withTestLbugDB(
|
|||
|
||||
try {
|
||||
const result = await augment('login', 'C:\\');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result).toBe('');
|
||||
} finally {
|
||||
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* End-to-end flow with real git and real registry/meta I/O.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
|
@ -41,6 +41,10 @@ async function seedIndexedRepo(
|
|||
meta: RepoMeta,
|
||||
repoName: string = 'test-repo',
|
||||
): Promise<void> {
|
||||
// Registry validation accepts only an owned storage directory that contains
|
||||
// the index path. The context resource itself reads metadata only, so a
|
||||
// minimal placeholder directory is sufficient for this fixture.
|
||||
mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
|
||||
await saveMeta(storagePath, meta);
|
||||
await registerRepo(repoPath, meta, { name: repoName });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { runFullAnalysis } from '../../src/core/run-analyze.js';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { statusCommand } from '../../src/cli/status.js';
|
||||
import { getStoragePaths, loadMeta } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
STORAGE_PATH_ENV,
|
||||
STORAGE_ROOT_ENV,
|
||||
storagePathFromRoot,
|
||||
} from '../../src/storage/storage-resolver.js';
|
||||
|
||||
const savedStoragePath = process.env[STORAGE_PATH_ENV];
|
||||
const savedStorageRoot = process.env[STORAGE_ROOT_ENV];
|
||||
const savedRetention = process.env.GITNEXUS_CONTENT_RETENTION;
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
const temporaryPaths: string[] = [];
|
||||
|
||||
const makeTempDir = async (prefix: string): Promise<string> => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
temporaryPaths.push(directory);
|
||||
return directory;
|
||||
};
|
||||
|
||||
const restoreEnvironment = () => {
|
||||
if (savedStoragePath === undefined) delete process.env[STORAGE_PATH_ENV];
|
||||
else process.env[STORAGE_PATH_ENV] = savedStoragePath;
|
||||
if (savedStorageRoot === undefined) delete process.env[STORAGE_ROOT_ENV];
|
||||
else process.env[STORAGE_ROOT_ENV] = savedStorageRoot;
|
||||
if (savedRetention === undefined) delete process.env.GITNEXUS_CONTENT_RETENTION;
|
||||
else process.env.GITNEXUS_CONTENT_RETENTION = savedRetention;
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnvironment();
|
||||
await Promise.all(
|
||||
temporaryPaths.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
const recursiveSize = async (target: string): Promise<number> => {
|
||||
const stat = await fs.lstat(target);
|
||||
if (!stat.isDirectory()) return stat.size;
|
||||
const entries = await fs.readdir(target);
|
||||
return (
|
||||
await Promise.all(entries.map((entry) => recursiveSize(path.join(target, entry))))
|
||||
).reduce((total, size) => total + size, 0);
|
||||
};
|
||||
|
||||
const readGraph = async (lbugPath: string) => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
try {
|
||||
return await adapter.withLbugDb(
|
||||
lbugPath,
|
||||
async () => {
|
||||
const [nodes, edges, files, functions, basicBlocks, basicBlockCount] = await Promise.all([
|
||||
adapter.executeQuery('MATCH (n) RETURN count(n) AS count'),
|
||||
adapter.executeQuery(
|
||||
'MATCH (source)-[r:CodeRelation]->(target) RETURN count(r) AS count',
|
||||
),
|
||||
adapter.executeQuery(
|
||||
"MATCH (n:File) WHERE n.filePath = 'src/fixture.ts' RETURN n.content AS content",
|
||||
),
|
||||
adapter.executeQuery(
|
||||
"MATCH (n:Function) WHERE n.name = 'retentionFixture' RETURN n.content AS content, n.description AS description",
|
||||
),
|
||||
adapter.executeQuery('MATCH (n:BasicBlock) RETURN n.text AS text LIMIT 1'),
|
||||
adapter.executeQuery('MATCH (n:BasicBlock) RETURN count(n) AS count'),
|
||||
]);
|
||||
const count = (rows: any[]) => Number(rows[0]?.count ?? rows[0]?.[0] ?? 0);
|
||||
return {
|
||||
nodes: count(nodes),
|
||||
edges: count(edges),
|
||||
fileContent: files[0]?.content ?? files[0]?.[0],
|
||||
functionContent: functions[0]?.content ?? functions[0]?.[0],
|
||||
functionDescription: functions[0]?.description ?? functions[0]?.[1],
|
||||
basicBlockText: basicBlocks[0]?.text ?? basicBlocks[0]?.[0],
|
||||
basicBlockCount: count(basicBlockCount),
|
||||
};
|
||||
},
|
||||
{ readOnly: true },
|
||||
);
|
||||
} finally {
|
||||
await adapter.closeLbug();
|
||||
}
|
||||
};
|
||||
|
||||
describe('external storage and content retention', () => {
|
||||
it('keeps the full index outside the checkout, rebuilds on retention changes, and remains queryable after checkout removal', async () => {
|
||||
const repo = await makeTempDir('gitnexus-run-analyze-retention-repo-');
|
||||
const storageRoot = await makeTempDir('gitnexus-run-analyze-retention-storage-');
|
||||
const storage = storagePathFromRoot(storageRoot, repo);
|
||||
const home = await makeTempDir('gitnexus-run-analyze-retention-home-');
|
||||
await fs.mkdir(path.join(repo, 'src'));
|
||||
await fs.writeFile(
|
||||
path.join(repo, 'src/fixture.ts'),
|
||||
`/** retention fixture documentation */\nexport function retentionFixture(enabled = true) {\n const payload = '${'fullRetentionPayload '.repeat(20_000)}';\n if (enabled) return payload;\n return '';\n}\n`,
|
||||
);
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
delete process.env[STORAGE_PATH_ENV];
|
||||
process.env[STORAGE_ROOT_ENV] = storageRoot;
|
||||
process.env.GITNEXUS_CONTENT_RETENTION = 'full';
|
||||
|
||||
const logs: string[] = [];
|
||||
const options = {
|
||||
force: true,
|
||||
skipGit: true,
|
||||
skipAgentsMd: true,
|
||||
skipSkills: true,
|
||||
workerPoolSize: 1,
|
||||
pdg: true,
|
||||
streamPdgEmit: true,
|
||||
registryName: 'retention-fixture',
|
||||
};
|
||||
await runFullAnalysis(repo, options, {
|
||||
onProgress: () => undefined,
|
||||
onLog: (message) => logs.push(message),
|
||||
});
|
||||
|
||||
const initialMeta = await loadMeta(storage);
|
||||
const { lbugPath } = getStoragePaths(repo, undefined, storage);
|
||||
const fullGraph = await readGraph(lbugPath);
|
||||
const fullDatabaseSize = await recursiveSize(lbugPath);
|
||||
expect(initialMeta).toMatchObject({
|
||||
repoPath: repo,
|
||||
storagePath: storage,
|
||||
contentRetention: 'full',
|
||||
contentRetentionSchemaVersion: 1,
|
||||
ftsProfile: 'full',
|
||||
});
|
||||
await expect(fs.access(path.join(repo, '.gitnexus'))).rejects.toThrow();
|
||||
expect(fullGraph.fileContent).toContain('fullRetentionPayload');
|
||||
expect(fullGraph.functionContent).toContain('retentionFixture');
|
||||
expect(fullGraph.basicBlockCount).toBeGreaterThan(0);
|
||||
|
||||
process.env.GITNEXUS_CONTENT_RETENTION = 'symbol';
|
||||
await runFullAnalysis(
|
||||
repo,
|
||||
{ ...options, force: false },
|
||||
{
|
||||
onProgress: () => undefined,
|
||||
onLog: (message) => logs.push(message),
|
||||
},
|
||||
);
|
||||
const symbolMeta = await loadMeta(storage);
|
||||
const symbolGraph = await readGraph(lbugPath);
|
||||
const symbolDatabaseSize = await recursiveSize(lbugPath);
|
||||
expect(logs.join('\n')).toContain('forcing a full rebuild');
|
||||
expect(symbolMeta).toMatchObject({
|
||||
contentRetention: 'symbol',
|
||||
contentRetentionSchemaVersion: 1,
|
||||
ftsProfile: 'symbol-no-file-content',
|
||||
});
|
||||
expect(symbolGraph).toMatchObject({ nodes: fullGraph.nodes, edges: fullGraph.edges });
|
||||
expect(symbolGraph.fileContent).toBeUndefined();
|
||||
expect(symbolGraph.functionContent).toContain('retentionFixture');
|
||||
expect(symbolGraph.basicBlockCount).toBe(fullGraph.basicBlockCount);
|
||||
expect(symbolGraph.basicBlockText).toBeUndefined();
|
||||
expect(symbolDatabaseSize).toBeLessThan(fullDatabaseSize);
|
||||
|
||||
process.env.GITNEXUS_CONTENT_RETENTION = 'none';
|
||||
await runFullAnalysis(
|
||||
repo,
|
||||
{ ...options, force: false },
|
||||
{
|
||||
onProgress: () => undefined,
|
||||
onLog: (message) => logs.push(message),
|
||||
},
|
||||
);
|
||||
const noneMeta = await loadMeta(storage);
|
||||
expect(noneMeta).toMatchObject({
|
||||
contentRetention: 'none',
|
||||
contentRetentionSchemaVersion: 1,
|
||||
ftsProfile: 'name-only',
|
||||
});
|
||||
|
||||
await fs.rm(repo, { recursive: true, force: true });
|
||||
const output: string[] = [];
|
||||
const logSpy = vi
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation((value) => output.push(String(value)));
|
||||
try {
|
||||
await statusCommand({ repo: 'retention-fixture', json: true });
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
expect(JSON.parse(output[0])).toMatchObject({
|
||||
storagePath: storage,
|
||||
sourceAvailable: false,
|
||||
status: 'source-unavailable',
|
||||
index: { contentRetention: 'none' },
|
||||
});
|
||||
|
||||
const backend = new LocalBackend();
|
||||
try {
|
||||
await backend.init();
|
||||
const context = await backend.callTool('context', {
|
||||
name: 'retentionFixture',
|
||||
repo: 'retention-fixture',
|
||||
include_content: true,
|
||||
});
|
||||
expect(context).toMatchObject({
|
||||
status: 'found',
|
||||
contentAvailability: {
|
||||
requested: true,
|
||||
profile: 'none',
|
||||
available: false,
|
||||
scope: 'none',
|
||||
},
|
||||
});
|
||||
expect(context.symbol.content).toBeUndefined();
|
||||
} finally {
|
||||
await backend.disconnect();
|
||||
}
|
||||
|
||||
const noneGraph = await readGraph(lbugPath);
|
||||
expect(noneGraph).toMatchObject({ nodes: fullGraph.nodes, edges: fullGraph.edges });
|
||||
expect(noneGraph.fileContent).toBeUndefined();
|
||||
expect(noneGraph.functionContent).toBeUndefined();
|
||||
expect(noneGraph.functionDescription).toBeUndefined();
|
||||
expect(noneGraph.basicBlockCount).toBe(fullGraph.basicBlockCount);
|
||||
expect(noneGraph.basicBlockText).toBeUndefined();
|
||||
}, 120_000);
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import os from 'os';
|
||||
import {
|
||||
runHook,
|
||||
runHook as spawnHook,
|
||||
parseHookOutput,
|
||||
createGitNexusPathEntry,
|
||||
envWithPath,
|
||||
|
|
@ -40,6 +40,7 @@ const HOOKS = [
|
|||
|
||||
let tmpDir: string;
|
||||
let gitNexusDir: string;
|
||||
let hookHome: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-e2e-'));
|
||||
|
|
@ -52,12 +53,37 @@ beforeAll(() => {
|
|||
// Create a file and commit so HEAD exists
|
||||
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello');
|
||||
commitAll(tmpDir, 'init');
|
||||
|
||||
hookHome = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-e2e-home-'));
|
||||
fs.writeFileSync(
|
||||
path.join(hookHome, 'registry.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'hooks-e2e',
|
||||
path: tmpDir,
|
||||
storagePath: gitNexusDir,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(hookHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runHook(
|
||||
hookPath: string,
|
||||
input: Record<string, any>,
|
||||
cwd?: string,
|
||||
options: { env?: NodeJS.ProcessEnv } = {},
|
||||
) {
|
||||
return spawnHook(hookPath, input, cwd, {
|
||||
...options,
|
||||
env: { ...(options.env ?? process.env), GITNEXUS_HOME: hookHome },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@
|
|||
* instance, verifying cypher, context, impact, and query tools work
|
||||
* end-to-end against seeded graph data with FTS indexes.
|
||||
*/
|
||||
import fs from 'fs/promises';
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { listRegisteredRepos, saveMeta } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
import {
|
||||
LOCAL_BACKEND_SEED_DATA,
|
||||
|
|
@ -208,6 +209,97 @@ withTestLbugDB(
|
|||
expect(validate.content).toBe('function validate() {}');
|
||||
});
|
||||
|
||||
it('reports content capability for the default full profile', async () => {
|
||||
const query = await backend.callTool('query', { query: 'login', include_content: true });
|
||||
const context = await backend.callTool('context', { name: 'login', include_content: true });
|
||||
|
||||
expect(query.contentAvailability).toEqual({
|
||||
requested: true,
|
||||
profile: 'full',
|
||||
available: true,
|
||||
scope: 'full',
|
||||
});
|
||||
expect(context.contentAvailability).toEqual(query.contentAvailability);
|
||||
expect(context.symbol.content).toBe('function login() {}');
|
||||
});
|
||||
|
||||
it('does not disclose lingering source text when metadata says retention is none', async () => {
|
||||
const storagePath = handle.tmpHandle.dbPath;
|
||||
await saveMeta(storagePath, {
|
||||
repoPath: '/test/repo',
|
||||
storagePath,
|
||||
lastCommit: 'abc123',
|
||||
indexedAt: new Date().toISOString(),
|
||||
contentRetention: 'none',
|
||||
contentRetentionSchemaVersion: 1,
|
||||
ftsProfile: 'name-only',
|
||||
});
|
||||
try {
|
||||
const query = await backend.callTool('query', { query: 'login', include_content: true });
|
||||
const context = await backend.callTool('context', {
|
||||
name: 'login',
|
||||
include_content: true,
|
||||
});
|
||||
const login = (query.process_symbols ?? []).find(
|
||||
(symbol: any) => symbol.id === 'func:login',
|
||||
);
|
||||
|
||||
expect(query.contentAvailability).toEqual({
|
||||
requested: true,
|
||||
profile: 'none',
|
||||
available: false,
|
||||
scope: 'none',
|
||||
reason: 'Source-derived content is not retained by this index.',
|
||||
});
|
||||
expect(login?.content).toBeUndefined();
|
||||
expect(context.contentAvailability).toEqual(query.contentAvailability);
|
||||
expect(context.symbol.content).toBeUndefined();
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.rm(`${storagePath}/gitnexus.json`, { force: true }),
|
||||
fs.rm(`${storagePath}/meta.json`, { force: true }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not disclose lingering source text when retention metadata is invalid', async () => {
|
||||
const storagePath = handle.tmpHandle.dbPath;
|
||||
await saveMeta(storagePath, {
|
||||
repoPath: '/test/repo',
|
||||
storagePath,
|
||||
lastCommit: 'abc123',
|
||||
indexedAt: new Date().toISOString(),
|
||||
contentRetention: 'invalid' as never,
|
||||
contentRetentionSchemaVersion: 1,
|
||||
ftsProfile: 'name-only',
|
||||
});
|
||||
try {
|
||||
const query = await backend.callTool('query', { query: 'login', include_content: true });
|
||||
const context = await backend.callTool('context', {
|
||||
name: 'login',
|
||||
include_content: true,
|
||||
});
|
||||
const login = (query.process_symbols ?? []).find(
|
||||
(symbol: any) => symbol.id === 'func:login',
|
||||
);
|
||||
|
||||
expect(query.contentAvailability).toMatchObject({
|
||||
requested: true,
|
||||
profile: 'none',
|
||||
available: false,
|
||||
scope: 'none',
|
||||
});
|
||||
expect(login?.content).toBeUndefined();
|
||||
expect(context.contentAvailability).toEqual(query.contentAvailability);
|
||||
expect(context.symbol.content).toBeUndefined();
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.rm(`${storagePath}/gitnexus.json`, { force: true }),
|
||||
fs.rm(`${storagePath}/meta.json`, { force: true }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// PR #222 port: a symbol in MULTIPLE processes is what fully exercises the
|
||||
// +1 positional shift in the batched STEP_IN_PROCESS aggregation — with a
|
||||
// single process row, `row.pid ?? row[1]` succeeds whether the shift is
|
||||
|
|
|
|||
|
|
@ -5,55 +5,61 @@ import path from 'node:path';
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { FIXTURES } from './helpers.js';
|
||||
|
||||
const javacAvailable = spawnSync('javac', ['-version'], { stdio: 'ignore' }).status === 0;
|
||||
const javacProbe = spawnSync('javac', ['-version'], { encoding: 'utf8' });
|
||||
const javacMajorVersion = `${javacProbe.stdout}\n${javacProbe.stderr}`.match(/javac\s+(\d+)/)?.[1];
|
||||
// Local records and enum declarations used by this fixture require Java 16+.
|
||||
const javacSupportsLocalTypes = javacProbe.status === 0 && Number(javacMajorVersion) >= 16;
|
||||
|
||||
describe('Java local-type names emitted by javac', () => {
|
||||
it.runIf(javacAvailable)('matches the identities asserted by the resolver fixture', () => {
|
||||
const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-local-types-'));
|
||||
const output = path.join(temp, 'classes');
|
||||
mkdirSync(output);
|
||||
it.runIf(javacSupportsLocalTypes)(
|
||||
'matches the identities asserted by the resolver fixture',
|
||||
() => {
|
||||
const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-local-types-'));
|
||||
const output = path.join(temp, 'classes');
|
||||
mkdirSync(output);
|
||||
|
||||
try {
|
||||
const sourceDir = path.join(FIXTURES, 'java-local-class-naming', 'src');
|
||||
const sources = readdirSync(sourceDir)
|
||||
.filter((name) => name.endsWith('.java'))
|
||||
.map((name) => path.join(sourceDir, name));
|
||||
execFileSync('javac', ['-d', output, ...sources]);
|
||||
try {
|
||||
const sourceDir = path.join(FIXTURES, 'java-local-class-naming', 'src');
|
||||
const sources = readdirSync(sourceDir)
|
||||
.filter((name) => name.endsWith('.java'))
|
||||
.map((name) => path.join(sourceDir, name));
|
||||
execFileSync('javac', ['-d', output, ...sources]);
|
||||
|
||||
expect(readdirSync(output).sort()).toEqual([
|
||||
'Compact$1.class',
|
||||
'Compact$1Local.class',
|
||||
'Compact.class',
|
||||
'Outer$1.class',
|
||||
'Outer$1CtorHost$1Local.class',
|
||||
'Outer$1CtorHost.class',
|
||||
'Outer$1Cyclic.class',
|
||||
'Outer$1InstanceLocal.class',
|
||||
'Outer$1LambdaLocal.class',
|
||||
'Outer$1Local$1.class',
|
||||
'Outer$1Local.class',
|
||||
'Outer$1NestedHost$Member$1Local.class',
|
||||
'Outer$1NestedHost$Member.class',
|
||||
'Outer$1NestedHost.class',
|
||||
'Outer$1StaticLocal.class',
|
||||
'Outer$2.class',
|
||||
'Outer$2Local.class',
|
||||
'Outer$3$1Local.class',
|
||||
'Outer$3.class',
|
||||
'Outer$3Local.class',
|
||||
'Outer$4Local.class',
|
||||
'Outer$Cyclic.class',
|
||||
'Outer$MemberHost$1Local.class',
|
||||
'Outer$MemberHost.class',
|
||||
'Outer.class',
|
||||
'Types$1.class',
|
||||
'Types$1E.class',
|
||||
'Types$1I.class',
|
||||
'Types$1R.class',
|
||||
'Types.class',
|
||||
]);
|
||||
} finally {
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
expect(readdirSync(output).sort()).toEqual([
|
||||
'Compact$1.class',
|
||||
'Compact$1Local.class',
|
||||
'Compact.class',
|
||||
'Outer$1.class',
|
||||
'Outer$1CtorHost$1Local.class',
|
||||
'Outer$1CtorHost.class',
|
||||
'Outer$1Cyclic.class',
|
||||
'Outer$1InstanceLocal.class',
|
||||
'Outer$1LambdaLocal.class',
|
||||
'Outer$1Local$1.class',
|
||||
'Outer$1Local.class',
|
||||
'Outer$1NestedHost$Member$1Local.class',
|
||||
'Outer$1NestedHost$Member.class',
|
||||
'Outer$1NestedHost.class',
|
||||
'Outer$1StaticLocal.class',
|
||||
'Outer$2.class',
|
||||
'Outer$2Local.class',
|
||||
'Outer$3$1Local.class',
|
||||
'Outer$3.class',
|
||||
'Outer$3Local.class',
|
||||
'Outer$4Local.class',
|
||||
'Outer$Cyclic.class',
|
||||
'Outer$MemberHost$1Local.class',
|
||||
'Outer$MemberHost.class',
|
||||
'Outer.class',
|
||||
'Types$1.class',
|
||||
'Types$1E.class',
|
||||
'Types$1I.class',
|
||||
'Types$1R.class',
|
||||
'Types.class',
|
||||
]);
|
||||
} finally {
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ describe('end-of-run adopt is best-effort (#2364 F5)', () => {
|
|||
// The run resolved (no throw), the adopt was attempted and its failure
|
||||
// surfaced as a warning…
|
||||
expect(result.alreadyUpToDate).toBeFalsy();
|
||||
expect(rmCtx.adoptMock).toHaveBeenCalledWith(repo, 'main');
|
||||
expect(rmCtx.adoptMock).toHaveBeenCalledWith(repo, 'main', path.join(repo, '.gitnexus'));
|
||||
expect(logs.some((m) => m.includes('could not sync the workspace branch label'))).toBe(true);
|
||||
// …and registration had already completed before the label sync.
|
||||
const entries = await listRegisteredRepos();
|
||||
|
|
|
|||
|
|
@ -99,11 +99,12 @@ describeBlock('repo routes expose branch and freshness (real server)', () => {
|
|||
// `storagePath` is gone, so a fixture without it is silently dropped and the
|
||||
// route legitimately returns nothing (found while writing this test).
|
||||
const storagePath = path.join(repoPath, '.gitnexus');
|
||||
fs.mkdirSync(storagePath, { recursive: true });
|
||||
fs.mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(storagePath, 'gitnexus.json'),
|
||||
JSON.stringify({
|
||||
repoPath,
|
||||
storagePath,
|
||||
indexedAt: INDEXED_AT,
|
||||
lastCommit: firstCommit,
|
||||
branch: 'main',
|
||||
|
|
|
|||
|
|
@ -642,7 +642,7 @@ describe('POST /api/embed route wiring (#2790)', () => {
|
|||
// `stats.embeddings`, and the next CLI run's preserve-or-wipe decision
|
||||
// hangs on it.
|
||||
expect(region).toContain('const measuredEmbeddings = await countPersistedEmbeddings();');
|
||||
expect(region).toContain('await saveMeta(entry.storagePath, embeddingMeta);');
|
||||
expect(region).toContain('await saveMeta(storagePath, embeddingMeta);');
|
||||
// Ordering, without brittle character spans: flush → measure → decide →
|
||||
// write. Counting before the flush would describe rows still in the WAL.
|
||||
const flushed = region.lastIndexOf('await flushWAL();');
|
||||
|
|
@ -689,3 +689,22 @@ describe('POST /api/embed route wiring (#2790)', () => {
|
|||
expect(source).not.toMatch(/p\.phase === 'ready' \? 'complete'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP repo catalog validation', () => {
|
||||
const readSource = () =>
|
||||
fs.readFile(path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'), 'utf-8');
|
||||
|
||||
it('lists and resolves repos with validate: true, and maps StorageRequirementError', async () => {
|
||||
const source = await readSource();
|
||||
expect(source).toMatch(/const repos = await listRegisteredRepos\(\{\s*validate:\s*true\s*\}\)/);
|
||||
expect(source).toMatch(
|
||||
/const freshRepos = await listRegisteredRepos\(\{\s*validate:\s*options\.validateStorage !== false,\s*\}\)/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/app\.get\('\/api\/repos'[\s\S]*listRegisteredRepos\(\{\s*validate:\s*true\s*\}\)/,
|
||||
);
|
||||
expect(source).toMatch(/sendStorageRequirementHttp\(err, res\)/);
|
||||
expect(source).toMatch(/storageRequirementToHttp\(err\)/);
|
||||
expect(source).toMatch(/code: 'index-unavailable'/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,11 +45,15 @@ vi.mock('child_process', async () => {
|
|||
});
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
canonicalizePath: (p: string) => p,
|
||||
getStoragePath: () => H.STORAGE_PATH,
|
||||
INDEX_METADATA_FILE: H.METADATA_FILE,
|
||||
listRegisteredRepos: async () => [{ path: H.REPO_PATH, storagePath: H.STORAGE_PATH }],
|
||||
registryPathEquals: (a: string, b: string) => a === b,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/storage-resolver.js', () => ({
|
||||
ANALYZE_STORAGE_REQUIREMENTS: { allowedStates: ['missing', 'empty', 'owned'] },
|
||||
ANALYZE_FORCE_STORAGE_REQUIREMENTS: {
|
||||
allowedStates: ['missing', 'empty', 'owned', 'unowned', 'foreign'],
|
||||
},
|
||||
requireStoragePath: async () => H.STORAGE_PATH,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
|
|
@ -115,12 +119,12 @@ describe('finalization gate follows the placement the run chose', () => {
|
|||
let backendInit: Mock<() => Promise<unknown>>;
|
||||
let closeDbHandle: Mock<() => Promise<void>>;
|
||||
|
||||
const launcher = () =>
|
||||
const launcher = (extras?: { releaseRepoLock?: () => void }) =>
|
||||
createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
acquireRepoLock: () => null,
|
||||
releaseRepoLock: () => {},
|
||||
releaseRepoLock: extras?.releaseRepoLock ?? (() => {}),
|
||||
closeDbHandle,
|
||||
});
|
||||
|
||||
|
|
@ -134,6 +138,7 @@ describe('finalization gate follows the placement the run chose', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
jobManager.dispose();
|
||||
vi.restoreAllMocks();
|
||||
forkMock.mockReset();
|
||||
|
|
@ -145,7 +150,7 @@ describe('finalization gate follows the placement the run chose', () => {
|
|||
H.settledDir = path.join(H.STORAGE_PATH, BRANCHES_DIR, branchSlug(BRANCH));
|
||||
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH, branch: BRANCH });
|
||||
launcher()(job, REPO_PATH, { branch: BRANCH });
|
||||
await launcher()(job, REPO_PATH, { branch: BRANCH });
|
||||
|
||||
child.emit('message', completeMessage(false));
|
||||
|
||||
|
|
@ -159,7 +164,7 @@ describe('finalization gate follows the placement the run chose', () => {
|
|||
H.settledDir = H.STORAGE_PATH;
|
||||
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
launcher()(job, REPO_PATH, {});
|
||||
await launcher()(job, REPO_PATH, {});
|
||||
|
||||
child.emit('message', completeMessage(true));
|
||||
|
||||
|
|
@ -173,7 +178,7 @@ describe('finalization gate follows the placement the run chose', () => {
|
|||
H.settledDir = H.STORAGE_PATH;
|
||||
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH, branch: BRANCH });
|
||||
launcher()(job, REPO_PATH, { branch: BRANCH });
|
||||
await launcher()(job, REPO_PATH, { branch: BRANCH });
|
||||
|
||||
child.emit('message', completeMessage(true));
|
||||
|
||||
|
|
@ -186,9 +191,10 @@ describe('finalization gate follows the placement the run chose', () => {
|
|||
// No directory looks freshly written. Without the alreadyUpToDate skip the
|
||||
// mtime gate would hold the analyze slot for the full 60s settle timeout.
|
||||
H.settledDir = '';
|
||||
const releaseRepoLock = vi.fn();
|
||||
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
launcher()(job, REPO_PATH, {});
|
||||
await launcher({ releaseRepoLock })(job, REPO_PATH, {});
|
||||
|
||||
child.emit('message', completeMessage(true, { alreadyUpToDate: true }));
|
||||
child.emit('exit', 0);
|
||||
|
|
@ -200,6 +206,8 @@ describe('finalization gate follows the placement the run chose', () => {
|
|||
expect(backendInit).toHaveBeenCalledTimes(1);
|
||||
expect(forkMock).toHaveBeenCalledTimes(1);
|
||||
expect(jobManager.getJob(job.id)?.retryCount).toBe(0);
|
||||
// Short path: skip the mtime wait, then drop the lock once it finishes.
|
||||
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fork a retry when the worker exits 0 after reporting complete', async () => {
|
||||
|
|
@ -209,7 +217,7 @@ describe('finalization gate follows the placement the run chose', () => {
|
|||
H.settledDir = path.join(H.STORAGE_PATH, BRANCHES_DIR, branchSlug(BRANCH));
|
||||
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH, branch: BRANCH });
|
||||
launcher()(job, REPO_PATH, { branch: BRANCH });
|
||||
await launcher()(job, REPO_PATH, { branch: BRANCH });
|
||||
|
||||
child.emit('message', completeMessage(false));
|
||||
child.emit('exit', 0);
|
||||
|
|
@ -221,12 +229,65 @@ describe('finalization gate follows the placement the run chose', () => {
|
|||
expect(jobManager.getJob(job.id)?.retryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('fails and does not publish when the settle gate times out', async () => {
|
||||
vi.useFakeTimers();
|
||||
H.settledDir = '';
|
||||
const releaseRepoLock = vi.fn();
|
||||
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
await launcher({ releaseRepoLock })(job, REPO_PATH, {});
|
||||
child.emit('message', completeMessage(true));
|
||||
|
||||
expect(releaseRepoLock).not.toHaveBeenCalled();
|
||||
expect(backendInit).not.toHaveBeenCalled();
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
|
||||
|
||||
// Must match FINALIZE_SETTLE_TIMEOUT_MS + one poll in analyze-launch.ts.
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
|
||||
const done = jobManager.getJob(job.id);
|
||||
expect(done?.status).toBe('failed');
|
||||
expect(done?.error).toMatch(/finalization not visible after timeout/i);
|
||||
expect(backendInit).not.toHaveBeenCalled();
|
||||
expect(closeDbHandle).not.toHaveBeenCalled();
|
||||
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('holds the write lock until settle resolves, then releases once after publish', async () => {
|
||||
vi.useFakeTimers();
|
||||
H.settledDir = '';
|
||||
const order: string[] = [];
|
||||
const releaseRepoLock = vi.fn(() => {
|
||||
order.push('releaseRepoLock');
|
||||
});
|
||||
backendInit = vi.fn(async () => {
|
||||
order.push('backend.init');
|
||||
return true;
|
||||
});
|
||||
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
await launcher({ releaseRepoLock })(job, REPO_PATH, {});
|
||||
child.emit('message', completeMessage(true));
|
||||
|
||||
expect(releaseRepoLock).not.toHaveBeenCalled();
|
||||
expect(backendInit).not.toHaveBeenCalled();
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
|
||||
|
||||
H.settledDir = H.STORAGE_PATH;
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('complete');
|
||||
expect(backendInit).toHaveBeenCalledTimes(1);
|
||||
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
||||
expect(order).toEqual(['backend.init', 'releaseRepoLock']);
|
||||
});
|
||||
|
||||
it('still treats an exit with no terminal IPC as a crash worth retrying', async () => {
|
||||
// The guard must not swallow real crashes: no `complete`/`error` was sent.
|
||||
H.settledDir = H.STORAGE_PATH;
|
||||
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
launcher()(job, REPO_PATH, {});
|
||||
await launcher()(job, REPO_PATH, {});
|
||||
|
||||
child.emit('exit', 1);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,12 +23,18 @@ import { EventEmitter } from 'node:events';
|
|||
// `vi.mock` factories are hoisted above every top-level `const`, and this file
|
||||
// imports the module under test statically — so anything a factory closes over
|
||||
// must be hoisted with it.
|
||||
const H = vi.hoisted(() => ({
|
||||
forkMock: vi.fn(),
|
||||
STORAGE_PATH: '/tmp/gitnexus-test-storage',
|
||||
REPO_PATH: '/tmp/gitnexus-test-repo',
|
||||
METADATA_FILE: 'gitnexus.json',
|
||||
}));
|
||||
const H = vi.hoisted(() => {
|
||||
const STORAGE_PATH = '/tmp/gitnexus-test-storage';
|
||||
return {
|
||||
forkMock: vi.fn(),
|
||||
STORAGE_PATH,
|
||||
REPO_PATH: '/tmp/gitnexus-test-repo',
|
||||
METADATA_FILE: 'gitnexus.json',
|
||||
// When false, the finalization gate sees no fresh index (timeout / lock-hold tests).
|
||||
settleOk: true,
|
||||
requireStoragePath: vi.fn(async () => STORAGE_PATH),
|
||||
};
|
||||
});
|
||||
const { forkMock, REPO_PATH } = H;
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
|
|
@ -36,25 +42,34 @@ vi.mock('child_process', async () => {
|
|||
return { ...actual, fork: H.forkMock };
|
||||
});
|
||||
|
||||
// The launcher's finalization gate (`waitForSettledIndex`) probes the registry
|
||||
// and the filesystem. Pin both so the gate settles on its FIRST poll — the gate
|
||||
// itself is not under test here and its 200ms poll would otherwise put a real
|
||||
// timer between the worker message and the assertions.
|
||||
// The launcher's finalization gate (`waitForSettledIndex`) probes the
|
||||
// ownership-validated storage path. Pin the filesystem so the gate settles on
|
||||
// its FIRST poll — the gate itself is not under test here and its 200ms poll
|
||||
// would otherwise put a real timer between the worker message and the assertions.
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
canonicalizePath: (p: string) => p,
|
||||
getStoragePath: () => H.STORAGE_PATH,
|
||||
INDEX_METADATA_FILE: H.METADATA_FILE,
|
||||
listRegisteredRepos: async () => [{ path: H.REPO_PATH, storagePath: H.STORAGE_PATH }],
|
||||
registryPathEquals: (a: string, b: string) => a === b,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/storage-resolver.js', () => ({
|
||||
ANALYZE_STORAGE_REQUIREMENTS: { allowedStates: ['missing', 'empty', 'owned'] },
|
||||
ANALYZE_FORCE_STORAGE_REQUIREMENTS: {
|
||||
allowedStates: ['missing', 'empty', 'owned', 'unowned', 'foreign'],
|
||||
},
|
||||
requireStoragePath: H.requireStoragePath,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
||||
return {
|
||||
...actual,
|
||||
// Both index files were (re)written far in the future relative to jobStartMs…
|
||||
statSync: () => ({ mtimeMs: Number.MAX_SAFE_INTEGER }),
|
||||
// …and no WAL/shadow/checkpoint sidecar remains.
|
||||
statSync: () => {
|
||||
if (!H.settleOk) {
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
}
|
||||
// Both index files were (re)written far in the future relative to jobStartMs.
|
||||
return { mtimeMs: Number.MAX_SAFE_INTEGER };
|
||||
},
|
||||
// No WAL/shadow/checkpoint sidecar remains when the gate is allowed to settle.
|
||||
existsSync: () => false,
|
||||
};
|
||||
});
|
||||
|
|
@ -76,6 +91,7 @@ const completeMessage = (graphWriteCollapsed?: { expected: number; persisted: nu
|
|||
const result = {
|
||||
repoName: REPO_NAME,
|
||||
repoPath: REPO_PATH,
|
||||
storagePath: H.STORAGE_PATH,
|
||||
stats: { files: 10, nodes: 100, edges: 500 },
|
||||
...(graphWriteCollapsed ? { graphWriteCollapsed } : {}),
|
||||
} satisfies Partial<AnalyzeResult> as AnalyzeResult;
|
||||
|
|
@ -109,12 +125,14 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
acquireRepoLock: () => null,
|
||||
releaseRepoLock: () => {},
|
||||
releaseRepoLock: () => {
|
||||
calls.push('releaseRepoLock');
|
||||
},
|
||||
closeDbHandle,
|
||||
});
|
||||
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
launch(job, REPO_PATH, {});
|
||||
await launch(job, REPO_PATH, {});
|
||||
child.emit('message', msg);
|
||||
|
||||
await vi.waitFor(() => expect(calls).toContain('updateJob:terminal'));
|
||||
|
|
@ -123,6 +141,8 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
H.settleOk = true;
|
||||
H.requireStoragePath.mockClear();
|
||||
jobManager = new JobManager();
|
||||
child = makeChild();
|
||||
forkMock.mockImplementation(() => child);
|
||||
|
|
@ -149,7 +169,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
});
|
||||
});
|
||||
|
||||
it('forwards the Spring Actuator snapshot path to the worker', () => {
|
||||
it('forwards the Spring Actuator snapshot path to the worker', async () => {
|
||||
const launch = createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
|
|
@ -159,7 +179,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
});
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
|
||||
launch(job, REPO_PATH, { springActuatorPath: 'runtime/actuator' });
|
||||
await launch(job, REPO_PATH, { springActuatorPath: 'runtime/actuator' });
|
||||
|
||||
expect(child.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -170,7 +190,29 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
);
|
||||
});
|
||||
|
||||
it('forwards the index-branch selector to the worker', () => {
|
||||
it('uses the force storage set only when launch options request force', async () => {
|
||||
const launch = createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
acquireRepoLock: () => null,
|
||||
releaseRepoLock: () => {},
|
||||
closeDbHandle,
|
||||
});
|
||||
|
||||
const ordinary = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
await launch(ordinary, REPO_PATH, {});
|
||||
expect(H.requireStoragePath).toHaveBeenLastCalledWith(REPO_PATH, {
|
||||
allowedStates: ['missing', 'empty', 'owned'],
|
||||
});
|
||||
|
||||
const forced = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
await launch(forced, REPO_PATH, { force: true });
|
||||
expect(H.requireStoragePath).toHaveBeenLastCalledWith(REPO_PATH, {
|
||||
allowedStates: ['missing', 'empty', 'owned', 'unowned', 'foreign'],
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the index-branch selector to the worker', async () => {
|
||||
const launch = createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
|
|
@ -180,7 +222,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
});
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
|
||||
launch(job, REPO_PATH, { branch: 'development' });
|
||||
await launch(job, REPO_PATH, { branch: 'development' });
|
||||
|
||||
// `StartMessage.options` is typed as `AnalyzeOptions`, so this key IS
|
||||
// `AnalyzeOptions.branch` — the field `resolveWriteTarget` reads to choose
|
||||
|
|
@ -195,7 +237,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
);
|
||||
});
|
||||
|
||||
it('omits branch entirely when the caller did not select one', () => {
|
||||
it('omits branch entirely when the caller did not select one', async () => {
|
||||
const launch = createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
|
|
@ -205,7 +247,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
});
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
|
||||
launch(job, REPO_PATH, {});
|
||||
await launch(job, REPO_PATH, {});
|
||||
|
||||
// Not merely undefined: absent. `AnalyzeOptions.branch === undefined` is the
|
||||
// documented signal for "target the flat workspace slot", so sending the key
|
||||
|
|
@ -215,9 +257,11 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
jobManager.dispose();
|
||||
vi.restoreAllMocks();
|
||||
forkMock.mockReset();
|
||||
H.settleOk = true;
|
||||
});
|
||||
|
||||
it('does not publish the index — backend.init() is never called for a collapsed run', async () => {
|
||||
|
|
@ -232,6 +276,8 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
// is not publication.
|
||||
expect(closeDbHandle).toHaveBeenCalledTimes(1);
|
||||
expect(calls.indexOf('closeDbHandle')).toBeLessThan(calls.indexOf('updateJob:failed'));
|
||||
// Lock is held through the collapse decision and dropped afterwards, once.
|
||||
expect(calls.indexOf('updateJob:failed')).toBeLessThan(calls.indexOf('releaseRepoLock'));
|
||||
});
|
||||
|
||||
it('marks the collapsed run failed and still reports repoName', async () => {
|
||||
|
|
@ -261,6 +307,7 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
'backend.init',
|
||||
'updateJob:complete',
|
||||
'updateJob:terminal',
|
||||
'releaseRepoLock',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -278,4 +325,67 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
const job = await runWorker(healthy);
|
||||
expect(job?.status).toBe('complete');
|
||||
});
|
||||
|
||||
it('fails and does not publish when index finalization never becomes visible', async () => {
|
||||
vi.useFakeTimers();
|
||||
H.settleOk = false;
|
||||
const releaseRepoLock = vi.fn(() => {
|
||||
calls.push('releaseRepoLock');
|
||||
});
|
||||
const launch = createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
acquireRepoLock: () => null,
|
||||
releaseRepoLock,
|
||||
closeDbHandle,
|
||||
});
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
await launch(job, REPO_PATH, {});
|
||||
child.emit('message', completeMessage());
|
||||
|
||||
expect(releaseRepoLock).not.toHaveBeenCalled();
|
||||
expect(backendInit).not.toHaveBeenCalled();
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
|
||||
|
||||
// Must match FINALIZE_SETTLE_TIMEOUT_MS + one poll in analyze-launch.ts.
|
||||
await vi.advanceTimersByTimeAsync(61_000);
|
||||
|
||||
const done = jobManager.getJob(job.id);
|
||||
expect(done?.status).toBe('failed');
|
||||
expect(done?.error).toMatch(/finalization not visible after timeout/i);
|
||||
expect(backendInit).not.toHaveBeenCalled();
|
||||
expect(closeDbHandle).not.toHaveBeenCalled();
|
||||
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('holds the write lock until settle resolves, then releases once after publish', async () => {
|
||||
vi.useFakeTimers();
|
||||
H.settleOk = false;
|
||||
const releaseRepoLock = vi.fn(() => {
|
||||
calls.push('releaseRepoLock');
|
||||
});
|
||||
const launch = createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
acquireRepoLock: () => null,
|
||||
releaseRepoLock,
|
||||
closeDbHandle,
|
||||
});
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
await launch(job, REPO_PATH, {});
|
||||
child.emit('message', completeMessage());
|
||||
|
||||
// First poll failed; the gate is sleeping. Lock must still be held.
|
||||
expect(releaseRepoLock).not.toHaveBeenCalled();
|
||||
expect(backendInit).not.toHaveBeenCalled();
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
|
||||
|
||||
H.settleOk = true;
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('complete');
|
||||
expect(backendInit).toHaveBeenCalledTimes(1);
|
||||
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
||||
expect(calls.indexOf('backend.init')).toBeLessThan(calls.indexOf('releaseRepoLock'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { IndexLockTimeoutError, type LockRecord } from '../../src/storage/index-
|
|||
const baseResult: AnalyzeResult = {
|
||||
repoName: 'repo',
|
||||
repoPath: '/repo',
|
||||
storagePath: '/repo/.gitnexus',
|
||||
stats: {},
|
||||
alreadyUpToDate: false,
|
||||
ftsRepairedOnly: false,
|
||||
|
|
@ -77,6 +78,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => {
|
|||
|
||||
const completes = send.mock.calls.filter((c) => c[0].type === 'complete');
|
||||
expect(completes).toHaveLength(1);
|
||||
expect(okFinalize).toHaveBeenCalledWith('/repo', '/repo/.gitnexus');
|
||||
});
|
||||
|
||||
it('threads the pre-import runner receipt into runFullAnalysis', async () => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ function hostileResult(): AnalyzeResult {
|
|||
return {
|
||||
repoName: 'demo',
|
||||
repoPath: '/repos/demo',
|
||||
storagePath: '/repos/demo/.gitnexus',
|
||||
stats: { files: 3, nodes: 1, edges: 0 },
|
||||
alreadyUpToDate: false,
|
||||
ftsSkipped: true,
|
||||
|
|
@ -85,6 +86,7 @@ describe('#2112: analyze-worker IPC projection', () => {
|
|||
const result: AnalyzeResult = {
|
||||
repoName: 'demo',
|
||||
repoPath: '/r',
|
||||
storagePath: '/r/.gitnexus',
|
||||
stats: { nodes: 50, edges: 1 },
|
||||
pipelineResult: {
|
||||
graph,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* that signal. Mutation still fail-closes on both paths.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, realpath, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
|
@ -177,7 +177,7 @@ describe('analyzer identity in-process cache guards (#3092)', () => {
|
|||
const tree = await seedWideBuildTree(fixture.dbPath);
|
||||
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
|
||||
resolveAnalyzerRunnerIdentity(pathToFileURL(tree.modulePath).href, { cacheDirectory });
|
||||
fsCtx.unwritableExact.add(tree.packageRoot);
|
||||
fsCtx.unwritableExact.add(await realpath(tree.packageRoot));
|
||||
spawnCtx.spawnSync.mockClear();
|
||||
_clearAnalyzerIdentityProcessCacheForTests();
|
||||
resolveAnalyzerRunnerIdentity(pathToFileURL(tree.modulePath).href, { cacheDirectory });
|
||||
|
|
@ -198,7 +198,9 @@ describe('analyzer identity in-process cache guards (#3092)', () => {
|
|||
fsCtx.wOkProbes.length = 0;
|
||||
_clearAnalyzerIdentityProcessCacheForTests();
|
||||
resolveAnalyzerRunnerIdentity(pathToFileURL(tree.modulePath).href, { cacheDirectory });
|
||||
expect(fsCtx.wOkProbes).toEqual(expect.arrayContaining([tree.packageRoot, tree.sourceRoot]));
|
||||
expect(fsCtx.wOkProbes).toEqual(
|
||||
expect.arrayContaining([await realpath(tree.packageRoot), await realpath(tree.sourceRoot)]),
|
||||
);
|
||||
expect(fsCtx.wOkProbes).not.toContain(cacheDirectory);
|
||||
expect(cacheGuardSpawnCount()).toBeGreaterThan(0);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { link, mkdir, readFile, readdir, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
link,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
|
@ -34,6 +43,10 @@ describe('analyzer runner identity', () => {
|
|||
await writeFile(path.join(fixture.dbPath, 'package-lock.json'), '{"lockfileVersion":3}\n');
|
||||
await writeFile(modulePath, 'export const analyzer = 1;\n');
|
||||
const cacheDirectory = path.join(fixture.dbPath, 'identity-cache');
|
||||
const resolvedModulePath = await realpath(modulePath);
|
||||
const resolvedSourceRoot = await realpath(sourceRoot);
|
||||
const resolvedManifestPath = await realpath(path.join(fixture.dbPath, 'package.json'));
|
||||
const resolvedLockfilePath = await realpath(path.join(fixture.dbPath, 'package-lock.json'));
|
||||
|
||||
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
|
||||
cacheDirectory,
|
||||
|
|
@ -50,18 +63,18 @@ describe('analyzer runner identity', () => {
|
|||
libc: expect.any(String),
|
||||
},
|
||||
invokedArtifact: {
|
||||
path: modulePath,
|
||||
path: resolvedModulePath,
|
||||
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
build: {
|
||||
kind: 'source',
|
||||
rootPath: sourceRoot,
|
||||
rootPath: resolvedSourceRoot,
|
||||
canonicalization: 'gitnexus-analyzer-build-v2',
|
||||
digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
},
|
||||
dependencyRuntime: {
|
||||
manifestPath: path.join(fixture.dbPath, 'package.json'),
|
||||
lockfilePath: path.join(fixture.dbPath, 'package-lock.json'),
|
||||
manifestPath: resolvedManifestPath,
|
||||
lockfilePath: resolvedLockfilePath,
|
||||
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4',
|
||||
packageCount: 1,
|
||||
artifactCount: 0,
|
||||
|
|
@ -312,7 +325,7 @@ describe('analyzer runner identity', () => {
|
|||
return bytes;
|
||||
};
|
||||
|
||||
process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = protectedCache.dbPath;
|
||||
process.env.GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR = await realpath(protectedCache.dbPath);
|
||||
_clearAnalyzerIdentityProcessCacheForTests();
|
||||
expect(hashedBytes()).toBeGreaterThanOrEqual(64 * 1024);
|
||||
_clearAnalyzerIdentityProcessCacheForTests();
|
||||
|
|
@ -602,7 +615,7 @@ describe('analyzer runner identity', () => {
|
|||
const withLock = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
|
||||
cacheDirectory,
|
||||
});
|
||||
expect(withLock.dependencyRuntime.lockfilePath).toBe(ancestorLock);
|
||||
expect(withLock.dependencyRuntime.lockfilePath).toBe(await realpath(ancestorLock));
|
||||
expect(withLock.dependencyRuntime.digest).not.toBe(withoutLock.dependencyRuntime.digest);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
|
|
@ -1095,7 +1108,9 @@ describe('analyzer runner identity', () => {
|
|||
const first = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
|
||||
cacheDirectory,
|
||||
});
|
||||
expect(first.dependencyRuntime.lockfilePath).toBe(lockLink);
|
||||
expect(first.dependencyRuntime.lockfilePath).toBe(
|
||||
path.join(await realpath(path.dirname(lockLink)), path.basename(lockLink)),
|
||||
);
|
||||
|
||||
await writeFile(lockTarget, '{"lockfileVersion":4,"changed":true}\n');
|
||||
const targetChanged = resolveAnalyzerRunnerIdentity(pathToFileURL(modulePath).href, {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { handleFileRequest } from '../../src/server/api.js';
|
||||
import { handleFileRequest, type SourceAvailability } from '../../src/server/api.js';
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
|
|
@ -42,7 +42,10 @@ afterAll(async () => {
|
|||
// Minimal express-shaped mock that captures status() / json() calls in a
|
||||
// shape compatible with the handler's expected interface. Returns the
|
||||
// final status (default 200 for naked res.json) and JSON body.
|
||||
const invoke = async (query: Record<string, unknown>): Promise<{ status: number; body: any }> => {
|
||||
const invoke = async (
|
||||
query: Record<string, unknown>,
|
||||
availability?: SourceAvailability,
|
||||
): Promise<{ status: number; body: any }> => {
|
||||
let capturedStatus = 200;
|
||||
let capturedBody: any = undefined;
|
||||
const res = {
|
||||
|
|
@ -54,7 +57,7 @@ const invoke = async (query: Record<string, unknown>): Promise<{ status: number;
|
|||
capturedBody = body;
|
||||
},
|
||||
};
|
||||
await handleFileRequest({ query }, res, tmpRoot);
|
||||
await handleFileRequest({ query }, res, tmpRoot, availability);
|
||||
return { status: capturedStatus, body: capturedBody };
|
||||
};
|
||||
|
||||
|
|
@ -71,6 +74,15 @@ describe('handleFileRequest — security wiring', () => {
|
|||
expect(body.content).toBe('nested\n');
|
||||
});
|
||||
|
||||
it('returns 410 when the selected retention profile cannot provide full source', async () => {
|
||||
const { status, body } = await invoke(
|
||||
{ path: 'hello.txt' },
|
||||
{ available: false, reason: 'content-retention' },
|
||||
);
|
||||
expect(status).toBe(410);
|
||||
expect(body).toMatchObject({ code: 'source-unavailable', reason: 'content-retention' });
|
||||
});
|
||||
|
||||
it('returns 400 when path is missing', async () => {
|
||||
const { status, body } = await invoke({});
|
||||
expect(status).toBe(400);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import express from 'express';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
|
|
@ -16,6 +18,10 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({
|
|||
loadMeta: mocks.loadMeta,
|
||||
listRegisteredRepos: mocks.listRegisteredRepos,
|
||||
}));
|
||||
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/storage/storage-resolver.js')>()),
|
||||
requireRegisteredStoragePath: vi.fn(async (entry: { storagePath: string }) => entry.storagePath),
|
||||
}));
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
withLbugDb: mocks.withLbugDb,
|
||||
executeQuery: vi.fn(async () => []),
|
||||
|
|
@ -68,11 +74,13 @@ vi.mock('../../src/server/analyze-job.js', () => ({
|
|||
import { createServer } from '../../src/server/api.js';
|
||||
import { FTS_DISABLED_MESSAGE } from '../../src/core/search/fts-policy.js';
|
||||
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fts-mode-fixture-'));
|
||||
const entry = {
|
||||
name: 'fts-mode-fixture',
|
||||
path: path.resolve('fts-mode-fixture'),
|
||||
storagePath: path.resolve('fts-mode-fixture/.gitnexus'),
|
||||
path: fixtureRoot,
|
||||
storagePath: path.join(fixtureRoot, '.gitnexus'),
|
||||
};
|
||||
fs.mkdirSync(entry.storagePath, { recursive: true });
|
||||
let app: express.Express;
|
||||
const events = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'] as const;
|
||||
const originalListeners = new Map(events.map((event) => [event, process.listeners(event)]));
|
||||
|
|
@ -102,6 +110,7 @@ afterAll(() => {
|
|||
}
|
||||
}
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -199,7 +208,8 @@ describe('serve uses one metadata-derived FTS mode on every DB-open path', () =>
|
|||
}
|
||||
}
|
||||
expect(mocks.withLbugDb).toHaveBeenCalledTimes(sequence.length);
|
||||
expect(mocks.loadMeta).toHaveBeenCalledTimes(sequence.length);
|
||||
// Grep also loads metadata for getSourceAvailability before the FTS session.
|
||||
expect(mocks.loadMeta).toHaveBeenCalledTimes(sequence.length + 1);
|
||||
for (const [dbPath, , options] of mocks.withLbugDb.mock.calls) {
|
||||
expect(dbPath).toBe(path.join(entry.storagePath, 'lbug'));
|
||||
expect(options).toEqual({ readOnly: true, ...(skip ? { skipFts: true } : {}) });
|
||||
|
|
@ -240,3 +250,12 @@ describe('serve uses one metadata-derived FTS mode on every DB-open path', () =>
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('GET /api/repos catalog validation', () => {
|
||||
it('lists registered repos with validate: true', async () => {
|
||||
mocks.loadMeta.mockResolvedValue({});
|
||||
await invoke('/api/repos');
|
||||
expect(mocks.listRegisteredRepos).toHaveBeenCalledWith({ validate: true });
|
||||
expect(mocks.listRegisteredRepos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -292,6 +292,14 @@ describe('LocalBackend.init', () => {
|
|||
await backend.init();
|
||||
expect(listRegisteredRepos).toHaveBeenCalledWith({ validate: true });
|
||||
});
|
||||
|
||||
it('does not delete legacy Kuzu files while initializing a read backend', async () => {
|
||||
setupSingleRepo();
|
||||
|
||||
await backend.init();
|
||||
|
||||
expect(cleanupOldKuzuFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalBackend.countRepos', () => {
|
||||
|
|
@ -1187,13 +1195,22 @@ describe('LocalBackend.callTool', () => {
|
|||
expect(result.error).toContain('Either "name" or "uid"');
|
||||
});
|
||||
|
||||
it('context tool returns not-found for missing symbol', async () => {
|
||||
it('context tool returns content availability with a missing symbol', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
const result = await backend.callTool('context', { name: 'doesNotExist' });
|
||||
const result = await backend.callTool('context', {
|
||||
name: 'doesNotExist',
|
||||
include_content: true,
|
||||
});
|
||||
expect(result.error).toContain('not found');
|
||||
expect(result.contentAvailability).toEqual({
|
||||
requested: true,
|
||||
profile: 'full',
|
||||
available: true,
|
||||
scope: 'full',
|
||||
});
|
||||
});
|
||||
|
||||
it('context tool returns disambiguation for multiple matches', async () => {
|
||||
it('context tool returns content availability with ambiguous matches', async () => {
|
||||
(executeParameterized as any).mockResolvedValue([
|
||||
{
|
||||
id: 'func:main:1',
|
||||
|
|
@ -1212,9 +1229,15 @@ describe('LocalBackend.callTool', () => {
|
|||
endLine: 5,
|
||||
},
|
||||
]);
|
||||
const result = await backend.callTool('context', { name: 'main' });
|
||||
const result = await backend.callTool('context', { name: 'main', include_content: true });
|
||||
expect(result.status).toBe('ambiguous');
|
||||
expect(result.candidates).toHaveLength(2);
|
||||
expect(result.contentAvailability).toEqual({
|
||||
requested: true,
|
||||
profile: 'full',
|
||||
available: true,
|
||||
scope: 'full',
|
||||
});
|
||||
|
||||
// #470: every candidate carries a relevance score in [0, 1] and the list
|
||||
// is sorted descending by score (with deterministic tiebreakers).
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ describe('canonicalizePath vs the `\\\\?\\` long-path prefix (#2667)', () => {
|
|||
// Pinned here because "complete the fix by stripping here too" is the tempting
|
||||
// follow-up refactor, and it would widen what the recursive delete accepts.
|
||||
describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => {
|
||||
const itOnWindows = process.platform === 'win32' ? it : it.skip;
|
||||
const base: Omit<RegistryEntry, 'storagePath'> = {
|
||||
name: 'repo',
|
||||
path: '\\\\?\\D:\\Projects\\repo',
|
||||
|
|
@ -152,16 +153,16 @@ describe('assertSafeStoragePath vs the `\\\\?\\` prefix (#2667)', () => {
|
|||
lastCommit: 'deadbee',
|
||||
};
|
||||
|
||||
it('accepts an entry whose path and storagePath share the prefix', () => {
|
||||
expect(() =>
|
||||
itOnWindows('accepts an entry whose path and storagePath share the prefix', async () => {
|
||||
await expect(
|
||||
assertSafeStoragePath({ ...base, storagePath: '\\\\?\\D:\\Projects\\repo\\.gitnexus' }),
|
||||
).not.toThrow();
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a mixed-form entry instead of deleting through it', () => {
|
||||
expect(() =>
|
||||
itOnWindows('rejects a mixed-form entry instead of deleting through it', async () => {
|
||||
await expect(
|
||||
assertSafeStoragePath({ ...base, storagePath: 'D:\\Projects\\repo\\.gitnexus' }),
|
||||
).toThrow();
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
119
gitnexus/test/unit/clean-command-ownership.test.ts
Normal file
119
gitnexus/test/unit/clean-command-ownership.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Regression for PR #3060: ordinary `clean --force` must not trust a
|
||||
* registry entry that redirects repository A to repository B's external index.
|
||||
*
|
||||
* This deliberately drives the real clean command, resolver, registry reader,
|
||||
* and filesystem. Guard-only tests cannot catch a future bypass in clean.ts.
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanCommand } from '../../src/cli/clean.js';
|
||||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||||
import { initGitRepo } from '../helpers/temp-git-repo.js';
|
||||
|
||||
describe('cleanCommand external storage ownership', () => {
|
||||
let fixture: TestDBHandle;
|
||||
let previousGitNexusHome: string | undefined;
|
||||
let repoA: string;
|
||||
let repoB: string;
|
||||
let storageB: string;
|
||||
let registryPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
fixture = await createTempDir();
|
||||
previousGitNexusHome = process.env.GITNEXUS_HOME;
|
||||
|
||||
const home = path.join(fixture.dbPath, 'home');
|
||||
repoA = path.join(fixture.dbPath, 'repo-a');
|
||||
repoB = path.join(fixture.dbPath, 'repo-b');
|
||||
storageB = path.join(fixture.dbPath, 'external-index-b');
|
||||
registryPath = path.join(home, 'registry.json');
|
||||
|
||||
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(repoA), fs.mkdir(repoB)]);
|
||||
initGitRepo(repoA);
|
||||
initGitRepo(repoB);
|
||||
|
||||
await fs.mkdir(storageB);
|
||||
const metadata = {
|
||||
repoPath: repoB,
|
||||
storagePath: storageB,
|
||||
lastCommit: 'b-indexed-commit',
|
||||
indexedAt: '2026-09-05T00:00:00.000Z',
|
||||
};
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(storageB, 'gitnexus.json'), JSON.stringify(metadata)),
|
||||
fs.writeFile(path.join(storageB, 'meta.json'), JSON.stringify(metadata)),
|
||||
fs.writeFile(path.join(storageB, 'ownership-sentinel'), 'must survive\n'),
|
||||
]);
|
||||
|
||||
// Deliberately corrupted registry: repository A names B's valid index.
|
||||
await fs.writeFile(
|
||||
registryPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'repo-a',
|
||||
path: repoA,
|
||||
storagePath: storageB,
|
||||
lastCommit: 'a-indexed-commit',
|
||||
indexedAt: '2026-09-05T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repoA);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (previousGitNexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousGitNexusHome;
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it('deletes a foreign repository-local .gitnexus on clean --all --force', async () => {
|
||||
const localStorage = path.join(repoA, '.gitnexus');
|
||||
await fs.mkdir(localStorage, { recursive: true });
|
||||
const metadata = {
|
||||
repoPath: repoB,
|
||||
storagePath: localStorage,
|
||||
lastCommit: 'foreign-local-commit',
|
||||
indexedAt: '2026-09-05T00:00:00.000Z',
|
||||
};
|
||||
await fs.writeFile(path.join(localStorage, 'gitnexus.json'), JSON.stringify(metadata));
|
||||
await fs.writeFile(path.join(localStorage, 'ownership-sentinel'), 'local-foreign\n');
|
||||
await fs.writeFile(
|
||||
registryPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'repo-a',
|
||||
path: repoA,
|
||||
storagePath: localStorage,
|
||||
lastCommit: 'a-indexed-commit',
|
||||
indexedAt: '2026-09-05T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await cleanCommand({ force: true, all: true });
|
||||
|
||||
await expect(fs.access(localStorage)).rejects.toBeTruthy();
|
||||
expect(JSON.parse(await fs.readFile(registryPath, 'utf-8'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves a foreign external index and registry entry on ordinary clean --force', async () => {
|
||||
await cleanCommand({ force: true });
|
||||
|
||||
await expect(fs.access(storageB)).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(storageB, 'gitnexus.json'))).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(storageB, 'meta.json'))).resolves.toBeUndefined();
|
||||
await expect(fs.readFile(path.join(storageB, 'ownership-sentinel'), 'utf-8')).resolves.toBe(
|
||||
'must survive\n',
|
||||
);
|
||||
|
||||
const [remainingEntry] = JSON.parse(await fs.readFile(registryPath, 'utf-8'));
|
||||
expect(remainingEntry).toMatchObject({ path: repoA, storagePath: storageB });
|
||||
});
|
||||
});
|
||||
|
|
@ -192,12 +192,31 @@ describe('CLI help surface', () => {
|
|||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('环境变量:');
|
||||
expect(result.stdout).toContain('GITNEXUS_STORAGE_PATH=/absolute/index');
|
||||
expect(result.stdout).toContain('完整外部索引目录');
|
||||
expect(result.stdout).toContain('GITNEXUS_STORAGE_ROOT=/absolute/root');
|
||||
expect(result.stdout).toContain('外部索引根目录');
|
||||
expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full');
|
||||
expect(result.stdout).toContain('源码文本保留策略');
|
||||
expect(result.stdout).toContain('当参数和对应环境变量同时提供时,参数优先。');
|
||||
expect(result.stdout).toContain('提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。');
|
||||
expect(result.stdout).not.toContain('Environment variables:');
|
||||
expect(result.stdout).not.toContain('Flags override the corresponding env vars');
|
||||
});
|
||||
|
||||
it('analyze help documents the external storage root layout', () => {
|
||||
const result = runHelp('analyze');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('GITNEXUS_STORAGE_PATH=/absolute/index');
|
||||
expect(result.stdout).toContain('Complete external index directory');
|
||||
expect(result.stdout).toContain('GITNEXUS_STORAGE_ROOT=/absolute/root');
|
||||
expect(result.stdout).toContain('External index root');
|
||||
expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full');
|
||||
expect(result.stdout).toContain('Source-text retention profile');
|
||||
expect(result.stdout).toContain('<repo-basename>-<canonical-path-hash>/');
|
||||
});
|
||||
|
||||
it('query help keeps advanced search options without importing analyze deps', () => {
|
||||
const result = runHelp('query');
|
||||
|
||||
|
|
|
|||
150
gitnexus/test/unit/content-retention.test.ts
Normal file
150
gitnexus/test/unit/content-retention.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyContentRetention,
|
||||
contentRetentionFromEnvironment,
|
||||
contentRetentionFromMeta,
|
||||
contentRetentionMismatch,
|
||||
ftsProfileForContentRetention,
|
||||
} from '../../src/core/content-retention.js';
|
||||
import { getFtsIndexes } from '../../src/core/search/fts-schema.js';
|
||||
import { buildTestGraph } from '../helpers/test-graph.js';
|
||||
|
||||
const savedRetention = process.env.GITNEXUS_CONTENT_RETENTION;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedRetention === undefined) delete process.env.GITNEXUS_CONTENT_RETENTION;
|
||||
else process.env.GITNEXUS_CONTENT_RETENTION = savedRetention;
|
||||
});
|
||||
|
||||
const contentGraph = () =>
|
||||
buildTestGraph([
|
||||
{
|
||||
id: 'File:src/index.ts',
|
||||
label: 'File',
|
||||
name: 'index.ts',
|
||||
filePath: 'src/index.ts',
|
||||
extra: { content: 'const retainedFileText = true;' },
|
||||
},
|
||||
{
|
||||
id: 'Function:src/index.ts:run:1',
|
||||
label: 'Function',
|
||||
name: 'run',
|
||||
filePath: 'src/index.ts',
|
||||
startLine: 1,
|
||||
endLine: 3,
|
||||
extra: {
|
||||
content: 'function run() { return retainedSymbolText; }',
|
||||
description: 'source comment',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BasicBlock:src/index.ts:run:1:0',
|
||||
label: 'BasicBlock',
|
||||
name: 'block',
|
||||
filePath: 'src/index.ts',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
extra: { text: 'return retainedBlockText;', description: 'block annotation' },
|
||||
},
|
||||
]);
|
||||
|
||||
describe('content retention profiles', () => {
|
||||
it('uses full when the environment is absent or blank and rejects explicit invalid values', () => {
|
||||
delete process.env.GITNEXUS_CONTENT_RETENTION;
|
||||
expect(contentRetentionFromEnvironment()).toBe('full');
|
||||
process.env.GITNEXUS_CONTENT_RETENTION = ' ';
|
||||
expect(contentRetentionFromEnvironment()).toBe('full');
|
||||
process.env.GITNEXUS_CONTENT_RETENTION = 'archive';
|
||||
expect(() => contentRetentionFromEnvironment()).toThrow(/GITNEXUS_CONTENT_RETENTION/);
|
||||
});
|
||||
|
||||
it('keeps every existing text field in the full profile', () => {
|
||||
const graph = contentGraph();
|
||||
applyContentRetention(graph, 'full');
|
||||
|
||||
expect(graph.getNode('File:src/index.ts')?.properties.content).toContain('retainedFileText');
|
||||
expect(graph.getNode('Function:src/index.ts:run:1')?.properties.content).toContain(
|
||||
'retainedSymbolText',
|
||||
);
|
||||
expect(graph.getNode('BasicBlock:src/index.ts:run:1:0')?.properties.text).toContain(
|
||||
'retainedBlockText',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes file text but preserves symbol spans in the symbol profile', () => {
|
||||
const graph = contentGraph();
|
||||
applyContentRetention(graph, 'symbol');
|
||||
|
||||
expect(graph.getNode('File:src/index.ts')?.properties.content).toBeUndefined();
|
||||
expect(graph.getNode('Function:src/index.ts:run:1')?.properties.content).toContain(
|
||||
'retainedSymbolText',
|
||||
);
|
||||
expect(graph.getNode('Function:src/index.ts:run:1')?.properties.description).toBe(
|
||||
'source comment',
|
||||
);
|
||||
expect(graph.getNode('BasicBlock:src/index.ts:run:1:0')?.properties.text).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes every source-derived text field in the none profile', () => {
|
||||
const graph = contentGraph();
|
||||
applyContentRetention(graph, 'none');
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
expect(node.properties.content).toBeUndefined();
|
||||
expect(node.properties.description).toBeUndefined();
|
||||
}
|
||||
expect(graph.getNode('BasicBlock:src/index.ts:run:1:0')?.properties.text).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats legacy metadata as full and forces a rebuild for changed retention metadata', () => {
|
||||
expect(contentRetentionFromMeta({})).toBe('full');
|
||||
expect(contentRetentionFromMeta({ contentRetention: 'corrupt' } as never)).toBe('none');
|
||||
expect(contentRetentionMismatch({}, 'full')).toBe(false);
|
||||
expect(contentRetentionMismatch({}, 'symbol')).toBe(true);
|
||||
expect(contentRetentionMismatch({ contentRetention: 'full' }, 'full')).toBe(true);
|
||||
expect(contentRetentionMismatch({ contentRetention: 'symbol' }, 'symbol')).toBe(true);
|
||||
expect(
|
||||
contentRetentionMismatch(
|
||||
{
|
||||
contentRetention: 'symbol',
|
||||
contentRetentionSchemaVersion: 1,
|
||||
ftsProfile: 'symbol-no-file-content',
|
||||
},
|
||||
'symbol',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
contentRetentionMismatch(
|
||||
{
|
||||
contentRetention: 'symbol',
|
||||
contentRetentionSchemaVersion: 2,
|
||||
ftsProfile: 'symbol-no-file-content',
|
||||
},
|
||||
'symbol',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
contentRetentionMismatch(
|
||||
{
|
||||
contentRetention: 'symbol',
|
||||
contentRetentionSchemaVersion: 1,
|
||||
},
|
||||
'symbol',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('selects FTS columns that never require discarded text', () => {
|
||||
expect(ftsProfileForContentRetention('full')).toBe('full');
|
||||
expect(ftsProfileForContentRetention('symbol')).toBe('symbol-no-file-content');
|
||||
expect(ftsProfileForContentRetention('none')).toBe('name-only');
|
||||
expect(getFtsIndexes('full').find((index) => index.table === 'File')?.properties).toEqual([
|
||||
'name',
|
||||
'content',
|
||||
]);
|
||||
expect(
|
||||
getFtsIndexes('symbol-no-file-content').find((index) => index.table === 'File')?.properties,
|
||||
).toEqual(['name']);
|
||||
expect(getFtsIndexes('name-only').every((index) => index.properties.length === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
*
|
||||
* Covers:
|
||||
* - extractPattern: pattern extraction from Grep/Read/Shell tool inputs
|
||||
* - findGitNexusDir: .gitnexus directory discovery (shared with Claude hook)
|
||||
* - findRegisteredRepo: registry-backed repository discovery
|
||||
* - cwd validation: rejects relative paths
|
||||
* - shell injection: verifies no `shell: true` in spawnSync calls
|
||||
* - cross-platform: Windows .cmd extension handling
|
||||
|
|
@ -23,7 +23,7 @@ import { createRequire } from 'module';
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runHook } from '../utils/hook-test-helpers.js';
|
||||
import { runHook as spawnHook } from '../utils/hook-test-helpers.js';
|
||||
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
|
||||
|
||||
// ─── Path to the Cursor hook + manifest ─────────────────────────────
|
||||
|
|
@ -82,6 +82,7 @@ let tmpDir: string;
|
|||
// deliberately has no .gitnexus so unrelated early-exit tests stay cheap.
|
||||
let guardTmpDir: string;
|
||||
let guardGitNexusDir: string;
|
||||
let hookHome: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-test-'));
|
||||
|
|
@ -93,13 +94,38 @@ beforeAll(() => {
|
|||
initGitRepo(guardTmpDir, { name: 'Test', email: 'test@test.com' });
|
||||
fs.writeFileSync(path.join(guardTmpDir, 'dummy.txt'), 'hello');
|
||||
commitAll(guardTmpDir, 'init');
|
||||
|
||||
hookHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-home-'));
|
||||
fs.writeFileSync(
|
||||
path.join(hookHome, 'registry.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'cursor-guard',
|
||||
path: guardTmpDir,
|
||||
storagePath: guardGitNexusDir,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(hookHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(guardTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runHook(
|
||||
hookPath: string,
|
||||
input: Record<string, any>,
|
||||
cwd?: string,
|
||||
options: { env?: NodeJS.ProcessEnv } = {},
|
||||
) {
|
||||
return spawnHook(hookPath, input, cwd, {
|
||||
...options,
|
||||
env: { ...(options.env ?? process.env), GITNEXUS_HOME: hookHome },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Manifest + hook file presence ───────────────────────────────────
|
||||
|
||||
describe('Cursor integration files', () => {
|
||||
|
|
@ -208,17 +234,25 @@ describe('Cursor hook source regressions', () => {
|
|||
expect(source).toMatch(/'augment',\s*'--',\s*pattern/);
|
||||
});
|
||||
|
||||
it('gates on a non-global .gitnexus directory before invoking the CLI', () => {
|
||||
expect(source).toContain('findGitNexusDir');
|
||||
expect(source).toContain('isGlobalRegistryDir');
|
||||
});
|
||||
|
||||
it('isGlobalRegistryDir recognizes gitnexus.json as well as legacy meta.json', () => {
|
||||
expect(source).toContain('gitnexus.json');
|
||||
it('gates on a registry entry before invoking the CLI', () => {
|
||||
expect(source).toContain('resolveHookRepo');
|
||||
expect(source).toContain('registry-query.cjs');
|
||||
});
|
||||
|
||||
it('handles linked git worktrees via git rev-parse --git-common-dir', () => {
|
||||
expect(source).toContain('--git-common-dir');
|
||||
const resolver = fs.readFileSync(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'hooks',
|
||||
'registry-query.cjs',
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
expect(resolver).toContain('--git-common-dir');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const {
|
|||
initialiseSearchFTSStemmer,
|
||||
missingSearchFTSIndexTables,
|
||||
} = await import('../../src/core/search/fts-indexes.js');
|
||||
const { FTS_INDEXES } = await import('../../src/core/search/fts-schema.js');
|
||||
const { FTS_INDEXES, getFtsIndexes } = await import('../../src/core/search/fts-schema.js');
|
||||
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
/** SHOW_INDEXES rows covering every configured FTS index's expected properties. */
|
||||
|
|
@ -76,6 +76,16 @@ describe('createSearchFTSIndexes', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('applies the table filter within the selected content-retention profile', async () => {
|
||||
await createSearchFTSIndexes({
|
||||
indexes: getFtsIndexes('name-only'),
|
||||
tables: new Set(['Function']),
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['drop:Function.function_fts', 'create:Function.function_fts:porter']);
|
||||
expect(createFTSIndex).toHaveBeenCalledWith('Function', 'function_fts', ['name'], 'porter');
|
||||
});
|
||||
|
||||
it('invokes onIndexStart/onIndexReady once per index', async () => {
|
||||
const started: string[] = [];
|
||||
const ready: string[] = [];
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ describe('bridgeMetaMatchesFile with a half-written stamp', () => {
|
|||
|
||||
afterEach(async () => {
|
||||
renameMock.mode = 'none';
|
||||
await closeAllCachedBridges();
|
||||
await fsp.rm(groupDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
|
@ -246,6 +247,14 @@ describe('bridgeMetaMatchesFile with a half-written stamp', () => {
|
|||
await seedStamped();
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true);
|
||||
// Distinct filesystem mtimes must not collapse: rounding would treat
|
||||
// T and T+0.25 as the same stamp and wave a same-size swap through.
|
||||
await expect(
|
||||
bridgeMetaMatchesFile(groupDir, {
|
||||
...meta,
|
||||
bridgeMtimeMs: (meta.bridgeMtimeMs as number) + 0.25,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { syncGroup } from '../../../src/core/group/sync.js';
|
||||
import { RegistryAmbiguousTargetError } from '../../../src/storage/repo-manager.js';
|
||||
|
|
@ -49,11 +49,22 @@ const row = (
|
|||
indexedAt: string;
|
||||
lastCommit: string;
|
||||
} => {
|
||||
mkdirSync(path.join(tmpHome, 'repos', clone), { recursive: true });
|
||||
const repoPath = path.join(tmpHome, 'repos', clone);
|
||||
const storagePath = path.join(repoPath, '.gitnexus');
|
||||
mkdirSync(path.join(storagePath, 'lbug'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(storagePath, 'gitnexus.json'),
|
||||
JSON.stringify({
|
||||
repoPath,
|
||||
storagePath,
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
lastCommit: 'abc123',
|
||||
}),
|
||||
);
|
||||
return {
|
||||
name,
|
||||
path: path.join(tmpHome, 'repos', clone),
|
||||
storagePath: path.join(tmpHome, 'repos', clone, '.gitnexus'),
|
||||
path: repoPath,
|
||||
storagePath,
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
lastCommit: 'abc123',
|
||||
};
|
||||
|
|
@ -121,6 +132,23 @@ describe('syncGroup registry name identity', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('treats a registry member with foreign storage metadata as unreadable', async () => {
|
||||
const known = row(tmpHome.dbPath, 'backend-repo', 'backend');
|
||||
await fs.writeFile(
|
||||
path.join(known.storagePath, 'gitnexus.json'),
|
||||
JSON.stringify({ repoPath: path.join(tmpHome.dbPath, 'repos', 'other') }),
|
||||
);
|
||||
await fs.writeFile(registryPath, JSON.stringify([known]));
|
||||
|
||||
const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), {
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
expect(result.missingRepos).toEqual([]);
|
||||
expect(result.unreadableRepos).toEqual(['app/backend']);
|
||||
expect(initLbugMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats mixed missing and ambiguous names as a terminal ambiguity with no write', async () => {
|
||||
const a = row(tmpHome.dbPath, 'demo-api', 'clone-a');
|
||||
const b = row(tmpHome.dbPath, 'demo-api', 'clone-b');
|
||||
|
|
|
|||
|
|
@ -75,6 +75,20 @@ vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
// This file focuses on failures from `initLbug` and extraction after a member
|
||||
// has resolved. Keep its deliberately synthetic registry paths from being
|
||||
// intercepted by the production storage inspection; the real registry-backed
|
||||
// storage gate is covered by sync-registry-identity.test.ts.
|
||||
vi.mock('../../../src/storage/storage-resolver.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/storage/storage-resolver.js')>();
|
||||
return {
|
||||
...actual,
|
||||
requireRegisteredStoragePath: vi.fn(
|
||||
async (entry: { storagePath: string }) => entry.storagePath,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Armed by the bridge-write-failure suite at the bottom of this file, `null`
|
||||
* everywhere else. There is no filesystem shape that makes the real writer fail
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -279,6 +279,10 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
it('pins SCHEMA_BUMP to 99 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(99);
|
||||
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
|
||||
// The PREVIOUS version must fail the reuse gate, not merely differ from the
|
||||
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
||||
// while being wrong, which is exactly how the 37/38 exact clashes landed.
|
||||
// Every nearby historical or in-flight value is rejected.
|
||||
for (const taken of [
|
||||
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
|
||||
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,37 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
|
||||
const { StorageRequirementError } = vi.hoisted(() => {
|
||||
class StorageRequirementError extends Error {
|
||||
constructor(
|
||||
public inspection: {
|
||||
repoPath: string;
|
||||
storagePath: string;
|
||||
state: string;
|
||||
hasCodeIndexDB: boolean;
|
||||
},
|
||||
public requirements: { allowedStates: readonly string[]; requireCodeIndexDB?: boolean },
|
||||
) {
|
||||
super('storage requirement failed');
|
||||
this.name = 'StorageRequirementError';
|
||||
}
|
||||
}
|
||||
return { StorageRequirementError };
|
||||
});
|
||||
|
||||
const mockAccess = vi.fn();
|
||||
const mockGetStoragePaths = vi.fn();
|
||||
const mockLoadMeta = vi.fn();
|
||||
const mockSaveMeta = vi.fn();
|
||||
const mockRegisterRepo = vi.fn();
|
||||
const mockEnsureGitNexusIgnored = vi.fn();
|
||||
const mockGetGitRoot = vi.fn();
|
||||
const mockIsGitRepo = vi.fn();
|
||||
const mockRequireStoragePath = vi.fn();
|
||||
const mockGetIndexStorageRequirements = vi.fn((force: boolean) => ({
|
||||
allowedStates: force ? ['owned', 'unowned', 'foreign'] : ['owned'],
|
||||
requireCodeIndexDB: true,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: {
|
||||
|
|
@ -19,6 +43,7 @@ vi.mock('../../src/storage/repo-manager.js', () => ({
|
|||
getStoragePaths: mockGetStoragePaths,
|
||||
INDEX_METADATA_FILE: 'gitnexus.json',
|
||||
loadMeta: mockLoadMeta,
|
||||
saveMeta: mockSaveMeta,
|
||||
registerRepo: mockRegisterRepo,
|
||||
ensureGitNexusIgnored: mockEnsureGitNexusIgnored,
|
||||
}));
|
||||
|
|
@ -33,15 +58,38 @@ vi.mock('../../src/storage/git.js', () => ({
|
|||
getRemoteUrl: vi.fn().mockReturnValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/storage-resolver.js', () => ({
|
||||
getIndexStorageRequirements: mockGetIndexStorageRequirements,
|
||||
requireStoragePath: mockRequireStoragePath,
|
||||
StorageRequirementError,
|
||||
}));
|
||||
|
||||
describe('indexCommand', () => {
|
||||
const resolvedRepo = path.resolve('/repo');
|
||||
const resolvedOutside = path.resolve('/outside/path');
|
||||
const indexRequirements = { allowedStates: ['owned'] as const, requireCodeIndexDB: true };
|
||||
const storageFailure = (
|
||||
state: 'empty' | 'owned' | 'unowned',
|
||||
hasCodeIndexDB: boolean,
|
||||
): StorageRequirementError =>
|
||||
new StorageRequirementError(
|
||||
{
|
||||
repoPath: resolvedRepo,
|
||||
storagePath: `${resolvedRepo}/.gitnexus`,
|
||||
state,
|
||||
hasCodeIndexDB,
|
||||
},
|
||||
indexRequirements,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
|
||||
mockRequireStoragePath.mockReset();
|
||||
mockRequireStoragePath.mockResolvedValue(`${resolvedRepo}/.gitnexus`);
|
||||
|
||||
mockGetStoragePaths.mockImplementation((repoPath: string) => ({
|
||||
storagePath: `${repoPath}/.gitnexus`,
|
||||
lbugPath: `${repoPath}/.gitnexus/lbug`,
|
||||
|
|
@ -53,6 +101,7 @@ describe('indexCommand', () => {
|
|||
indexedAt: '2026-03-20T00:00:00.000Z',
|
||||
stats: { nodes: 10, edges: 20 },
|
||||
});
|
||||
mockSaveMeta.mockResolvedValue(undefined);
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
mockEnsureGitNexusIgnored.mockResolvedValue(undefined);
|
||||
mockGetGitRoot.mockReturnValue(resolvedRepo);
|
||||
|
|
@ -73,10 +122,7 @@ describe('indexCommand', () => {
|
|||
|
||||
it('fails when no metadata or LadybugDB index exists', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockAccess.mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath.includes('/.gitnexus/')) throw new Error(`missing ${targetPath}`);
|
||||
return undefined;
|
||||
});
|
||||
mockRequireStoragePath.mockRejectedValueOnce(storageFailure('empty', false));
|
||||
|
||||
const { indexCommand } = await import('../../src/cli/index-repo.js');
|
||||
await indexCommand(['/repo']);
|
||||
|
|
@ -90,10 +136,7 @@ describe('indexCommand', () => {
|
|||
|
||||
it('fails when lbug database does not exist', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockAccess.mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath === `${resolvedRepo}/.gitnexus/lbug`) throw new Error('missing lbug');
|
||||
return undefined;
|
||||
});
|
||||
mockRequireStoragePath.mockRejectedValueOnce(storageFailure('owned', false));
|
||||
|
||||
const { indexCommand } = await import('../../src/cli/index-repo.js');
|
||||
await indexCommand(['/repo']);
|
||||
|
|
@ -105,6 +148,7 @@ describe('indexCommand', () => {
|
|||
|
||||
it('fails when meta.json is missing and --force is not set', async () => {
|
||||
mockLoadMeta.mockResolvedValue(null);
|
||||
mockRequireStoragePath.mockRejectedValueOnce(storageFailure('unowned', true));
|
||||
|
||||
const { indexCommand } = await import('../../src/cli/index-repo.js');
|
||||
await indexCommand(['/repo']);
|
||||
|
|
@ -120,12 +164,49 @@ describe('indexCommand', () => {
|
|||
await indexCommand(['/repo'], { force: true });
|
||||
|
||||
expect(mockRegisterRepo).toHaveBeenCalledTimes(1);
|
||||
expect(mockSaveMeta).toHaveBeenCalledWith(
|
||||
`${resolvedRepo}/.gitnexus`,
|
||||
expect.objectContaining({ repoPath: resolvedRepo, lastCommit: '' }),
|
||||
);
|
||||
expect(mockRegisterRepo).toHaveBeenCalledWith(
|
||||
resolvedRepo,
|
||||
expect.objectContaining({
|
||||
repoPath: resolvedRepo,
|
||||
lastCommit: '',
|
||||
}),
|
||||
{ storagePath: `${resolvedRepo}/.gitnexus` },
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rewrites foreign local gitnexus.json ownership on --force adopt', async () => {
|
||||
const foreignRepo = path.resolve('/other-repo');
|
||||
mockLoadMeta.mockResolvedValue({
|
||||
repoPath: foreignRepo,
|
||||
storagePath: `${foreignRepo}/.gitnexus`,
|
||||
lastCommit: 'abc123',
|
||||
indexedAt: '2026-03-20T00:00:00.000Z',
|
||||
stats: { nodes: 10, edges: 20 },
|
||||
});
|
||||
|
||||
const { indexCommand } = await import('../../src/cli/index-repo.js');
|
||||
await indexCommand(['/repo'], { force: true });
|
||||
|
||||
expect(mockSaveMeta).toHaveBeenCalledWith(
|
||||
`${resolvedRepo}/.gitnexus`,
|
||||
expect.objectContaining({
|
||||
repoPath: resolvedRepo,
|
||||
storagePath: `${resolvedRepo}/.gitnexus`,
|
||||
lastCommit: 'abc123',
|
||||
}),
|
||||
);
|
||||
expect(mockRegisterRepo).toHaveBeenCalledWith(
|
||||
resolvedRepo,
|
||||
expect.objectContaining({
|
||||
repoPath: resolvedRepo,
|
||||
storagePath: `${resolvedRepo}/.gitnexus`,
|
||||
}),
|
||||
{ storagePath: `${resolvedRepo}/.gitnexus` },
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
|
@ -142,23 +223,24 @@ describe('indexCommand', () => {
|
|||
await indexCommand(['/repo'], { force: true });
|
||||
|
||||
expect(mockRegisterRepo).toHaveBeenCalledTimes(1);
|
||||
expect(mockSaveMeta).toHaveBeenCalledWith(
|
||||
`${resolvedRepo}/.gitnexus`,
|
||||
expect.objectContaining({ repoPath: resolvedRepo, lastCommit: '' }),
|
||||
);
|
||||
expect(mockRegisterRepo).toHaveBeenCalledWith(
|
||||
resolvedRepo,
|
||||
expect.objectContaining({
|
||||
repoPath: resolvedRepo,
|
||||
lastCommit: '',
|
||||
}),
|
||||
{ storagePath: `${resolvedRepo}/.gitnexus` },
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails without --force when LadybugDB exists but metadata is missing', async () => {
|
||||
mockLoadMeta.mockResolvedValue(null);
|
||||
mockAccess.mockImplementation(async (targetPath: string) => {
|
||||
if (targetPath === `${resolvedRepo}/.gitnexus/lbug`) return undefined;
|
||||
if (targetPath.includes('/.gitnexus/')) throw new Error(`missing ${targetPath}`);
|
||||
return undefined;
|
||||
});
|
||||
mockRequireStoragePath.mockRejectedValueOnce(storageFailure('unowned', true));
|
||||
|
||||
const { indexCommand } = await import('../../src/cli/index-repo.js');
|
||||
await indexCommand(['/repo']);
|
||||
|
|
@ -175,9 +257,13 @@ describe('indexCommand', () => {
|
|||
expect(mockRegisterRepo).toHaveBeenCalledWith(
|
||||
resolvedRepo,
|
||||
expect.objectContaining({ repoPath: resolvedRepo }),
|
||||
{ storagePath: `${resolvedRepo}/.gitnexus` },
|
||||
);
|
||||
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledTimes(1);
|
||||
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(resolvedRepo);
|
||||
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(
|
||||
resolvedRepo,
|
||||
`${resolvedRepo}/.gitnexus`,
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -211,8 +297,12 @@ describe('indexCommand', () => {
|
|||
expect(mockRegisterRepo).toHaveBeenCalledWith(
|
||||
resolvedRepo,
|
||||
expect.objectContaining({ repoPath: resolvedRepo }),
|
||||
{ storagePath: `${resolvedRepo}/.gitnexus` },
|
||||
);
|
||||
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(
|
||||
resolvedRepo,
|
||||
`${resolvedRepo}/.gitnexus`,
|
||||
);
|
||||
expect(mockEnsureGitNexusIgnored).toHaveBeenCalledWith(resolvedRepo);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
* is unchanged, and `status` reflects the checked-out branch.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
const { runnerIdentity } = vi.hoisted(() => ({
|
||||
runnerIdentity: {
|
||||
|
|
@ -39,18 +42,21 @@ const { runnerIdentity } = vi.hoisted(() => ({
|
|||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn(),
|
||||
findRepo: vi.fn(),
|
||||
getStoragePaths: vi.fn((repoPath: string, branch?: string) => ({
|
||||
storagePath: `${repoPath}/.gitnexus`,
|
||||
getStoragePaths: vi.fn((repoPath: string, branch?: string, resolvedStoragePath?: string) => ({
|
||||
storagePath: resolvedStoragePath ?? `${repoPath}/.gitnexus`,
|
||||
lbugPath: branch
|
||||
? `${repoPath}/.gitnexus/branches/${branch}/lbug`
|
||||
: `${repoPath}/.gitnexus/lbug`,
|
||||
? `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/branches/${branch}/lbug`
|
||||
: `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/lbug`,
|
||||
metaPath: branch
|
||||
? `${repoPath}/.gitnexus/branches/${branch}/meta.json`
|
||||
: `${repoPath}/.gitnexus/meta.json`,
|
||||
? `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/branches/${branch}/meta.json`
|
||||
: `${resolvedStoragePath ?? `${repoPath}/.gitnexus`}/meta.json`,
|
||||
})),
|
||||
loadMeta: vi.fn(),
|
||||
hasKuzuIndex: vi.fn().mockResolvedValue(false),
|
||||
readRegistryStrict: vi.fn(),
|
||||
resolveRegistryEntry: vi.fn(),
|
||||
RegistryNotFoundError: class RegistryNotFoundError extends Error {},
|
||||
RegistryAmbiguousTargetError: class RegistryAmbiguousTargetError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/analyzer-identity.js', () => ({
|
||||
|
|
@ -60,19 +66,36 @@ vi.mock('../../src/core/analyzer-identity.js', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/storage/storage-resolver.js')>()),
|
||||
requireStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'),
|
||||
requireRegisteredStoragePath: vi.fn().mockResolvedValue('/repo/.gitnexus'),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', () => ({
|
||||
isGitRepo: vi.fn().mockReturnValue(true),
|
||||
getCurrentCommit: vi.fn().mockReturnValue('headsha0'),
|
||||
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
||||
getGitRoot: vi.fn((p: string) => p),
|
||||
getGitRoot: vi.fn().mockReturnValue('/repo'),
|
||||
isWorkingTreeDirty: vi.fn().mockReturnValue(false),
|
||||
listWorkingTreeDirtyPaths: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
import { listCommand } from '../../src/cli/list.js';
|
||||
import { statusCommand } from '../../src/cli/status.js';
|
||||
import { listRegisteredRepos, findRepo, loadMeta } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
listRegisteredRepos,
|
||||
loadMeta,
|
||||
readRegistryStrict,
|
||||
resolveRegistryEntry,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { getCurrentBranch, getCurrentCommit, isWorkingTreeDirty } from '../../src/storage/git.js';
|
||||
import {
|
||||
requireRegisteredStoragePath,
|
||||
requireStoragePath,
|
||||
STATUS_STORAGE_REQUIREMENTS,
|
||||
StorageRequirementError,
|
||||
} from '../../src/storage/storage-resolver.js';
|
||||
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
|
@ -80,6 +103,14 @@ const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
(loadMeta as any).mockResolvedValue({
|
||||
repoPath: '/repo',
|
||||
lastCommit: 'headsha0',
|
||||
indexedAt: '2026-06-10T12:00:00.000Z',
|
||||
branch: 'main',
|
||||
runnerIdentity,
|
||||
scopeExtractionReceipt: 1 as const,
|
||||
});
|
||||
});
|
||||
|
||||
describe('list branch rendering (#2106)', () => {
|
||||
|
|
@ -142,8 +173,108 @@ describe('status branch rendering (#2106)', () => {
|
|||
},
|
||||
};
|
||||
|
||||
it.each([false, true])('uses the strict registry reader for status --repo%s', async (json) => {
|
||||
const corrupt = new Error('registry is corrupt: entry 0 is malformed');
|
||||
vi.mocked(readRegistryStrict).mockRejectedValueOnce(corrupt);
|
||||
|
||||
await expect(statusCommand({ repo: 'demo', json })).rejects.toBe(corrupt);
|
||||
expect(resolveRegistryEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the Git root before checking the default storage path', async () => {
|
||||
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
||||
|
||||
await statusCommand({ json: true });
|
||||
|
||||
expect(requireStoragePath).toHaveBeenCalledWith('/repo', STATUS_STORAGE_REQUIREMENTS);
|
||||
});
|
||||
|
||||
it('checks the exact registry storage path for status --repo', async () => {
|
||||
const entry = { path: '/repo', storagePath: '/external/repo-slot' };
|
||||
(readRegistryStrict as any).mockResolvedValue([entry]);
|
||||
(resolveRegistryEntry as any).mockReturnValue(entry);
|
||||
(requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath);
|
||||
(loadMeta as any).mockResolvedValue({ ...baseRepo.meta, repoPath: entry.path });
|
||||
|
||||
await statusCommand({ repo: 'repo', json: true });
|
||||
|
||||
expect(requireRegisteredStoragePath).toHaveBeenCalledWith(entry, STATUS_STORAGE_REQUIREMENTS);
|
||||
expect(JSON.parse(output())).toMatchObject({ storagePath: entry.storagePath });
|
||||
});
|
||||
|
||||
it.each(['symbol', 'none'] as const)(
|
||||
'status --repo reports source-unavailable when checkout exists but contentRetention is %s',
|
||||
async (contentRetention) => {
|
||||
const checkout = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-status-src-'));
|
||||
try {
|
||||
const entry = { path: checkout, storagePath: '/external/repo-slot' };
|
||||
(readRegistryStrict as any).mockResolvedValue([entry]);
|
||||
(resolveRegistryEntry as any).mockReturnValue(entry);
|
||||
(requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath);
|
||||
(loadMeta as any).mockResolvedValue({
|
||||
...baseRepo.meta,
|
||||
repoPath: entry.path,
|
||||
contentRetention,
|
||||
});
|
||||
|
||||
await statusCommand({ repo: 'repo', json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
sourceAvailable: false,
|
||||
status: 'source-unavailable',
|
||||
index: { contentRetention },
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(checkout, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('status --repo reports sourceAvailable when checkout exists and retention is full', async () => {
|
||||
const checkout = await fs.mkdtemp(path.join(os.tmpdir(), 'gnx-status-full-'));
|
||||
try {
|
||||
const entry = { path: checkout, storagePath: '/external/repo-slot' };
|
||||
(readRegistryStrict as any).mockResolvedValue([entry]);
|
||||
(resolveRegistryEntry as any).mockReturnValue(entry);
|
||||
(requireRegisteredStoragePath as any).mockResolvedValue(entry.storagePath);
|
||||
(loadMeta as any).mockResolvedValue({
|
||||
...baseRepo.meta,
|
||||
repoPath: entry.path,
|
||||
contentRetention: 'full',
|
||||
});
|
||||
|
||||
await statusCommand({ repo: 'repo', json: true });
|
||||
|
||||
expect(JSON.parse(output())).toMatchObject({
|
||||
sourceAvailable: true,
|
||||
status: 'registered',
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(checkout, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a foreign registered storage path instead of treating it as an index', async () => {
|
||||
const entry = { path: '/repo', storagePath: '/external/repo-slot' };
|
||||
const inspection = {
|
||||
repoPath: entry.path,
|
||||
storagePath: entry.storagePath,
|
||||
state: 'foreign' as const,
|
||||
hasCodeIndexDB: true,
|
||||
};
|
||||
(readRegistryStrict as any).mockResolvedValue([entry]);
|
||||
(resolveRegistryEntry as any).mockReturnValue(entry);
|
||||
(requireRegisteredStoragePath as any).mockRejectedValue(
|
||||
new StorageRequirementError(inspection, STATUS_STORAGE_REQUIREMENTS),
|
||||
);
|
||||
|
||||
await expect(statusCommand({ repo: 'repo', json: true })).rejects.toBeInstanceOf(
|
||||
StorageRequirementError,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders indexed and current typed runner receipts for exact comparison', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
||||
(getCurrentBranch as any).mockReturnValue('main');
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0');
|
||||
|
||||
|
|
@ -154,7 +285,7 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('renders stable machine-readable provenance with --json', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
||||
(getCurrentBranch as any).mockReturnValue('main');
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0');
|
||||
|
||||
|
|
@ -170,7 +301,7 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('reports a dirty working tree as stale in --json even when the commit matches', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
||||
(getCurrentBranch as any).mockReturnValue('main');
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0');
|
||||
(isWorkingTreeDirty as any).mockReturnValueOnce(true);
|
||||
|
|
@ -184,7 +315,7 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('reports a dirty working tree as stale in the human output at a matching commit', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
||||
(getCurrentBranch as any).mockReturnValue('main');
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0');
|
||||
(isWorkingTreeDirty as any).mockReturnValueOnce(true);
|
||||
|
|
@ -194,20 +325,17 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('never certifies dirty or checkpointed metadata and reports stable incomplete reasons', async () => {
|
||||
(findRepo as any).mockResolvedValue({
|
||||
...baseRepo,
|
||||
meta: {
|
||||
...baseRepo.meta,
|
||||
incrementalInProgress: { startedAt: 1, toWriteCount: 2 },
|
||||
embeddingCheckpoint: {
|
||||
at: '2026-07-18T00:00:00.000Z',
|
||||
nodesProcessed: 1,
|
||||
totalNodes: 2,
|
||||
chunksProcessed: 1,
|
||||
model: 'fixture',
|
||||
dimensions: 3,
|
||||
provider: 'local',
|
||||
},
|
||||
(loadMeta as any).mockResolvedValue({
|
||||
...baseRepo.meta,
|
||||
incrementalInProgress: { startedAt: 1, toWriteCount: 2 },
|
||||
embeddingCheckpoint: {
|
||||
at: '2026-07-18T00:00:00.000Z',
|
||||
nodesProcessed: 1,
|
||||
totalNodes: 2,
|
||||
chunksProcessed: 1,
|
||||
model: 'fixture',
|
||||
dimensions: 3,
|
||||
provider: 'local',
|
||||
},
|
||||
});
|
||||
(getCurrentBranch as any).mockReturnValue('main');
|
||||
|
|
@ -224,12 +352,9 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('treats an older runner receipt schema as stale at the same commit', async () => {
|
||||
(findRepo as any).mockResolvedValue({
|
||||
...baseRepo,
|
||||
meta: {
|
||||
...baseRepo.meta,
|
||||
runnerIdentity: { ...runnerIdentity, schemaVersion: 1 },
|
||||
},
|
||||
(loadMeta as any).mockResolvedValue({
|
||||
...baseRepo.meta,
|
||||
runnerIdentity: { ...runnerIdentity, schemaVersion: 1 },
|
||||
});
|
||||
(getCurrentBranch as any).mockReturnValue('main');
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0');
|
||||
|
|
@ -242,7 +367,7 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('shows the current branch and up-to-date on the primary', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
||||
(getCurrentBranch as any).mockReturnValue('main');
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0');
|
||||
|
||||
|
|
@ -253,10 +378,9 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('falls through to the workspace index when the branch has no pinned index (#2354)', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce(null); // feature/y has no pinned index
|
||||
(getCurrentBranch as any).mockReturnValue('feature/y');
|
||||
(getCurrentCommit as any).mockReturnValue('headsha9');
|
||||
(loadMeta as any).mockResolvedValue(null); // feature/y has no pinned index
|
||||
|
||||
await statusCommand();
|
||||
const out = output();
|
||||
|
|
@ -268,10 +392,9 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('same-commit branch flip reports up-to-date against the workspace index (#2354)', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce(null); // feature/y has no pinned index
|
||||
(getCurrentBranch as any).mockReturnValue('feature/y');
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0'); // same commit as flat meta
|
||||
(loadMeta as any).mockResolvedValue(null); // feature/y has no pinned index
|
||||
|
||||
await statusCommand();
|
||||
const out = output();
|
||||
|
|
@ -280,10 +403,7 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('compares against the branch index when the current branch has one', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(getCurrentBranch as any).mockReturnValue('feature/z');
|
||||
(getCurrentCommit as any).mockReturnValue('zzzzsha0');
|
||||
(loadMeta as any).mockResolvedValue({
|
||||
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce({
|
||||
repoPath: '/repo',
|
||||
lastCommit: 'zzzzsha0',
|
||||
indexedAt: '2026-06-10T14:00:00.000Z',
|
||||
|
|
@ -291,6 +411,8 @@ describe('status branch rendering (#2106)', () => {
|
|||
runnerIdentity,
|
||||
scopeExtractionReceipt: 1,
|
||||
});
|
||||
(getCurrentBranch as any).mockReturnValue('feature/z');
|
||||
(getCurrentCommit as any).mockReturnValue('zzzzsha0');
|
||||
|
||||
await statusCommand();
|
||||
const out = output();
|
||||
|
|
@ -299,7 +421,7 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('shows detached HEAD and compares against the flat index', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(loadMeta as any).mockResolvedValue(baseRepo.meta);
|
||||
(getCurrentBranch as any).mockReturnValue(null); // detached
|
||||
(getCurrentCommit as any).mockReturnValue('headsha0');
|
||||
|
||||
|
|
@ -310,15 +432,14 @@ describe('status branch rendering (#2106)', () => {
|
|||
});
|
||||
|
||||
it('reports stale when the branch index is behind the branch tip', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(getCurrentBranch as any).mockReturnValue('feature/z');
|
||||
(getCurrentCommit as any).mockReturnValue('newsha99'); // moved past the index
|
||||
(loadMeta as any).mockResolvedValue({
|
||||
(loadMeta as any).mockResolvedValueOnce(baseRepo.meta).mockResolvedValueOnce({
|
||||
repoPath: '/repo',
|
||||
lastCommit: 'oldsha00',
|
||||
indexedAt: '2026-06-10T14:00:00.000Z',
|
||||
branch: 'feature/z',
|
||||
});
|
||||
(getCurrentBranch as any).mockReturnValue('feature/z');
|
||||
(getCurrentCommit as any).mockReturnValue('newsha99'); // moved past the index
|
||||
|
||||
await statusCommand();
|
||||
const out = output();
|
||||
|
|
|
|||
|
|
@ -118,8 +118,9 @@ describe('publishCommand (no-token no-op)', () => {
|
|||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-test-'));
|
||||
// Simulate an existing index so hasIndex() returns true.
|
||||
// Simulate an existing owned index with a LadybugDB directory.
|
||||
await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true });
|
||||
await fs.mkdir(path.join(tempDir, '.gitnexus', 'lbug'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, '.gitnexus', 'meta.json'),
|
||||
JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }),
|
||||
|
|
@ -186,6 +187,7 @@ describe('publishCommand response branches (MEDIUM 5)', () => {
|
|||
vi.resetModules();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-resp-'));
|
||||
await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true });
|
||||
await fs.mkdir(path.join(tempDir, '.gitnexus', 'lbug'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, '.gitnexus', 'meta.json'),
|
||||
JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }),
|
||||
|
|
|
|||
|
|
@ -246,6 +246,15 @@ describe('production routes — rate-limit middleware wiring', () => {
|
|||
expect(apiSource).toMatch(/app\.delete\('\/api\/repo',\s*createRouteLimiter\(/);
|
||||
});
|
||||
|
||||
it('DELETE /api/repo resolves the registry row without pruning unusable storage', () => {
|
||||
expect(apiSource).toMatch(
|
||||
/resolveRepo\(repoName,\s*false,\s*undefined,\s*\{\s*validateStorage:\s*false\s*\}\)/,
|
||||
);
|
||||
expect(apiSource).toMatch(
|
||||
/listRegisteredRepos\(\{\s*validate:\s*options\.validateStorage !== false,\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /api/repo is wired with createRouteLimiter', () => {
|
||||
expect(apiSource).toMatch(/app\.get\('\/api\/repo',\s*createRouteLimiter\(/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import path from 'node:path';
|
|||
const mockRm = vi.fn();
|
||||
const mockReadRegistry = vi.fn();
|
||||
const mockResolveRegistryEntry = vi.fn();
|
||||
const mockAssertSafeStoragePath = vi.fn();
|
||||
const mockRequireDeletableStoragePath = vi.fn();
|
||||
const mockUnregisterRepo = vi.fn();
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
|
|
@ -25,11 +25,14 @@ vi.mock('fs/promises', () => ({
|
|||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
readRegistry: mockReadRegistry,
|
||||
resolveRegistryEntry: mockResolveRegistryEntry,
|
||||
assertSafeStoragePath: mockAssertSafeStoragePath,
|
||||
unregisterRepo: mockUnregisterRepo,
|
||||
RegistryNotFoundError: class RegistryNotFoundError extends Error {},
|
||||
RegistryAmbiguousTargetError: class RegistryAmbiguousTargetError extends Error {},
|
||||
UnsafeStoragePathError: class UnsafeStoragePathError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/storage-resolver.js', () => ({
|
||||
requireDeletableStoragePath: mockRequireDeletableStoragePath,
|
||||
StorageDeletionError: class StorageDeletionError extends Error {},
|
||||
}));
|
||||
|
||||
describe('removeCommand', () => {
|
||||
|
|
@ -47,7 +50,7 @@ describe('removeCommand', () => {
|
|||
|
||||
mockReadRegistry.mockResolvedValue([entry]);
|
||||
mockResolveRegistryEntry.mockReturnValue(entry);
|
||||
mockAssertSafeStoragePath.mockReturnValue(undefined);
|
||||
mockRequireDeletableStoragePath.mockResolvedValue(entry.storagePath);
|
||||
mockRm.mockResolvedValue(undefined);
|
||||
mockUnregisterRepo.mockResolvedValue(undefined);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,11 +26,18 @@ import {
|
|||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
import {
|
||||
STORAGE_PATH_ENV,
|
||||
STORAGE_ROOT_ENV,
|
||||
storagePathFromRoot,
|
||||
} from '../../src/storage/storage-resolver.js';
|
||||
|
||||
describe('assertAnalysisFinalized (#1169)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedGitnexusHome: string | undefined;
|
||||
let savedStoragePath: string | undefined;
|
||||
let savedStorageRoot: string | undefined;
|
||||
|
||||
const meta: RepoMeta = {
|
||||
repoPath: '',
|
||||
|
|
@ -43,12 +50,20 @@ describe('assertAnalysisFinalized (#1169)', () => {
|
|||
tmpHome = await createTempDir('gn-1169-home-');
|
||||
tmpRepo = await createTempDir('gn-1169-repo-');
|
||||
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
||||
savedStoragePath = process.env[STORAGE_PATH_ENV];
|
||||
savedStorageRoot = process.env[STORAGE_ROOT_ENV];
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
delete process.env[STORAGE_PATH_ENV];
|
||||
delete process.env[STORAGE_ROOT_ENV];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
||||
if (savedStoragePath === undefined) delete process.env[STORAGE_PATH_ENV];
|
||||
else process.env[STORAGE_PATH_ENV] = savedStoragePath;
|
||||
if (savedStorageRoot === undefined) delete process.env[STORAGE_ROOT_ENV];
|
||||
else process.env[STORAGE_ROOT_ENV] = savedStorageRoot;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
});
|
||||
|
|
@ -84,6 +99,19 @@ describe('assertAnalysisFinalized (#1169)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('rejects a corrupt registry as unreadable, not as a missing registry-entry', async () => {
|
||||
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
|
||||
await saveMeta(storagePath, meta);
|
||||
await fs.writeFile(path.join(tmpHome.dbPath, 'registry.json'), '{"truncated":');
|
||||
|
||||
await expect(assertAnalysisFinalized(tmpRepo.dbPath)).rejects.toThrow(
|
||||
/corrupt|not valid JSON/i,
|
||||
);
|
||||
await expect(assertAnalysisFinalized(tmpRepo.dbPath)).rejects.not.toBeInstanceOf(
|
||||
AnalysisNotFinalizedError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws missing="registry-entry" when meta.json exists but the registry was not updated', async () => {
|
||||
// Half-finalized state — meta.json was written but registerRepo
|
||||
// failed or was skipped. Surface this as a hard failure so the
|
||||
|
|
@ -117,6 +145,23 @@ describe('assertAnalysisFinalized (#1169)', () => {
|
|||
await expect(assertAnalysisFinalized(tmpRepo.dbPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('checks the analysis-selected external slot even when the environment changes afterward', async () => {
|
||||
const storageRoot = path.join(tmpHome.dbPath, 'external-indexes');
|
||||
const storagePath = storagePathFromRoot(storageRoot, tmpRepo.dbPath);
|
||||
const externalMeta = { ...meta, repoPath: tmpRepo.dbPath, storagePath };
|
||||
await saveMeta(storagePath, externalMeta);
|
||||
await registerRepo(tmpRepo.dbPath, externalMeta, { storagePath });
|
||||
|
||||
// Simulate a parent process changing configuration after the worker has
|
||||
// completed the analysis but before its finalization assertion runs.
|
||||
process.env[STORAGE_ROOT_ENV] = path.join(tmpHome.dbPath, 'different-indexes');
|
||||
|
||||
await expect(assertAnalysisFinalized(tmpRepo.dbPath, storagePath)).resolves.toBeUndefined();
|
||||
await expect(assertAnalysisFinalized(tmpRepo.dbPath)).rejects.toBeInstanceOf(
|
||||
AnalysisNotFinalizedError,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches registry entries case-insensitively on Windows so 8.3 short-name paths still finalize', async () => {
|
||||
// The registry comparison applies canonicalizePath + Windows
|
||||
// case-insensitivity. If the analyze caller passes the path in a
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
*
|
||||
* The gitnexus.json / meta.json dual-file contract:
|
||||
* - saveMeta writes BOTH files (primary must succeed, mirror best-effort)
|
||||
* - reconcileMetadataFiles converges the two on every analyze: fresher
|
||||
* `indexedAt` wins, written to both, nothing ever deleted
|
||||
* - reconcileMetadataFiles converges the two on every analyze: a valid
|
||||
* gitnexus.json wins; legacy is used only when primary is absent
|
||||
* - loadMeta prefers gitnexus.json, falls back to the mirror only when the
|
||||
* primary is provably absent (ENOENT/ENOTDIR)
|
||||
*
|
||||
|
|
@ -23,6 +23,11 @@ import {
|
|||
reconcileMetadataFiles,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
STORAGE_PATH_ENV,
|
||||
STORAGE_ROOT_ENV,
|
||||
storagePathFromRoot,
|
||||
} from '../../src/storage/storage-resolver.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
const metaAt = (indexedAt: string, lastCommit: string, extra?: Partial<RepoMeta>): RepoMeta => ({
|
||||
|
|
@ -38,15 +43,25 @@ const readJson = async (dir: string, filename: string): Promise<RepoMeta> =>
|
|||
describe('reconcileMetadataFiles', () => {
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let storagePath: string;
|
||||
let savedStoragePath: string | undefined;
|
||||
let savedStorageRoot: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRepo = await createTempDir('gitnexus-reconcile-suite-');
|
||||
savedStoragePath = process.env[STORAGE_PATH_ENV];
|
||||
savedStorageRoot = process.env[STORAGE_ROOT_ENV];
|
||||
delete process.env[STORAGE_PATH_ENV];
|
||||
delete process.env[STORAGE_ROOT_ENV];
|
||||
storagePath = getStoragePaths(tmpRepo.dbPath).storagePath;
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (savedStoragePath === undefined) delete process.env[STORAGE_PATH_ENV];
|
||||
else process.env[STORAGE_PATH_ENV] = savedStoragePath;
|
||||
if (savedStorageRoot === undefined) delete process.env[STORAGE_ROOT_ENV];
|
||||
else process.env[STORAGE_ROOT_ENV] = savedStorageRoot;
|
||||
await tmpRepo.cleanup();
|
||||
});
|
||||
|
||||
|
|
@ -87,12 +102,12 @@ describe('reconcileMetadataFiles', () => {
|
|||
expect(primary).toMatchObject({ incrementalInProgress: true, lastCommit: 'crashed-run' });
|
||||
});
|
||||
|
||||
it('mixed branch states converge in one call (legacy-only / converged / stale-primary)', async () => {
|
||||
it('mixed branch states converge in one call (legacy-only / converged / primary-authoritative)', async () => {
|
||||
const branches = path.join(storagePath, 'branches');
|
||||
const legacyOnly = path.join(branches, 'legacy-only');
|
||||
const converged = path.join(branches, 'converged');
|
||||
const stalePrimary = path.join(branches, 'stale-primary');
|
||||
for (const dir of [legacyOnly, converged, stalePrimary]) {
|
||||
const primaryAuthoritative = path.join(branches, 'primary-authoritative');
|
||||
for (const dir of [legacyOnly, converged, primaryAuthoritative]) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
|
|
@ -105,12 +120,12 @@ describe('reconcileMetadataFiles', () => {
|
|||
await saveMeta(converged, convergedMeta); // writes both, already in sync
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(stalePrimary, 'gitnexus.json'),
|
||||
JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'sp-stale')),
|
||||
path.join(primaryAuthoritative, 'gitnexus.json'),
|
||||
JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'primary-commit')),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stalePrimary, 'meta.json'),
|
||||
JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'sp-fresh')),
|
||||
path.join(primaryAuthoritative, 'meta.json'),
|
||||
JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'legacy-newer-but-not-authoritative')),
|
||||
);
|
||||
|
||||
// Flat slot: nothing — stays empty and untouched.
|
||||
|
|
@ -120,11 +135,11 @@ describe('reconcileMetadataFiles', () => {
|
|||
lastCommit: 'lo-commit',
|
||||
});
|
||||
await expect(readJson(converged, 'gitnexus.json')).resolves.toEqual(convergedMeta);
|
||||
await expect(readJson(stalePrimary, 'gitnexus.json')).resolves.toMatchObject({
|
||||
lastCommit: 'sp-fresh',
|
||||
await expect(readJson(primaryAuthoritative, 'gitnexus.json')).resolves.toMatchObject({
|
||||
lastCommit: 'primary-commit',
|
||||
});
|
||||
await expect(readJson(stalePrimary, 'meta.json')).resolves.toMatchObject({
|
||||
lastCommit: 'sp-fresh',
|
||||
await expect(readJson(primaryAuthoritative, 'meta.json')).resolves.toMatchObject({
|
||||
lastCommit: 'primary-commit',
|
||||
});
|
||||
// Flat slot stayed empty (reconcile fabricates nothing).
|
||||
await expect(fs.access(path.join(storagePath, 'gitnexus.json'))).rejects.toThrow();
|
||||
|
|
@ -149,7 +164,7 @@ describe('reconcileMetadataFiles', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('both files corrupt: no throw, no fabricated content, a warning per corrupt file', async () => {
|
||||
it('both files corrupt: no throw, no fabricated content, one primary warning', async () => {
|
||||
await fs.writeFile(path.join(storagePath, 'gitnexus.json'), '{ nope');
|
||||
await fs.writeFile(path.join(storagePath, 'meta.json'), 'also nope {{{');
|
||||
|
||||
|
|
@ -167,9 +182,7 @@ describe('reconcileMetadataFiles', () => {
|
|||
await expect(fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8')).resolves.toBe(
|
||||
'also nope {{{',
|
||||
);
|
||||
expect(
|
||||
cap.records().filter((r) => r.level === 40 && String(r.msg ?? '').includes('unreadable')),
|
||||
).toHaveLength(2);
|
||||
expect(cap.records().filter((r) => r.level === 40)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fresh directory (neither file) is a silent no-op', async () => {
|
||||
|
|
@ -219,6 +232,46 @@ describe('reconcileMetadataFiles', () => {
|
|||
|
||||
await expect(loadMeta(storagePath)).resolves.toEqual(legacy);
|
||||
});
|
||||
|
||||
it('does not overwrite a corrupt primary with otherwise valid legacy metadata', async () => {
|
||||
await fs.writeFile(path.join(storagePath, 'gitnexus.json'), '{ invalid primary');
|
||||
await fs.writeFile(
|
||||
path.join(storagePath, 'meta.json'),
|
||||
JSON.stringify(metaAt('2026-06-01T00:00:00.000Z', 'legacy-commit')),
|
||||
);
|
||||
|
||||
await expect(reconcileMetadataFiles(tmpRepo.dbPath)).resolves.toBe(false);
|
||||
await expect(fs.readFile(path.join(storagePath, 'gitnexus.json'), 'utf-8')).resolves.toBe(
|
||||
'{ invalid primary',
|
||||
);
|
||||
await expect(readJson(storagePath, 'meta.json')).resolves.toMatchObject({
|
||||
lastCommit: 'legacy-commit',
|
||||
});
|
||||
});
|
||||
|
||||
it("uses analyze's validated storage path instead of resolving it again", async () => {
|
||||
const externalRoot = path.join(tmpRepo.dbPath, 'external-storage');
|
||||
const externalStoragePath = storagePathFromRoot(externalRoot, tmpRepo.dbPath);
|
||||
const expected = metaAt('2026-06-01T00:00:00.000Z', 'validated-path');
|
||||
const redirected = metaAt('2026-06-01T00:00:00.000Z', 'redirected-path');
|
||||
const previous = process.env[STORAGE_ROOT_ENV];
|
||||
await fs.mkdir(externalStoragePath, { recursive: true });
|
||||
await fs.writeFile(path.join(storagePath, 'meta.json'), JSON.stringify(expected));
|
||||
await fs.writeFile(path.join(externalStoragePath, 'meta.json'), JSON.stringify(redirected));
|
||||
process.env[STORAGE_ROOT_ENV] = externalRoot;
|
||||
|
||||
try {
|
||||
await expect(reconcileMetadataFiles(tmpRepo.dbPath, storagePath)).resolves.toBe(true);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[STORAGE_ROOT_ENV];
|
||||
else process.env[STORAGE_ROOT_ENV] = previous;
|
||||
}
|
||||
|
||||
await expect(readJson(storagePath, 'gitnexus.json')).resolves.toMatchObject({
|
||||
lastCommit: 'validated-path',
|
||||
});
|
||||
await expect(fs.access(path.join(externalStoragePath, 'gitnexus.json'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── analyze entry point: a pre-rename repo ends with both files ─────────
|
||||
|
|
@ -282,7 +335,12 @@ describe('runFullAnalysis metadata reconciliation (mocked pipeline)', () => {
|
|||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(storagePath, 'meta.json'),
|
||||
JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'pre-rename-commit')),
|
||||
JSON.stringify(
|
||||
metaAt('2026-01-01T00:00:00.000Z', 'pre-rename-commit', {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
storagePath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
|
|
|
|||
|
|
@ -214,13 +214,11 @@ describe('readRegistryStrict', () => {
|
|||
await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt');
|
||||
});
|
||||
|
||||
it('accepts a row whose unused `path` is blank, and syncs the repo it names', async () => {
|
||||
// The counter-case that fixes the width of the rule. `path` is not what
|
||||
// identifies a repo, so tightening it too would trade this fail-open for a
|
||||
// fail-shut: one blank `path` anywhere in the MACHINE-WIDE registry would
|
||||
// reject the whole file and break every group sync on the machine,
|
||||
// including groups whose repos all resolve. Same principle as indexedAt /
|
||||
// lastCommit — require only what the resolution path depends on.
|
||||
it('accepts a legacy row whose `path` is blank but reports its group member unreadable', async () => {
|
||||
// Strict registry parsing remains backward compatible with this legacy
|
||||
// shape. Group loading now needs the source path for storage ownership,
|
||||
// so only this member degrades to unreadable instead of invalidating the
|
||||
// machine-wide registry for otherwise healthy consumers.
|
||||
const row = { ...resolvableRow(), path: ' ' };
|
||||
await fs.writeFile(registryPath, JSON.stringify([row]));
|
||||
|
||||
|
|
@ -230,14 +228,10 @@ describe('readRegistryStrict', () => {
|
|||
skipWrite: true,
|
||||
});
|
||||
|
||||
// Resolved: not reported missing, and the snapshot carries THIS row's
|
||||
// registry metadata, which only a successful name match could supply.
|
||||
// The registry name still resolves, but ownership cannot be established.
|
||||
expect(result.missingRepos).toEqual([]);
|
||||
expect(result.unreadableRepos).toEqual([]);
|
||||
expect(result.repoSnapshots['app/backend']).toEqual({
|
||||
indexedAt: '2026-01-01T00:00:00.000Z',
|
||||
lastCommit: 'abc123',
|
||||
});
|
||||
expect(result.unreadableRepos).toEqual(['app/backend']);
|
||||
expect(result.repoSnapshots['app/backend']).toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const storageResolverCtx = vi.hoisted(() => ({
|
||||
inspectRegisteredStorage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/storage/storage-resolver.js')>()),
|
||||
inspectRegisteredStorage: storageResolverCtx.inspectRegisteredStorage,
|
||||
}));
|
||||
|
||||
import {
|
||||
listRegisteredRepos,
|
||||
readRegistry,
|
||||
registerRepo,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
describe('listRegisteredRepos validation persistence', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedGitnexusHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-registry-atomic-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-registry-atomic-repo-a-');
|
||||
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
storageResolverCtx.inspectRegisteredStorage.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
});
|
||||
|
||||
it('does not prune a concurrent re-registration into a different storage slot', async () => {
|
||||
const repoPath = tmpRepo.dbPath;
|
||||
const staleStoragePath = path.join(tmpHome.dbPath, 'stale-slot');
|
||||
const replacementStoragePath = path.join(tmpHome.dbPath, 'replacement-slot');
|
||||
const registryPath = path.join(tmpHome.dbPath, 'registry.json');
|
||||
const meta: RepoMeta = {
|
||||
repoPath,
|
||||
storagePath: replacementStoragePath,
|
||||
lastCommit: 'abc1234',
|
||||
indexedAt: '2026-09-05T00:00:00.000Z',
|
||||
stats: { files: 1, nodes: 1 },
|
||||
};
|
||||
await fs.writeFile(
|
||||
registryPath,
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'repo',
|
||||
path: repoPath,
|
||||
storagePath: staleStoragePath,
|
||||
indexedAt: meta.indexedAt,
|
||||
lastCommit: meta.lastCommit,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
let inspectionStarted!: () => void;
|
||||
let releaseInspection!: () => void;
|
||||
const inspectionStartedPromise = new Promise<void>((resolve) => {
|
||||
inspectionStarted = resolve;
|
||||
});
|
||||
const releaseInspectionPromise = new Promise<void>((resolve) => {
|
||||
releaseInspection = resolve;
|
||||
});
|
||||
storageResolverCtx.inspectRegisteredStorage.mockImplementationOnce(async (entry: any) => {
|
||||
inspectionStarted();
|
||||
await releaseInspectionPromise;
|
||||
return {
|
||||
repoPath: entry.path,
|
||||
storagePath: entry.storagePath,
|
||||
state: 'missing',
|
||||
hasCodeIndexDB: false,
|
||||
};
|
||||
});
|
||||
|
||||
const validatingRead = listRegisteredRepos({ validate: true });
|
||||
await inspectionStartedPromise;
|
||||
await registerRepo(repoPath, meta, { name: 'repo', storagePath: replacementStoragePath });
|
||||
releaseInspection();
|
||||
|
||||
await expect(validatingRead).resolves.toEqual([]);
|
||||
await expect(readRegistry()).resolves.toMatchObject([
|
||||
{ path: repoPath, storagePath: replacementStoragePath },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,16 @@ const mockMeta: any = {
|
|||
stats: { files: 1, nodes: 1 },
|
||||
};
|
||||
|
||||
const materializeLegacyIndex = async (repoPath: string): Promise<void> => {
|
||||
const storagePath = path.join(repoPath, '.gitnexus');
|
||||
await fs.mkdir(path.join(storagePath, 'lbug'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(storagePath, 'meta.json'),
|
||||
JSON.stringify({ ...mockMeta, repoPath }),
|
||||
'utf8',
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the persisted registry straight off disk so tests can assert what was
|
||||
* actually written — the original bug was about *persisting* the wrong list,
|
||||
|
|
@ -48,6 +58,7 @@ describe('listRegisteredRepos({ validate: true }) — transient error safety (PR
|
|||
tmpRepo = await createTempDir('gitnexus-transient-repo-');
|
||||
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
await materializeLegacyIndex(tmpRepo.dbPath);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -61,17 +72,11 @@ describe('listRegisteredRepos({ validate: true }) — transient error safety (PR
|
|||
it('ENOENT prunes the entry (index genuinely removed)', async () => {
|
||||
await registerRepo(tmpRepo.dbPath, mockMeta);
|
||||
|
||||
// registerRepo writes the registry entry but doesn't create .gitnexus/meta.json.
|
||||
// That's done by analyze. Create it so the entry passes validation initially.
|
||||
const metaPath = path.join(tmpRepo.dbPath, '.gitnexus', 'meta.json');
|
||||
await fs.mkdir(path.dirname(metaPath), { recursive: true });
|
||||
await fs.writeFile(metaPath, JSON.stringify(mockMeta));
|
||||
|
||||
const before = await listRegisteredRepos({ validate: true });
|
||||
expect(before).toHaveLength(1);
|
||||
|
||||
// Delete meta.json to simulate genuinely removed index
|
||||
await fs.unlink(metaPath);
|
||||
await fs.rm(path.join(tmpRepo.dbPath, '.gitnexus'), { recursive: true, force: true });
|
||||
|
||||
const after = await listRegisteredRepos({ validate: true });
|
||||
expect(after).toHaveLength(0);
|
||||
|
|
@ -270,21 +275,22 @@ describe('listRegisteredRepos({ validate: true }) — transient error safety (PR
|
|||
try {
|
||||
const nameA = await registerRepo(tmpRepo.dbPath, mockMeta);
|
||||
const nameB = await registerRepo(tmpRepoB.dbPath, mockMeta);
|
||||
await materializeLegacyIndex(tmpRepoB.dbPath);
|
||||
|
||||
const before = await listRegisteredRepos();
|
||||
expect(before).toHaveLength(2);
|
||||
|
||||
// Repo A is genuinely gone: drop both metadata files so inspection
|
||||
// cannot treat a leftover `gitnexus.json` from `registerRepo` as owned.
|
||||
await fs.rm(path.join(tmpRepo.dbPath, '.gitnexus', 'gitnexus.json'), { force: true });
|
||||
await fs.rm(path.join(tmpRepo.dbPath, '.gitnexus', 'meta.json'), { force: true });
|
||||
|
||||
// Branch on each repo's distinct temp-dir segment — both meta.json paths
|
||||
// contain `.gitnexus`/`meta.json`, so matching those shared substrings
|
||||
// alone would mis-route. repo A → ENOENT (prune), repo B → EIO (keep).
|
||||
// alone would mis-route. repo B → EIO (keep).
|
||||
const originalAccess = fs.access;
|
||||
vi.spyOn(fs, 'access').mockImplementation(async (p, mode) => {
|
||||
const pStr = typeof p === 'string' ? p : p.toString();
|
||||
if (pStr.includes('meta.json') && pStr.includes(tmpRepo.dbPath)) {
|
||||
const err = new Error('no such file') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
if (pStr.includes('meta.json') && pStr.includes(tmpRepoB.dbPath)) {
|
||||
const err = new Error('input/output error') as NodeJS.ErrnoException;
|
||||
err.code = 'EIO';
|
||||
|
|
@ -309,4 +315,36 @@ describe('listRegisteredRepos({ validate: true }) — transient error safety (PR
|
|||
await tmpRepoB.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns only owned entries with a LadybugDB directory', async () => {
|
||||
const foreignRepo = await createTempDir('gitnexus-validation-foreign-');
|
||||
const missingDbRepo = await createTempDir('gitnexus-validation-no-db-');
|
||||
try {
|
||||
await registerRepo(tmpRepo.dbPath, mockMeta, { name: 'owned' });
|
||||
await registerRepo(foreignRepo.dbPath, mockMeta, { name: 'foreign' });
|
||||
await registerRepo(missingDbRepo.dbPath, mockMeta, { name: 'no-db' });
|
||||
|
||||
const foreignStorage = path.join(foreignRepo.dbPath, '.gitnexus');
|
||||
await fs.mkdir(path.join(foreignStorage, 'lbug'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(foreignStorage, 'meta.json'),
|
||||
JSON.stringify({ ...mockMeta, repoPath: tmpRepo.dbPath }),
|
||||
'utf8',
|
||||
);
|
||||
await fs.mkdir(path.join(missingDbRepo.dbPath, '.gitnexus'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(missingDbRepo.dbPath, '.gitnexus', 'meta.json'),
|
||||
JSON.stringify({ ...mockMeta, repoPath: missingDbRepo.dbPath }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const entries = await listRegisteredRepos({ validate: true });
|
||||
|
||||
expect(entries.map((entry) => entry.name)).toEqual(['owned']);
|
||||
expect(await readRegistryFromDisk()).toHaveLength(3);
|
||||
} finally {
|
||||
await foreignRepo.cleanup();
|
||||
await missingDbRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import {
|
|||
resolveBranchPlacement,
|
||||
saveMeta,
|
||||
loadMeta,
|
||||
hasIndex,
|
||||
loadRepo,
|
||||
findRepo,
|
||||
reconcileMetadataFiles,
|
||||
AnalysisNotFinalizedError,
|
||||
INDEX_METADATA_FILE,
|
||||
|
|
@ -102,6 +105,61 @@ describe('getStoragePaths', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ─── Index ownership lookup ─────────────────────────────────────────
|
||||
|
||||
describe('index ownership lookup', () => {
|
||||
let workspace: Awaited<ReturnType<typeof createTempDir>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
workspace = await createTempDir('gitnexus-index-ownership-');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await workspace.cleanup();
|
||||
});
|
||||
|
||||
it('does not resolve an indexed parent directory as a child Git repository', async () => {
|
||||
const childRepo = path.join(workspace.dbPath, 'child-repo');
|
||||
const childSource = path.join(childRepo, 'src');
|
||||
const parentStorage = getStoragePaths(workspace.dbPath).storagePath;
|
||||
await fs.mkdir(childSource, { recursive: true });
|
||||
execSync('git init', { cwd: childRepo, stdio: 'ignore' });
|
||||
await saveMeta(parentStorage, {
|
||||
repoPath: workspace.dbPath,
|
||||
storagePath: parentStorage,
|
||||
lastCommit: '',
|
||||
indexedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(findRepo(childSource)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects foreign metadata, while preserving metadata-only owned slots for clean', async () => {
|
||||
const repoPath = path.join(workspace.dbPath, 'repo');
|
||||
const storagePath = getStoragePaths(repoPath).storagePath;
|
||||
await fs.mkdir(repoPath, { recursive: true });
|
||||
await saveMeta(storagePath, {
|
||||
repoPath: path.join(workspace.dbPath, 'other-repo'),
|
||||
storagePath,
|
||||
lastCommit: '',
|
||||
indexedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(loadRepo(repoPath)).resolves.toBeNull();
|
||||
await expect(hasIndex(repoPath)).resolves.toBe(false);
|
||||
|
||||
await saveMeta(storagePath, {
|
||||
repoPath,
|
||||
storagePath,
|
||||
lastCommit: '',
|
||||
indexedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(loadRepo(repoPath)).resolves.toMatchObject({ repoPath, storagePath });
|
||||
await expect(hasIndex(repoPath)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── branchSlug (#2106) ──────────────────────────────────────────────
|
||||
|
||||
describe('branchSlug (#2106)', () => {
|
||||
|
|
@ -411,10 +469,7 @@ describe('reconcileMetadataFiles stale-shadow regression', () => {
|
|||
indexedAt,
|
||||
});
|
||||
|
||||
it('a FRESHER legacy meta.json wins over a stale gitnexus.json (both rewritten)', async () => {
|
||||
// The reproduced PR #2363 bug: an older binary re-analyzes and writes only
|
||||
// meta.json AFTER gitnexus.json exists; the one-shot existence gate then
|
||||
// ignored the fresher state forever (stale lastCommit won, dirty flag lost).
|
||||
it('a valid gitnexus.json wins over a newer legacy meta.json (both rewritten)', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(storagePath, 'gitnexus.json'),
|
||||
JSON.stringify(metaAt('2026-01-01T00:00:00.000Z', 'stale-commit')),
|
||||
|
|
@ -432,8 +487,8 @@ describe('reconcileMetadataFiles stale-shadow regression', () => {
|
|||
const legacy = JSON.parse(
|
||||
await fs.readFile(path.join(storagePath, 'meta.json'), 'utf-8'),
|
||||
) as RepoMeta;
|
||||
expect(primary.lastCommit).toBe('fresh-commit');
|
||||
expect(legacy.lastCommit).toBe('fresh-commit');
|
||||
expect(primary.lastCommit).toBe('stale-commit');
|
||||
expect(legacy.lastCommit).toBe('stale-commit');
|
||||
});
|
||||
|
||||
it('bootstraps gitnexus.json from a legacy-only directory (pre-rename repo)', async () => {
|
||||
|
|
@ -544,6 +599,15 @@ describe('ensureGitNexusIgnored (#1233)', () => {
|
|||
).resolves.toBe('*\n');
|
||||
});
|
||||
|
||||
it('writes the ignore file to an explicitly selected external storage slot', async () => {
|
||||
const storagePath = path.join(tmpRepo.dbPath, 'central-indexes', 'repo-slot');
|
||||
|
||||
await ensureGitNexusIgnored(tmpRepo.dbPath, storagePath);
|
||||
|
||||
await expect(fs.readFile(path.join(storagePath, '.gitignore'), 'utf-8')).resolves.toBe('*\n');
|
||||
await expect(fs.access(path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not create or modify the repository root .gitignore', async () => {
|
||||
const rootGitignorePath = path.join(tmpRepo.dbPath, '.gitignore');
|
||||
await fs.writeFile(rootGitignorePath, 'node_modules/\n');
|
||||
|
|
@ -841,6 +905,26 @@ describe('registerRepo name override + collision guard (#829)', () => {
|
|||
expect(entries[0].name).not.toBe(path.basename(tmpRepoA.dbPath));
|
||||
});
|
||||
|
||||
it('uses an explicitly selected storage slot only when metadata binds to it', async () => {
|
||||
const storagePath = path.join(tmpHome.dbPath, 'central', 'repo-slot');
|
||||
const boundMeta = { ...meta, repoPath: tmpRepoA.dbPath, storagePath };
|
||||
|
||||
await registerRepo(tmpRepoA.dbPath, boundMeta, { storagePath });
|
||||
expect((await listRegisteredRepos())[0].storagePath).toBe(storagePath);
|
||||
|
||||
await expect(
|
||||
registerRepo(
|
||||
tmpRepoA.dbPath,
|
||||
{ ...boundMeta, storagePath: `${storagePath}-other` },
|
||||
{ storagePath },
|
||||
),
|
||||
).rejects.toThrow('metadata storagePath does not match');
|
||||
|
||||
await expect(
|
||||
registerRepo(tmpRepoA.dbPath, { ...meta, repoPath: tmpRepoA.dbPath }, { storagePath }),
|
||||
).rejects.toThrow('external storage metadata must bind storagePath');
|
||||
});
|
||||
|
||||
it('preserves every concurrent registration', async () => {
|
||||
const repoPaths = Array.from({ length: 12 }, (_, index) =>
|
||||
path.join(tmpRepoA.dbPath, `concurrent-${index}`),
|
||||
|
|
@ -1262,6 +1346,10 @@ describe('registerRepo branch nesting (#2106)', () => {
|
|||
// real directory to remove.
|
||||
const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222'));
|
||||
await saveMeta(getStoragePaths(tmpRepo.dbPath).storagePath, {
|
||||
...metaFor('main', 'aaa1111'),
|
||||
repoPath: tmpRepo.dbPath,
|
||||
});
|
||||
|
||||
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
||||
|
||||
|
|
@ -1275,6 +1363,10 @@ describe('registerRepo branch nesting (#2106)', () => {
|
|||
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/y', 'ccc3333'), { branch: 'feature/y' });
|
||||
await saveMeta(getStoragePaths(tmpRepo.dbPath).storagePath, {
|
||||
...metaFor('main', 'aaa1111'),
|
||||
repoPath: tmpRepo.dbPath,
|
||||
});
|
||||
|
||||
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
||||
|
||||
|
|
@ -1297,6 +1389,28 @@ describe('registerRepo branch nesting (#2106)', () => {
|
|||
await expect(fs.access(path.dirname(metaPath))).resolves.toBeUndefined(); // dir survives
|
||||
});
|
||||
|
||||
it('does not delete an explicitly selected external branch slot after ownership changes', async () => {
|
||||
const storagePath = path.join(tmpHome.dbPath, 'central-indexes', 'repo-slot');
|
||||
const ownedMeta = {
|
||||
...metaFor('main', 'aaa1111'),
|
||||
repoPath: tmpRepo.dbPath,
|
||||
storagePath,
|
||||
};
|
||||
await saveMeta(storagePath, ownedMeta);
|
||||
await registerRepo(tmpRepo.dbPath, ownedMeta, { storagePath });
|
||||
const shadowDir = path.dirname(
|
||||
getStoragePaths(tmpRepo.dbPath, 'feature/x', storagePath).metaPath,
|
||||
);
|
||||
await fs.mkdir(shadowDir, { recursive: true });
|
||||
|
||||
await saveMeta(storagePath, { ...ownedMeta, repoPath: tmpHome.dbPath });
|
||||
|
||||
await expect(adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x', storagePath)).rejects.toThrow(
|
||||
'storage is not owned',
|
||||
);
|
||||
await expect(fs.access(shadowDir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// ─── re-read-before-write merge (#2106 R9) ──────────────────────────
|
||||
|
||||
it('a branch run preserves the freshest top-level fields (alias survives)', async () => {
|
||||
|
|
@ -1763,9 +1877,8 @@ describe('resolveRegistryEntry backward-compat with non-canonical stored paths (
|
|||
// Guard rail against destroying more than the `.gitnexus/` subfolder.
|
||||
// `~/.gitnexus/registry.json` is user-writable plain text, so a
|
||||
// corrupted or hand-edited entry could put storagePath anywhere.
|
||||
// These tests use synthetic `RegistryEntry` fixtures (no disk I/O)
|
||||
// because the guard is a pure string check — it must not depend on
|
||||
// the paths existing.
|
||||
// Repository-local paths remain pure string checks. External slots require
|
||||
// metadata ownership proof before they may be recursively deleted.
|
||||
|
||||
describe('assertSafeStoragePath (#1003)', () => {
|
||||
const prefix = process.platform === 'win32' ? 'D:\\' : '/tmp/';
|
||||
|
|
@ -1777,61 +1890,76 @@ describe('assertSafeStoragePath (#1003)', () => {
|
|||
lastCommit: 'deadbee',
|
||||
};
|
||||
|
||||
it('accepts the canonical <repo>/.gitnexus storage path', () => {
|
||||
it('accepts the canonical <repo>/.gitnexus storage path', async () => {
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
storagePath: path.join(repoPath, '.gitnexus'),
|
||||
};
|
||||
expect(() => assertSafeStoragePath(entry)).not.toThrow();
|
||||
await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects when storagePath equals the repo path itself (would delete the code)', () => {
|
||||
it('rejects when storagePath equals the repo path itself (would delete the code)', async () => {
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
storagePath: repoPath, // catastrophic: rm the working tree
|
||||
};
|
||||
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
|
||||
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
|
||||
});
|
||||
|
||||
it('rejects when storagePath is a parent of the repo path', () => {
|
||||
it('rejects when storagePath is a parent of the repo path', async () => {
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
storagePath: path.dirname(repoPath), // also catastrophic
|
||||
};
|
||||
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
|
||||
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
|
||||
});
|
||||
|
||||
it('rejects when storagePath is empty (path.resolve falls back to cwd)', () => {
|
||||
it('rejects when storagePath is empty (path.resolve falls back to cwd)', async () => {
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
storagePath: '', // path.resolve('') === process.cwd() — would rm cwd
|
||||
};
|
||||
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
|
||||
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
|
||||
});
|
||||
|
||||
it('rejects when storagePath points somewhere totally unrelated', () => {
|
||||
it.each([null, 42])('rejects malformed storagePath %p with the safety error', async (value) => {
|
||||
const entry = {
|
||||
...base,
|
||||
storagePath: value,
|
||||
} as unknown as RegistryEntry;
|
||||
|
||||
try {
|
||||
await assertSafeStoragePath(entry);
|
||||
expect.unreachable('expected malformed storagePath to be rejected');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(UnsafeStoragePathError);
|
||||
expect((error as UnsafeStoragePathError).actualStoragePath).toBe(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when storagePath points somewhere totally unrelated', async () => {
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
storagePath: `${prefix}some${path.sep}other${path.sep}place`,
|
||||
};
|
||||
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
|
||||
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
|
||||
});
|
||||
|
||||
it('rejects when storagePath is a sibling .gitnexus (right basename, wrong parent)', () => {
|
||||
it('rejects when storagePath is a sibling .gitnexus (right basename, wrong parent)', async () => {
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
storagePath: path.join(`${prefix}different${path.sep}repo`, '.gitnexus'),
|
||||
};
|
||||
expect(() => assertSafeStoragePath(entry)).toThrow(UnsafeStoragePathError);
|
||||
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
|
||||
});
|
||||
|
||||
it('UnsafeStoragePathError carries the original entry + expected + actual paths', () => {
|
||||
it('UnsafeStoragePathError carries the original entry + expected + actual paths', async () => {
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
storagePath: `${prefix}evil${path.sep}path`,
|
||||
};
|
||||
try {
|
||||
assertSafeStoragePath(entry);
|
||||
await assertSafeStoragePath(entry);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(UnsafeStoragePathError);
|
||||
const err = e as UnsafeStoragePathError;
|
||||
|
|
@ -1846,14 +1974,56 @@ describe('assertSafeStoragePath (#1003)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('Windows: storagePath match is case-insensitive to match register/unregister semantics', () => {
|
||||
it('Windows: storagePath match is case-insensitive to match register/unregister semantics', async () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
storagePath: path.join(repoPath.toUpperCase(), '.GITNEXUS'),
|
||||
};
|
||||
// Should accept because Windows paths are case-insensitive.
|
||||
expect(() => assertSafeStoragePath(entry)).not.toThrow();
|
||||
await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts an external slot only when its metadata binds it to the registry entry', async () => {
|
||||
const repo = await createTempDir('gitnexus-external-repo-');
|
||||
const storage = await createTempDir('gitnexus-external-storage-');
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
path: repo.dbPath,
|
||||
storagePath: storage.dbPath,
|
||||
};
|
||||
try {
|
||||
await saveMeta(storage.dbPath, {
|
||||
repoPath: repo.dbPath,
|
||||
storagePath: storage.dbPath,
|
||||
lastCommit: 'deadbee',
|
||||
indexedAt: new Date(0).toISOString(),
|
||||
});
|
||||
await expect(assertSafeStoragePath(entry)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await Promise.all([repo.cleanup(), storage.cleanup()]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an external slot whose metadata belongs to another checkout', async () => {
|
||||
const repo = await createTempDir('gitnexus-external-repo-');
|
||||
const storage = await createTempDir('gitnexus-external-storage-');
|
||||
const entry: RegistryEntry = {
|
||||
...base,
|
||||
path: repo.dbPath,
|
||||
storagePath: storage.dbPath,
|
||||
};
|
||||
try {
|
||||
await saveMeta(storage.dbPath, {
|
||||
repoPath: path.join(repo.dbPath, 'other'),
|
||||
storagePath: storage.dbPath,
|
||||
lastCommit: 'deadbee',
|
||||
indexedAt: new Date(0).toISOString(),
|
||||
});
|
||||
await expect(assertSafeStoragePath(entry)).rejects.toBeInstanceOf(UnsafeStoragePathError);
|
||||
} finally {
|
||||
await Promise.all([repo.cleanup(), storage.cleanup()]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import type { StalenessInfo } from '../../src/core/git-staleness.js';
|
|||
import type { RegistryEntry } from '../../src/storage/repo-manager.js';
|
||||
import type { RepoMeta } from '../../src/storage/repo-meta.js';
|
||||
|
||||
const FULL_SOURCE = { contentRetention: 'full' as const, sourceAvailable: true };
|
||||
|
||||
const FRESH: StalenessInfo = { isStale: false, commitsBehind: 0 };
|
||||
const BEHIND: StalenessInfo = {
|
||||
isStale: true,
|
||||
|
|
@ -92,6 +94,7 @@ describe('projectRepoListEntry — GET /api/repos', () => {
|
|||
branches: [{ branch: 'test', indexedAt: '2026-09-08T11:00:00.000Z', lastCommit: 'abc123' }],
|
||||
}) as RegistryEntry,
|
||||
FRESH,
|
||||
FULL_SOURCE,
|
||||
);
|
||||
expect(out.branch).toBe('master');
|
||||
expect(out.branches).toHaveLength(1);
|
||||
|
|
@ -101,10 +104,11 @@ describe('projectRepoListEntry — GET /api/repos', () => {
|
|||
// A pinned analyze registers under its clone-directory name. Without
|
||||
// `branch`, these two are only tellable apart by parsing that slug — a
|
||||
// layout detail that is trimmed for long refs and absent for path entries.
|
||||
const primary = projectRepoListEntry(entry({ branch: 'master' }), FRESH);
|
||||
const primary = projectRepoListEntry(entry({ branch: 'master' }), FRESH, FULL_SOURCE);
|
||||
const pinned = projectRepoListEntry(
|
||||
entry({ name: 'Hello-World__test-9f86d081', branch: 'test' }),
|
||||
FRESH,
|
||||
FULL_SOURCE,
|
||||
);
|
||||
expect([primary.branch, pinned.branch]).toEqual(['master', 'test']);
|
||||
});
|
||||
|
|
@ -112,48 +116,62 @@ describe('projectRepoListEntry — GET /api/repos', () => {
|
|||
it('keeps every field the route returned before, unchanged', () => {
|
||||
// Additive only: an existing client must not notice this change.
|
||||
const e = entry();
|
||||
const out = projectRepoListEntry(e, FRESH);
|
||||
const out = projectRepoListEntry(e, FRESH, FULL_SOURCE);
|
||||
expect(out).toMatchObject({
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
repoPath: e.path,
|
||||
storagePath: e.storagePath,
|
||||
indexedAt: e.indexedAt,
|
||||
lastCommit: e.lastCommit,
|
||||
stats: e.stats,
|
||||
contentRetention: 'full',
|
||||
sourceAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves branch undefined for a legacy entry that never recorded one', () => {
|
||||
const out = projectRepoListEntry(entry(), FRESH);
|
||||
const out = projectRepoListEntry(entry(), FRESH, FULL_SOURCE);
|
||||
expect(out.branch).toBeUndefined();
|
||||
expect(out.branches).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries staleness through for a behind index', () => {
|
||||
expect(projectRepoListEntry(entry(), BEHIND).staleness).toEqual({
|
||||
expect(projectRepoListEntry(entry(), BEHIND, FULL_SOURCE).staleness).toEqual({
|
||||
status: 'behind',
|
||||
commitsBehind: 3,
|
||||
hint: BEHIND.hint,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes storagePath, contentRetention, and sourceAvailable', () => {
|
||||
const e = entry();
|
||||
const out = projectRepoListEntry(e, FRESH, {
|
||||
contentRetention: 'none',
|
||||
sourceAvailable: false,
|
||||
});
|
||||
expect(out.storagePath).toBe(e.storagePath);
|
||||
expect(out.contentRetention).toBe('none');
|
||||
expect(out.sourceAvailable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectRepoDetail — GET /api/repo', () => {
|
||||
it('returns lastCommit and branch, which the route used to drop', () => {
|
||||
const out = projectRepoDetail(entry({ branch: 'master' }), null, FRESH);
|
||||
const out = projectRepoDetail(entry({ branch: 'master' }), null, FRESH, FULL_SOURCE);
|
||||
expect(out.lastCommit).toBe(entry().lastCommit);
|
||||
expect(out.branch).toBe('master');
|
||||
});
|
||||
|
||||
it('prefers on-disk metadata over the registry entry, as indexedAt already did', () => {
|
||||
const out = projectRepoDetail(entry({ branch: 'master' }), meta(), FRESH);
|
||||
const out = projectRepoDetail(entry({ branch: 'master' }), meta(), FRESH, FULL_SOURCE);
|
||||
expect(out.indexedAt).toBe('2026-09-08T12:00:00.000Z');
|
||||
expect(out.lastCommit).toBe('ffffffffffffffffffffffffffffffffffffffff');
|
||||
expect(out.branch).toBe('develop');
|
||||
});
|
||||
|
||||
it('falls back to the entry when metadata cannot be read', () => {
|
||||
const out = projectRepoDetail(entry({ branch: 'master' }), undefined, FRESH);
|
||||
const out = projectRepoDetail(entry({ branch: 'master' }), undefined, FRESH, FULL_SOURCE);
|
||||
expect(out.indexedAt).toBe(entry().indexedAt);
|
||||
expect(out.lastCommit).toBe(entry().lastCommit);
|
||||
expect(out.branch).toBe('master');
|
||||
|
|
@ -161,7 +179,22 @@ describe('projectRepoDetail — GET /api/repo', () => {
|
|||
|
||||
it('still returns an empty stats object rather than undefined', () => {
|
||||
// Pre-existing contract: the route returned `{}` when neither side had stats.
|
||||
expect(projectRepoDetail(entry({ stats: undefined }), null, FRESH).stats).toEqual({});
|
||||
expect(projectRepoDetail(entry({ stats: undefined }), null, FRESH, FULL_SOURCE).stats).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes storagePath, contentRetention, and sourceAvailable', () => {
|
||||
const e = entry();
|
||||
const out = projectRepoDetail(e, meta({ contentRetention: 'symbol' }), FRESH, {
|
||||
contentRetention: 'symbol',
|
||||
sourceAvailable: false,
|
||||
});
|
||||
expect(out).toMatchObject({
|
||||
storagePath: e.storagePath,
|
||||
contentRetention: 'symbol',
|
||||
sourceAvailable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -171,7 +204,7 @@ describe('resolveLastCommit', () => {
|
|||
// commits-behind from another — a freshness number for a different index.
|
||||
const e = entry();
|
||||
const m = meta();
|
||||
expect(resolveLastCommit(e, m)).toBe(projectRepoDetail(e, m, FRESH).lastCommit);
|
||||
expect(resolveLastCommit(e, m)).toBe(projectRepoDetail(e, m, FRESH, FULL_SOURCE).lastCommit);
|
||||
});
|
||||
|
||||
it('falls back to the registry entry with no metadata', () => {
|
||||
|
|
|
|||
|
|
@ -517,6 +517,9 @@ describe('context resource freshness after out-of-process analyze (#2438)', () =
|
|||
);
|
||||
expect(result).toContain('index:');
|
||||
expect(result).toContain('commit: "0123456789abcdef0123456789abcdef01234567"');
|
||||
expect(result).toContain('storage_path: "/tmp/test-repo/.gitnexus"');
|
||||
expect(result).toContain('content_retention: "full"');
|
||||
expect(result).toMatch(/source_available: (true|false)/);
|
||||
expect(result).toContain(`runner_identity: ${JSON.stringify(runnerIdentity)}`);
|
||||
expect(result).toContain('runner_identity_schema_status: "current"');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,20 +20,25 @@ type RepoManagerModule = typeof import('../../src/storage/repo-manager.js');
|
|||
const rmCtx = vi.hoisted(() => ({
|
||||
adoptMock: vi.fn(),
|
||||
saveMetaMock: vi.fn(),
|
||||
writableMock: vi.fn(),
|
||||
realAdopt: null as RepoManagerModule['adoptFlatBranchLabel'] | null,
|
||||
realSaveMeta: null as RepoManagerModule['saveMeta'] | null,
|
||||
realEnsureWritable: null as RepoManagerModule['ensureStoragePathWritable'] | null,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<RepoManagerModule>();
|
||||
rmCtx.realAdopt = actual.adoptFlatBranchLabel;
|
||||
rmCtx.realSaveMeta = actual.saveMeta;
|
||||
rmCtx.realEnsureWritable = actual.ensureStoragePathWritable;
|
||||
rmCtx.adoptMock.mockImplementation(actual.adoptFlatBranchLabel);
|
||||
rmCtx.saveMetaMock.mockImplementation(actual.saveMeta);
|
||||
rmCtx.writableMock.mockImplementation(actual.ensureStoragePathWritable);
|
||||
return {
|
||||
...actual,
|
||||
adoptFlatBranchLabel: rmCtx.adoptMock,
|
||||
saveMeta: rmCtx.saveMetaMock,
|
||||
ensureStoragePathWritable: rmCtx.writableMock,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -61,12 +66,17 @@ describe('fast-path restamp failure modes (#2364 F3)', () => {
|
|||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
rmCtx.adoptMock.mockReset();
|
||||
rmCtx.saveMetaMock.mockReset();
|
||||
rmCtx.writableMock.mockReset();
|
||||
rmCtx.adoptMock.mockImplementation(
|
||||
(...args: Parameters<RepoManagerModule['adoptFlatBranchLabel']>) => rmCtx.realAdopt!(...args),
|
||||
);
|
||||
rmCtx.saveMetaMock.mockImplementation((...args: Parameters<RepoManagerModule['saveMeta']>) =>
|
||||
rmCtx.realSaveMeta!(...args),
|
||||
);
|
||||
rmCtx.writableMock.mockImplementation(
|
||||
(...args: Parameters<RepoManagerModule['ensureStoragePathWritable']>) =>
|
||||
rmCtx.realEnsureWritable!(...args),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -170,4 +180,26 @@ describe('fast-path restamp failure modes (#2364 F3)', () => {
|
|||
expect(meta?.branch).toBe('main');
|
||||
},
|
||||
);
|
||||
|
||||
it('does not require writable storage for an already-up-to-date run', async () => {
|
||||
await seedFlippedWorkspace();
|
||||
rmCtx.writableMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('mock read-only storage'), { code: 'EROFS' }),
|
||||
);
|
||||
|
||||
const result = await runFullAnalysis(tmpRepo.dbPath, {}, {});
|
||||
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(rmCtx.writableMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still requires writable storage when an up-to-date checkout has content changes', async () => {
|
||||
const { flatStorage } = await seedFlippedWorkspace();
|
||||
await fs.writeFile(path.join(tmpRepo.dbPath, 'dirty.ts'), 'export const dirty = true;\n');
|
||||
const readOnly = Object.assign(new Error('mock read-only storage'), { code: 'EROFS' });
|
||||
rmCtx.writableMock.mockRejectedValueOnce(readOnly);
|
||||
|
||||
await expect(runFullAnalysis(tmpRepo.dbPath, {}, {})).rejects.toBe(readOnly);
|
||||
expect(rmCtx.writableMock).toHaveBeenCalledWith(flatStorage);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue