mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat: flat workspace index follows the checked-out branch (#2364)
Some checks failed
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Devcontainer Smoke / Build devcontainer image (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Some checks failed
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Devcontainer Smoke / Build devcontainer image (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* feat: flat workspace index follows the checked-out branch (#2354) A plain `gitnexus analyze` now always targets the flat workspace slot, updating it incrementally across branch switches instead of auto-routing non-owner branches into `branches/<slug>/` sub-indexes (disk bloat) or nagging with the primary-inversion "run gitnexus clean" warning. No new CLI flag or config key: the smart behavior is the default. - Placement: only explicit `--branch` consults resolveBranchPlacement; plain runs resolve to the flat slot, `meta.branch` becomes an informational "last analyzed branch" label restamped each run. - Fast path: a same-commit clean-tree branch flip restamps the label and registry entry (adoptFlatBranchLabel, no-op for unregistered repos). - Shadow cleanup: when the flat slot adopts a label that has a pinned sub-index, the now-unreachable `branches/<slug>/` dir and its registry summary are removed together. - MCP: applyBranchScope always falls back to the on-disk flat meta before throwing "not indexed", so long-lived servers resolve a freshly restamped workspace branch. - status: no more "current branch not indexed" dead end — falls through to the workspace index with an informational line and the usual commit-based staleness verdict. - Deleted primaryInversionWarning; explicit `--branch` pinning, the checkout-mismatch guard, detached-HEAD/CI behavior, and `clean --branch` are unchanged. Supersedes the flag-based approaches in #2358/#2359. Closes #2354. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(storage): check registry before deleting shadowed sub-index (#2364 review F2) adoptFlatBranchLabel ran the branches/<slug>/ rm before its own unregistered-repo no-op check, so a repo in the #2264 half-finalized state (up to date but unregistered) lost its pinned sub-index on a same-commit branch flip while the run still failed. The registry lookup now precedes the deletion, making the no-self-heal rule (#2264/#1169) cover disk as well as registry state. The 'never self-heals' unit test now materializes a sub-index dir and asserts it survives; the run-analyze #2354 fast-path test registers its repo under an isolated GITNEXUS_HOME (deletion is only legitimate for registered repos) with a new unregistered variant pinning dir survival. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(storage): keep branch summary when sub-index rm fails (#2364 review F4) The shadow-cleanup fs.rm swallowed every error while the registry summary was dropped unconditionally. On Windows an lbug held open by a live MCP server fails the rm with EBUSY/EPERM, and once the summary is gone 'clean --branch' can never target the leftover dir (it resolves solely via the recorded summary) — stranding the exact un-cleanable disk bloat adoptFlatBranchLabel exists to prevent. The summary is now dropped only when the directory is verifiably gone (post-rm existence check); on failure the summary is retained, a warning names the path and errno, and the informational branch label still restamps. Later adopts retry the rm. New repo-manager-rm-failure.test.ts uses the delegating fs/promises mock idiom (vi.spyOn cannot intercept ESM namespace exports). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): restamp fast path adopt-first and tolerate read-only storage (#2364 review F3) The fast-path label sync stamped meta before adoptFlatBranchLabel, so a crash or adopt failure between the two flipped the retry guard (existingMeta.branch !== branchLabel) and locked in the partial state: every subsequent same-commit run skipped the cleanup and branch-scoped queries kept routing to the stale pinned sub-index. The block also sat outside any try/catch, so a same-commit branch flip on a read-only .gitnexus mount (the documented Docker :ro workflow, #1549) failed a byte-for-byte-current analyze over a purely informational label sync. Adopt now runs first and saveMeta last — any partial failure leaves the guard true and the next run self-heals — and the whole sync is best-effort: read-only errors warn citing #1549, anything else warns and retries next run. Safe because the block only fires on a same-commit clean tree, where the flat DB content is byte-valid for both labels. isReadOnlyFilesystemError is now exported. New run-analyze-adopt-failure.test.ts covers retry-after-partial- failure, adopt-before-stamp ordering, and EROFS/EACCES/EPERM (gaps 4 and 7); a detached-HEAD fast-path pin lands in run-analyze.test.ts (gap 6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): make flat meta authoritative in applyBranchScope (#2364 review F1) applyBranchScope trusted two pieces of cached state before its flat- meta disk fallback, and the handle cache only refreshes on a resolve miss — never on a hit. Post-#2354 that stale window is the routine case: (i) the handle.branch early-return served the flat handle under the OLD label after a workspace flip, silently returning the new branch's content as the old branch (the pool staleness reinit hot- swaps content without updating handle.branch); (ii) a stale cached branches[] summary routed to a branches/<slug>/ dir that adoptFlatBranchLabel had already deleted (raw 'LadybugDB not found' or POSIX ghost reads with staleness detection blinded). The on-disk flat meta is now read before any cached-state trust. A branches[] summary is served only when its sub-index lbug actually exists (the lbug is what the pool opens — serviceability truth); the cached label is trusted only when no readable flat meta contradicts it (#2106 R4 legacy shapes preserved). One refreshRepos() fires on detected staleness so subsequent calls see fresh handles. Safe against mid-analyze reads: dirty stamps spread the existing meta, preserving the old label until the end-of-run atomic write. Fixtures now materialize the pinned sub-index lbug; new regressions cover the stale-old-label error, adopted-summary fall-through to flat, and the dangling-summary partial-failure window (test gaps 1-2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): make end-of-run branch-label sync best-effort (#2364 review F5) The end-of-run adoptFlatBranchLabel sat inside the pipeline try whose catch rethrows, so a registry write failure (ENOSPC, ~/.gitnexus perms) after a successful multi-minute analyze failed the whole run — even though the index was complete and registered, the neighbouring parse-cache save is deliberately wrapped for exactly this reason, and adopt retries unconditionally on the next plain analyze. It now warns and continues, mirroring the parse-cache wrapper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): correct branch-not-indexed guidance for workspace index (#2364 review F6) The error told users to 'Run: gitnexus analyze --branch <X>', but post-#2354 that command hard-errors unless X is checked out — and this message is now the common goodbye for a formerly-indexed branch whose sub-index the workspace slot adopted. The guidance now explains that the workspace index follows the checked-out branch and leads with the checkout; the '(primary only)' fallback becomes '(workspace only)'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: align primary/workspace vocabulary with the #2354 inversion (#2364 review F7) The review flagged pre-inversion 'primary/non-primary' wording that now misleads readers about the placement model: the isPrimaryBranch JSDoc (field name kept — public API surface), the two branches? JSDoc comments in local-backend, the base_ref gate comment in cli/analyze, and four branch-scope test names. Comment/JSDoc/test-name edits only; 'Registry-primary' and 'primary key' senses untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): clarify workspace index status wording (#2364 review F8) 'gitnexus analyze follows this branch' was ambiguous about WHICH branch analyze follows — the recorded one on the line or the current checkout. Both locales now say a re-run follows the current branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(storage): re-read registry after the shadow rm in adoptFlatBranchLabel The F2 reorder moved the registry read to the top of the function, so the whole-file writeRegistry at the bottom persisted a snapshot taken BEFORE the recursive rm of an entire sub-index — widening the unlocked read-modify-write window from microseconds to the duration of a multi- hundred-MB delete. A concurrent registerRepo/removeBranchIndex writer in that window was silently clobbered (the #2106 R9 lost-update class; registerRepo re-reads before writing for exactly this reason). The top read is now a cheap membership gate only (the F2 no-op guarantee); the mutate re-reads its own fresh snapshot after the rm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: treat only provably-absent errno as gone in the new existence probes Both probes added by this series inverted the codebase's provably- absent polarity (listRegisteredRepos validate prunes only on ENOENT/ENOTDIR): adoptFlatBranchLabel's dirGone check read ANY fs.access failure — including a transient EACCES/EIO on a surviving dir — as 'verifiably gone' and dropped the summary, recreating exactly the stranded-bloat bug F4 fixed; applyBranchScope's sub-index check read the same transient errors on a healthy pinned lbug as 'adopted/ deleted', producing a false 'not indexed' error. A resolved force:true rm now proves absence without a probe; on failure the probe treats only ENOENT/ENOTDIR as gone, and a non-missing lbug serves the handle so the pool open surfaces the real error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): harden applyBranchScope stale-state coherence Four residual gaps in the new arm structure, found by post-fix review: - The stale-label error listed the just-contradicted cached label as indexed ('not indexed: main. Indexed branches: main'). The message now derives the flat label from the authoritative meta and excludes the requested branch from the hint list. - A branch pinned AFTER the server cached its handle never triggered a refresh (resolve hits skip the miss-refresh), erroring until restart. Every miss now fires exactly one best-effort refreshRepos() before the error, so the next call resolves; a refresh-once guard keeps doubly-stale resolutions to a single registry re-scan. - A registry entry claiming the branch both as flat label and pinned summary (the rm-failed adopt-degraded state) could serve the stale- vintage pin under a label the flat slot owns; the summary arm now requires handle.branch !== branch and the degraded state errors honestly. - The flat-meta match path returned the cached handle's pre-restamp branch/commit/stats; the meta that decided routing now also supplies the metadata. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): keep the real error visible in restamp warnings; correct the end-of-run retry claim The fast-path catch replaced the actual error with 'storage is read-only (#1549)' for any EACCES/EPERM — mislabeling ownership problems and transient Windows locks and discarding the only diagnostic signal. The warning now carries the real message with the #1549 hint appended. The end-of-run best-effort comment claimed adopt 'retries unconditionally on the next plain analyze'; same-commit runs take the fast path whose guard compares the already-stamped meta label, so the retry actually lands on the next content-changing run. The comment now states the true retry semantics and why the interim state is safe (flat meta stamped first; applyBranchScope trusts it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: unique tmpdir for the branch-scope fixture; drop redundant dynamic imports The branch-scope describe materialized its sub-index stub under a FIXED os.tmpdir()/gnx-2106-multi path — concurrent vitest runs on one host (the documented parallel-agents workflow) could rm each other's stub between beforeEach and the resolve under test, flaking the pinned-branch tests. The fixture root is now mkdtemp-unique per run with afterAll cleanup. run-analyze.test.ts dynamically imported repo-manager inside test bodies despite the module being statically imported at the top of the file (no vi.mock exists there to justify it); the three call sites now use the static import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d546fa3cce
commit
e46b87f291
20 changed files with 1176 additions and 194 deletions
|
|
@ -155,7 +155,7 @@ flowchart TB
|
|||
| `group_list` | List configured repository groups |
|
||||
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
|
||||
|
||||
> Per-repo tools take an optional `repo` parameter (omit it when only one repo is indexed) and an optional `branch` for multi-branch indexes. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
> Per-repo tools take an optional `repo` parameter (omit it when only one repo is indexed) and an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
|
||||
### Resources for instant context
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
|
|||
| `group_list` | List configured repository groups |
|
||||
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
|
||||
|
||||
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for multi-branch indexes. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
|
||||
## MCP Resources
|
||||
|
||||
|
|
|
|||
|
|
@ -1317,10 +1317,11 @@ const analyzeCommandImpl = async (
|
|||
// preserving the rest of the block (incl. --skills community rows). No-op
|
||||
// when the value already matches, so a routine up-to-date run is silent
|
||||
// (#1996 tri-review P2).
|
||||
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the
|
||||
// PRIMARY/flat index (#2106 R2). A non-primary branch's up-to-date
|
||||
// analyze must not churn the committed AGENTS.md — this mirrors the
|
||||
// in-pipeline `if (!placement.branch)` gate around generateAIContextFiles.
|
||||
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the flat
|
||||
// WORKSPACE index (#2106 R2, #2354). A pinned --branch sub-index's
|
||||
// up-to-date analyze must not churn the committed AGENTS.md — this
|
||||
// mirrors the in-pipeline `if (!placement.branch)` gate around
|
||||
// generateAIContextFiles.
|
||||
let baseRefRefreshed: string[] = [];
|
||||
if (result.isPrimaryBranch !== false) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ export const en = {
|
|||
'status.currentCommit': 'Current commit',
|
||||
'status.branch': 'Branch',
|
||||
'status.detached': '(detached HEAD)',
|
||||
'status.branchNotIndexed':
|
||||
"⚠️ current branch not indexed (primary index is for '{{primary}}'; run gitnexus analyze)",
|
||||
'status.workspaceIndexLabel':
|
||||
"Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)",
|
||||
'status.status': 'Status',
|
||||
'status.upToDate': '✅ up-to-date',
|
||||
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
|
||||
|
|
@ -212,7 +212,7 @@ export const en = {
|
|||
'help.option.force.confirmation': 'Skip confirmation prompt',
|
||||
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
|
||||
'help.option.clean.all': 'Clean all indexed repos',
|
||||
'help.option.clean.branch': 'Delete only the named branch index (not the primary)',
|
||||
'help.option.clean.branch': 'Delete only the named branch index (not the workspace index)',
|
||||
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
|
||||
'help.option.wiki.force': 'Force full regeneration even if up to date',
|
||||
'help.option.wiki.provider':
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ export const zhCN = {
|
|||
'status.currentCommit': '当前提交',
|
||||
'status.branch': '分支',
|
||||
'status.detached': '(分离 HEAD)',
|
||||
'status.branchNotIndexed':
|
||||
"⚠️ 当前分支未索引(主索引对应 '{{primary}}';请运行 gitnexus analyze)",
|
||||
'status.workspaceIndexLabel':
|
||||
"工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)",
|
||||
'status.status': '状态',
|
||||
'status.upToDate': '✅ 已是最新',
|
||||
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
|
||||
|
|
@ -199,7 +199,7 @@ export const zhCN = {
|
|||
'help.option.force.confirmation': '跳过确认提示',
|
||||
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
|
||||
'help.option.clean.all': '清理所有已索引仓库',
|
||||
'help.option.clean.branch': '仅删除指定分支的索引(不影响主索引)',
|
||||
'help.option.clean.branch': '仅删除指定分支的索引(不影响工作区索引)',
|
||||
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
|
||||
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
||||
'help.option.wiki.provider':
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ program
|
|||
)
|
||||
.option(
|
||||
'--branch <name>',
|
||||
'Index the working tree under a specific branch slot (multi-branch indexing). ' +
|
||||
'Defaults to the checked-out branch; the primary/first-indexed branch keeps the ' +
|
||||
'flat index and others get their own. Distinct from --default-branch (cosmetic base_ref).',
|
||||
'Pin the working tree into a dedicated per-branch index slot (multi-branch indexing). ' +
|
||||
'Without this flag, analyze always updates the workspace index, which follows the ' +
|
||||
'checked-out working tree. Distinct from --default-branch (cosmetic base_ref).',
|
||||
)
|
||||
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
|
|
@ -245,7 +245,7 @@ program
|
|||
.description('Delete GitNexus index for current repo')
|
||||
.option('-f, --force', 'Skip confirmation prompt')
|
||||
.option('--all', 'Clean all indexed repos')
|
||||
.option('--branch <name>', 'Delete only the named branch index (not the primary)')
|
||||
.option('--branch <name>', 'Delete only the named branch index (not the workspace index)')
|
||||
.option('--lbug-sidecars', 'Clean quarantined LadybugDB missing-shadow WAL sidecars')
|
||||
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
|
||||
|
||||
|
|
|
|||
|
|
@ -35,27 +35,25 @@ export const statusCommand = async () => {
|
|||
const currentCommit = getCurrentCommit(repo.repoPath);
|
||||
const currentBranch = getCurrentBranch(repo.repoPath);
|
||||
|
||||
// Pick the index matching the checked-out branch (#2106). The flat index
|
||||
// belongs to the primary branch (repo.meta.branch); when the current branch
|
||||
// differs and has its own index, report that one. Legacy/no-branch metas and
|
||||
// detached HEAD fall through to the flat index (unchanged behavior).
|
||||
// Pick the index matching the checked-out branch (#2106/#2354). A pinned
|
||||
// `--branch` sub-index for the current branch wins; otherwise report the
|
||||
// flat workspace index, which follows the checked-out working tree — the
|
||||
// commit comparison below then says whether it needs a re-analyze. Legacy/
|
||||
// no-branch metas and detached HEAD also fall through to the flat index.
|
||||
let activeMeta = repo.meta;
|
||||
let currentBranchIndexed = true;
|
||||
let workspaceLagsBranch = false;
|
||||
if (currentBranch && repo.meta.branch && currentBranch !== repo.meta.branch) {
|
||||
const { metaPath } = getStoragePaths(repo.repoPath, currentBranch);
|
||||
const branchMeta = await loadMeta(path.dirname(metaPath));
|
||||
if (branchMeta) activeMeta = branchMeta;
|
||||
else currentBranchIndexed = false;
|
||||
else workspaceLagsBranch = true;
|
||||
}
|
||||
|
||||
console.log(`${t('status.repository')}: ${repo.repoPath}`);
|
||||
console.log(`${t('status.branch')}: ${currentBranch ?? t('status.detached')}`);
|
||||
|
||||
if (!currentBranchIndexed) {
|
||||
console.log(
|
||||
`${t('status.status')}: ${t('status.branchNotIndexed', { primary: repo.meta.branch ?? '' })}`,
|
||||
);
|
||||
return;
|
||||
if (workspaceLagsBranch) {
|
||||
console.log(t('status.workspaceIndexLabel', { primary: repo.meta.branch ?? '' }));
|
||||
}
|
||||
|
||||
const isUpToDate = currentCommit === activeMeta.lastCommit;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ import {
|
|||
loadMeta,
|
||||
ensureGitNexusIgnored,
|
||||
registerRepo,
|
||||
adoptFlatBranchLabel,
|
||||
isReadOnlyFilesystemError,
|
||||
isRepoRegistered,
|
||||
cleanupOldKuzuFiles,
|
||||
reconcileMetadataFiles,
|
||||
|
|
@ -97,7 +99,6 @@ import {
|
|||
import {
|
||||
getCurrentCommit,
|
||||
getCurrentBranch,
|
||||
getDefaultBranch,
|
||||
getRemoteUrl,
|
||||
hasGitDir,
|
||||
getInferredRepoName,
|
||||
|
|
@ -208,13 +209,13 @@ export interface AnalyzeOptions {
|
|||
*/
|
||||
defaultBranch?: string;
|
||||
/**
|
||||
* Index-branch selector (#2106). Distinct from `defaultBranch` (which only
|
||||
* affects generated AGENTS.md/CLAUDE.md base_ref text). When set, this run is
|
||||
* labelled as that branch and routed to a per-branch index slot unless it is
|
||||
* the primary branch. When `undefined`, the branch is auto-detected from the
|
||||
* checked-out HEAD (the flat/primary slot for the first-indexed branch, a
|
||||
* `branches/<slug>/` sub-directory otherwise). Detached HEAD / non-git always
|
||||
* maps to the flat slot.
|
||||
* Index-branch selector (#2106, #2354). Distinct from `defaultBranch` (which
|
||||
* only affects generated AGENTS.md/CLAUDE.md base_ref text). When set, this
|
||||
* run is pinned to a per-branch index slot (`branches/<slug>/`) unless the
|
||||
* label matches the flat slot's recorded branch. When `undefined`, the run
|
||||
* always targets the flat workspace slot, which follows the checked-out
|
||||
* working tree; the auto-detected branch is only recorded as the slot's
|
||||
* informational label. Detached HEAD / non-git also map to the flat slot.
|
||||
*/
|
||||
branch?: string;
|
||||
/**
|
||||
|
|
@ -282,10 +283,12 @@ export interface AnalyzeResult {
|
|||
*/
|
||||
ftsSkipped?: boolean;
|
||||
/**
|
||||
* True when the index this run produced/validated is the primary/flat slot
|
||||
* (#2106 R2). `false` for a non-primary branch index. Lets the CLI skip
|
||||
* repo-root AGENTS.md/CLAUDE.md refreshes (e.g. the base_ref fast-path) for a
|
||||
* True when the index this run produced/validated is the flat workspace
|
||||
* slot (#2106 R2, inverted by #2354 to follow the checked-out branch).
|
||||
* `false` for a pinned `--branch` sub-index. Lets the CLI skip repo-root
|
||||
* AGENTS.md/CLAUDE.md refreshes (e.g. the base_ref fast-path) for a pinned
|
||||
* branch analyze, mirroring the in-pipeline `if (!placement.branch)` gate.
|
||||
* (The historical "primary" name is kept — it is public API surface.)
|
||||
*/
|
||||
isPrimaryBranch?: boolean;
|
||||
}
|
||||
|
|
@ -343,27 +346,6 @@ export const PHASE_LABELS: Record<string, string> = {
|
|||
* the {@link AnalyzeCallbacks} interface — it never writes to stdout/stderr
|
||||
* directly and never calls `process.exit()`.
|
||||
*/
|
||||
/**
|
||||
* Build the primary-inversion warning (#2106 R8), or `undefined` when there is
|
||||
* nothing to warn about. Pure + exported for testing. Both inputs are trimmed
|
||||
* (a diagnostic — a missed warning is low-harm; a false warning is the thing to
|
||||
* avoid). `defaultBranch` is the repo's `origin/HEAD` branch (null when unset,
|
||||
* e.g. fresh clones / CI), `flatOwner` is the branch that owns the flat slot.
|
||||
*/
|
||||
export const primaryInversionWarning = (
|
||||
defaultBranch: string | null | undefined,
|
||||
flatOwner: string | null | undefined,
|
||||
): string | undefined => {
|
||||
const norm = (s: string | null | undefined): string | undefined => s?.trim() || undefined;
|
||||
const d = norm(defaultBranch);
|
||||
const o = norm(flatOwner);
|
||||
if (!d || !o || d === o) return undefined;
|
||||
return (
|
||||
`Warning: the default branch "${d}" is not the primary index — "${o}" owns the flat slot. ` +
|
||||
`Run \`gitnexus clean --branch ${o}\` then re-index on "${d}", or query it explicitly with \`--branch ${d}\`.`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect the recorded parse-cache chunk keys across the flat + every branch
|
||||
* metadata directory under a flat `.gitnexus` storage, EXCLUDING `excludeDir`
|
||||
|
|
@ -600,13 +582,15 @@ export async function runFullAnalysis(
|
|||
const repoHasGit = hasGitDir(repoPath);
|
||||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
|
||||
// ── #2106: resolve which branch slot this run writes to ───────────────
|
||||
// ── #2106/#2354: resolve which branch slot this run writes to ─────────
|
||||
// `branchLabel` is the branch identity recorded in meta.json (incl. the
|
||||
// primary). `placement.branch` is undefined for the flat/primary slot (the
|
||||
// lbug/meta paths stay byte-identical to single-branch behavior) and set for
|
||||
// a `branches/<slug>/` sub-directory. Explicit `--branch` is always honored;
|
||||
// otherwise auto-detect the checked-out branch (null for detached HEAD /
|
||||
// non-git → flat slot).
|
||||
// flat workspace slot). `placement.branch` is undefined for the flat slot
|
||||
// (the lbug/meta paths stay byte-identical to single-branch behavior) and
|
||||
// set for a `branches/<slug>/` sub-directory. Only an explicit `--branch`
|
||||
// can route to a sub-directory; a plain analyze ALWAYS targets the flat
|
||||
// slot, which follows the checked-out working tree (#2354) — the
|
||||
// auto-detected branch (null for detached HEAD / non-git) is recorded as
|
||||
// the slot's informational label only.
|
||||
// Normalize the auto-detected branch the same way an explicit `--branch` is
|
||||
// validated (#2106 R1): a git ref the branch-name rules forbid (backtick,
|
||||
// `~ ^ : ? *`, leading `-`, `..`) becomes `null` → the flat slot, matching
|
||||
|
|
@ -627,7 +611,7 @@ export async function runFullAnalysis(
|
|||
);
|
||||
}
|
||||
const branchLabel = options.branch ?? checkedOutBranch;
|
||||
const placement = await resolveBranchPlacement(repoPath, branchLabel);
|
||||
const placement = options.branch ? await resolveBranchPlacement(repoPath, branchLabel) : {};
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch);
|
||||
// metaPath now points to the metadata file (gitnexus.json) in a branch-specific directory.
|
||||
// metaDir is the directory containing the metadata file (and branch-specific DBs).
|
||||
|
|
@ -647,21 +631,6 @@ export async function runFullAnalysis(
|
|||
|
||||
const existingMeta = await loadMeta(metaDir);
|
||||
|
||||
// ── #2106 (R8): warn when the repo's default branch is not the primary ──
|
||||
// A non-default branch can own the flat slot (it was indexed first). That
|
||||
// index is still fully queryable via `--branch`, so this is an ergonomics
|
||||
// wart, not data loss — we only warn (no risky relocation of a live DB).
|
||||
if (repoHasGit) {
|
||||
// Who owns the flat slot after this run? For a flat/primary run it is this
|
||||
// run's resolved label (carrying an existing stamp forward); for a branch
|
||||
// run the flat owner is unchanged, so read the flat meta.
|
||||
const flatOwner = placement.branch
|
||||
? (await loadMeta(storagePath))?.branch
|
||||
: (branchLabel ?? existingMeta?.branch);
|
||||
const warning = primaryInversionWarning(getDefaultBranch(repoPath), flatOwner);
|
||||
if (warning) log(warning);
|
||||
}
|
||||
|
||||
// ── FTS-only repair path ────────────────────────────────────────────
|
||||
if (options.repairFts) {
|
||||
if (!existingMeta) {
|
||||
|
|
@ -888,6 +857,39 @@ export async function runFullAnalysis(
|
|||
const healUnregistered =
|
||||
options.allowDuplicateName === true && !(await isRepoRegistered(repoPath));
|
||||
if (!dirty && !healUnregistered) {
|
||||
// ── #2354: restamp the workspace label on a same-commit branch flip ──
|
||||
// The flat slot follows the checked-out working tree; a branch switch
|
||||
// at the SAME commit with a clean tree changes nothing the pipeline
|
||||
// must rebuild, but the slot's informational `branch` label (and the
|
||||
// registry copy that query-side branch scoping reads) would go stale.
|
||||
// Detached HEAD / non-git (branchLabel === null) keeps the existing
|
||||
// stamp, mirroring the end-of-run meta write.
|
||||
if (!placement.branch && branchLabel && existingMeta.branch !== branchLabel) {
|
||||
// Adopt first, stamp last (#2364 review F3): this block's retry
|
||||
// guard is `existingMeta.branch !== branchLabel`, so stamping the
|
||||
// meta before the registry/shadow cleanup would flip the guard and
|
||||
// lock in any partial failure — with saveMeta last, a failed adopt
|
||||
// leaves the guard true and the next same-commit run self-heals
|
||||
// (adopt is idempotent). The whole sync is best-effort: the label
|
||||
// is informational and the flat DB content is byte-valid for both
|
||||
// labels here (same commit, clean tree), so an "Already up to
|
||||
// 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 saveMeta(metaDir, { ...existingMeta, branch: branchLabel });
|
||||
} catch (err) {
|
||||
// EACCES/EPERM also arise from ownership problems and transient
|
||||
// Windows locks, so keep the real error visible alongside the
|
||||
// #1549 read-only hint instead of replacing it.
|
||||
const reason = isReadOnlyFilesystemError(err)
|
||||
? `${(err as Error).message} — storage may be read-only (#1549)`
|
||||
: (err as Error).message;
|
||||
log(
|
||||
`Warning: could not restamp the workspace branch label (${reason}); will retry on the next run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
return {
|
||||
// `resolveRepoIdentityRoot` collapses worktree roots to the
|
||||
|
|
@ -1620,6 +1622,26 @@ export async function runFullAnalysis(
|
|||
branch: placement.branch,
|
||||
});
|
||||
|
||||
// ── #2354: the flat workspace slot has adopted this run's branch ──────
|
||||
// Drop a now-shadowed `branches/<slug>/` sub-index for the same label
|
||||
// (unreachable once the flat slot serves it) and align the registry's
|
||||
// top-level branch label. Best-effort like the parse-cache save above
|
||||
// (#2364 review F5): the index is complete and registered, and a failure
|
||||
// here leaves only a stale registry label / undeleted shadowed dir —
|
||||
// never wrong routing, because the flat meta this run already stamped is
|
||||
// what applyBranchScope trusts. Retried by the next content-changing run
|
||||
// (same-commit fast-path runs skip it: their guard compares the
|
||||
// already-stamped meta label).
|
||||
if (!placement.branch && branchLabel) {
|
||||
try {
|
||||
await adoptFlatBranchLabel(repoPath, branchLabel);
|
||||
} catch (e) {
|
||||
log(
|
||||
`Warning: could not sync the workspace branch label (${(e as Error).message}); continuing.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ interface RepoHandle {
|
|||
stats?: RegistryEntry['stats'];
|
||||
/** Primary/flat branch name, when known (#2106). */
|
||||
branch?: string;
|
||||
/** Non-primary branch indexes available for this repo (#2106). */
|
||||
/** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
|
||||
branches?: BranchSummary[];
|
||||
}
|
||||
|
||||
|
|
@ -584,7 +584,7 @@ export interface RepoListing {
|
|||
siblings?: Array<{ name: string; path: string; lastCommit: string }>;
|
||||
/** Primary/flat branch name, when known (#2106). */
|
||||
branch?: string;
|
||||
/** Non-primary branch indexes available for this repo (#2106). */
|
||||
/** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
|
||||
branches?: Array<Omit<BranchSummary, 'stats'>>;
|
||||
}
|
||||
|
||||
|
|
@ -1120,45 +1120,118 @@ export class LocalBackend {
|
|||
/**
|
||||
* Re-point a resolved repo handle at a specific branch index (#2106).
|
||||
*
|
||||
* - No `branch` (default) → the primary/flat handle, unchanged (backward
|
||||
* - No `branch` (default) → the flat workspace handle, unchanged (backward
|
||||
* compatible: every existing caller passes no branch).
|
||||
* - `branch` equal to the known primary → the flat handle.
|
||||
* - `branch` matching an indexed non-primary branch → a handle whose
|
||||
* - `branch` equal to the flat slot's **on-disk** recorded branch → the
|
||||
* flat handle. The disk meta is read before any cached state is trusted
|
||||
* (#2364 review F1): the flat slot follows the checked-out working tree
|
||||
* (#2354), so a plain analyze after a branch switch restamps the meta
|
||||
* without any repo-resolution miss that would refresh a long-lived
|
||||
* server's cached handle — the cached label can otherwise serve another
|
||||
* branch's content under the old name (the pool staleness reinit
|
||||
* hot-swaps content without updating `handle.branch`).
|
||||
* - `branch` matching an indexed pinned branch → a handle whose
|
||||
* `lbugPath` points at `branches/<slug>/lbug`; the connection pool keys by
|
||||
* `lbugPath`, so this is the only change needed to scope every tool.
|
||||
* - `branch` that was never indexed → a clear error (never a silently-empty
|
||||
* result against the wrong DB).
|
||||
* `lbugPath`, so this is the only change needed to scope every tool. The
|
||||
* sub-index lbug must actually exist on disk — `adoptFlatBranchLabel`
|
||||
* deletes the whole dir when the flat slot takes ownership, and a stale
|
||||
* cached summary must not route to the deleted path.
|
||||
* - Cached `handle.branch` is trusted only when there is no readable flat
|
||||
* meta to contradict it (legacy shapes, #2106 R4).
|
||||
* - Any miss → a clear error (never a silently-empty result against the
|
||||
* wrong DB), after exactly one `refreshRepos()` so newly-pinned branches
|
||||
* and restamped labels the cached handle predates resolve on the next
|
||||
* call.
|
||||
*/
|
||||
private async applyBranchScope(handle: RepoHandle, branch?: string): Promise<RepoHandle> {
|
||||
if (!branch) return handle;
|
||||
if (handle.branch && handle.branch === branch) return handle;
|
||||
const summary = handle.branches?.find((b) => b.branch === branch);
|
||||
if (summary) {
|
||||
const { lbugPath } = getStoragePaths(handle.repoPath, branch);
|
||||
// At most one cache refresh per resolution: enough for the NEXT call to
|
||||
// see fresh handles, without paying two registry re-scans when several
|
||||
// stale arms fire in one degraded resolution.
|
||||
let refreshed = false;
|
||||
const refreshOnce = async (): Promise<void> => {
|
||||
if (refreshed) return;
|
||||
refreshed = true;
|
||||
await this.refreshRepos().catch(() => {});
|
||||
};
|
||||
// One small JSON read per scoped call; mid-run meta writes preserve the
|
||||
// old label until the end-of-run atomic stamp (run-analyze dirty stamps
|
||||
// spread the existing meta), so this read never runs ahead of the DB.
|
||||
const flatMeta = await loadMeta(path.dirname(handle.lbugPath));
|
||||
if (flatMeta?.branch && flatMeta.branch === branch) {
|
||||
// The disk meta decides routing, so it also supplies the metadata —
|
||||
// the cached handle's label/commit/stats can predate the restamp.
|
||||
return {
|
||||
...handle,
|
||||
lbugPath,
|
||||
indexedAt: summary.indexedAt,
|
||||
lastCommit: summary.lastCommit,
|
||||
stats: summary.stats,
|
||||
branch: flatMeta.branch,
|
||||
indexedAt: flatMeta.indexedAt ?? handle.indexedAt,
|
||||
lastCommit: flatMeta.lastCommit ?? handle.lastCommit,
|
||||
stats: flatMeta.stats ?? handle.stats,
|
||||
};
|
||||
}
|
||||
// Legacy entry (pre-#2106): the registry has no recorded primary `branch`,
|
||||
// so a `--branch <primary>` request misses the checks above. Read the flat
|
||||
// meta.json (next to the flat handle's lbug) to learn the primary and serve
|
||||
// the flat handle only when it actually matches — never serve flat for an
|
||||
// arbitrary unindexed branch (#2106 R4).
|
||||
if (!handle.branch) {
|
||||
const flatMeta = await loadMeta(path.dirname(handle.lbugPath));
|
||||
if (flatMeta?.branch && flatMeta.branch === branch) return handle;
|
||||
|
||||
// A registry entry claiming `branch` both as the flat label AND as a
|
||||
// pinned summary is an adopt-degraded state (rm kept the summary while
|
||||
// the label restamped) — never serve the possibly stale-vintage pin for
|
||||
// a label the flat slot claims; fall through to the honest error.
|
||||
const summary =
|
||||
handle.branch !== branch ? handle.branches?.find((b) => b.branch === branch) : undefined;
|
||||
if (summary) {
|
||||
const { lbugPath } = getStoragePaths(handle.repoPath, branch);
|
||||
// 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).
|
||||
// Only provably-absent errno counts as missing: a transient EACCES/EIO
|
||||
// on a healthy pinned sub-index must serve the handle (the pool open
|
||||
// surfaces the real error) rather than a false "not indexed".
|
||||
const probeCode = await fs.access(lbugPath).then(
|
||||
() => null,
|
||||
(e: unknown) => (e as NodeJS.ErrnoException)?.code ?? 'UNKNOWN',
|
||||
);
|
||||
const subIndexMissing = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';
|
||||
if (!subIndexMissing) {
|
||||
return {
|
||||
...handle,
|
||||
lbugPath,
|
||||
indexedAt: summary.indexedAt,
|
||||
lastCommit: summary.lastCommit,
|
||||
stats: summary.stats,
|
||||
};
|
||||
}
|
||||
// Stale summary (sub-index adopted/deleted): refresh so later calls see
|
||||
// fresh handles, then fall through — the flat meta above is the truth.
|
||||
await refreshOnce();
|
||||
}
|
||||
const indexed = [handle.branch, ...(handle.branches?.map((b) => b.branch) ?? [])].filter(
|
||||
Boolean,
|
||||
|
||||
if (handle.branch && handle.branch === branch) {
|
||||
// No readable flat meta (missing/corrupt — loadMeta → null): keep the
|
||||
// pre-#2354 trust in the cached label (#2106 R4 legacy shapes). A
|
||||
// readable meta that names another branch means the label is stale.
|
||||
if (!flatMeta?.branch) return handle;
|
||||
}
|
||||
|
||||
// Every miss refreshes once before erroring: newly-pinned branches and
|
||||
// restamped labels the cached handle predates become resolvable on the
|
||||
// caller's next attempt (the cache otherwise only refreshes on repo-
|
||||
// resolution misses and list_repos).
|
||||
await refreshOnce();
|
||||
|
||||
// The flat slot's label comes from the authoritative meta when readable —
|
||||
// never echo a cached label the meta just contradicted (a "not indexed:
|
||||
// main / indexed: main" self-contradiction). Cached summaries may still
|
||||
// lag; they are a hint, not a promise.
|
||||
const flatLabel = flatMeta?.branch ?? handle.branch;
|
||||
const indexed = [flatLabel, ...(handle.branches?.map((b) => b.branch) ?? [])].filter(
|
||||
(b) => Boolean(b) && b !== branch,
|
||||
);
|
||||
const available = indexed.length > 0 ? indexed.join(', ') : '(primary only)';
|
||||
const available = indexed.length > 0 ? indexed.join(', ') : '(workspace only)';
|
||||
// Post-#2354 a bare `analyze --branch <X>` refuses to run unless X is
|
||||
// checked out, so the guidance must lead with the checkout (#2364 F6).
|
||||
throw new Error(
|
||||
`Branch "${branch}" is not indexed for "${handle.name}". ` +
|
||||
`Indexed branches: ${available}. Run: gitnexus analyze --branch ${branch}`,
|
||||
`Indexed branches: ${available}. The workspace index follows the ` +
|
||||
`checked-out branch — check out "${branch}" and re-run: gitnexus analyze ` +
|
||||
`(add --branch ${branch} while it is checked out to pin a separate sub-index).`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -906,11 +906,12 @@ for (const tool of GITNEXUS_TOOLS) {
|
|||
if (!BRANCH_SCOPED_TOOLS.has(tool.name)) continue;
|
||||
if (tool.inputSchema.properties.branch) continue;
|
||||
// Optional — `required` is left unchanged so omitting `branch` keeps today's
|
||||
// default/primary-branch behavior. Ignored in group mode (repo starts "@").
|
||||
// workspace-index behavior. Ignored in group mode (repo starts "@").
|
||||
tool.inputSchema.properties.branch = {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional: scope to a specific branch index (multi-branch repos, #2106). ' +
|
||||
'Omit for the default/primary branch. Ignored in group mode.',
|
||||
'Optional: scope to a pinned branch index (multi-branch repos, #2106). ' +
|
||||
'Omit for the workspace index, which follows the checked-out working tree. ' +
|
||||
'Ignored in group mode.',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,18 +45,20 @@ export const branchSlug = (rawRef: string): string => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Decide where a freshly-analyzed branch's index lives: the flat (primary) slot
|
||||
* or a per-branch sub-directory (#2106 KTD2).
|
||||
* Decide where an EXPLICIT `--branch` run's index lives: the flat workspace
|
||||
* slot or a per-branch sub-directory (#2106 KTD2, #2354).
|
||||
*
|
||||
* Returns `{}` for the flat/primary placement (byte-identical layout) or
|
||||
* `{ branch }` for a `branches/<slug>/` sub-directory. The flat slot is owned by
|
||||
* the FIRST branch indexed, recorded as `branch` in the flat `meta.json`; a
|
||||
* different checked-out branch then auto-routes to its own sub-directory so it
|
||||
* never overwrites the primary index.
|
||||
* Only explicit `--branch` runs consult this — a plain analyze always targets
|
||||
* the flat slot, which follows the checked-out working tree (#2354; gated at
|
||||
* the `runFullAnalysis` call site). Returns `{}` for the flat placement
|
||||
* (byte-identical layout) or `{ branch }` for a `branches/<slug>/`
|
||||
* sub-directory: when the requested label matches the flat slot's recorded
|
||||
* `branch` label the run updates the flat slot in place (identical content —
|
||||
* `--branch` requires the label to be checked out); any other label gets its
|
||||
* own pinned sub-directory that plain analyzes won't touch.
|
||||
*
|
||||
* `label` is the resolved index-branch (explicit `--branch`, else the
|
||||
* checked-out branch, else `null`). A `null` label — detached HEAD, non-git
|
||||
* folder, or CI checkout — always maps to the flat slot.
|
||||
* A `null` label — detached HEAD, non-git folder, or CI checkout — always
|
||||
* maps to the flat slot.
|
||||
*/
|
||||
export const resolveBranchPlacement = async (
|
||||
repoPath: string,
|
||||
|
|
|
|||
|
|
@ -657,7 +657,7 @@ export const findRepo = async (startPath: string): Promise<IndexedRepo | null> =
|
|||
return null;
|
||||
};
|
||||
|
||||
function isReadOnlyFilesystemError(err: unknown): boolean {
|
||||
export function isReadOnlyFilesystemError(err: unknown): boolean {
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
return code === 'EROFS' || code === 'EACCES' || code === 'EPERM';
|
||||
}
|
||||
|
|
@ -1120,6 +1120,90 @@ export const removeBranchIndex = async (repoPath: string, branch: string): Promi
|
|||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Record that the flat workspace slot now serves `branch` (#2354).
|
||||
*
|
||||
* The flat index follows the checked-out working tree, so when a plain
|
||||
* analyze lands on a branch that also has a pinned `branches/<slug>/`
|
||||
* sub-index, that sub-index becomes permanently shadowed — explicit
|
||||
* `--branch` runs re-resolve to the flat slot and query-side branch scoping
|
||||
* serves the flat handle first. Delete the shadowed directory and drop its
|
||||
* registry summary in the same pass (leaving either half behind would strand
|
||||
* un-cleanable disk bloat), and refresh the entry's top-level `branch` label
|
||||
* so `list`/`list_repos`/branch-scoped queries stay coherent.
|
||||
*
|
||||
* Deliberately narrow for the analyze fast path: a missing registry entry is
|
||||
* a no-op — including the sub-index deletion, which only runs for registered
|
||||
* repos (never self-heals an unregistered repo, per #2264/#1169; the registry
|
||||
* check precedes the rm per #2364 review F2) — and no subprocess is spawned.
|
||||
*/
|
||||
export const adoptFlatBranchLabel = async (repoPath: string, branch: 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 resolved = path.resolve(repoPath);
|
||||
const { storagePath } = getStoragePaths(resolved);
|
||||
// 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;
|
||||
let dirGone = false;
|
||||
if (branchDir.startsWith(branchesRoot)) {
|
||||
let rmError: NodeJS.ErrnoException | undefined;
|
||||
await fs.rm(branchDir, { recursive: true, force: true }).catch((err: unknown) => {
|
||||
rmError = err as NodeJS.ErrnoException;
|
||||
});
|
||||
// The registry summary may be dropped only for a verifiably-gone
|
||||
// directory: `clean --branch` resolves its target solely via the
|
||||
// recorded summary, so dropping it while the dir survives (e.g. Windows
|
||||
// EBUSY on an lbug held open by a live MCP server) would strand
|
||||
// un-cleanable disk bloat (#2364 review F4). A resolved force:true rm
|
||||
// proves absence; on failure, probe the disk and treat only
|
||||
// provably-absent errno as gone — EACCES/EIO are "not provably absent",
|
||||
// the same polarity as listRegisteredRepos({ validate: true }).
|
||||
if (!rmError) {
|
||||
dirGone = true;
|
||||
} else {
|
||||
const probeCode = await fs.access(branchDir).then(
|
||||
() => null,
|
||||
(e: unknown) => (e as NodeJS.ErrnoException)?.code ?? 'UNKNOWN',
|
||||
);
|
||||
dirGone = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';
|
||||
}
|
||||
if (dirGone) {
|
||||
// Non-recursive by design: only removes the parent when no other pinned
|
||||
// sub-index remains, so an empty branches/ dir doesn't read as "pinned".
|
||||
await fs.rmdir(path.join(storagePath, BRANCHES_DIR)).catch(() => {});
|
||||
} else {
|
||||
logger.warn(
|
||||
{ path: branchDir, code: rmError?.code },
|
||||
'Could not remove the shadowed branch sub-index; keeping its registry summary so `gitnexus clean --branch` can still target it.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read AFTER the potentially slow recursive rm: the registry is a
|
||||
// multi-writer whole-file overwrite, and writing a pre-rm snapshot would
|
||||
// silently clobber concurrent registerRepo/removeBranchIndex writers —
|
||||
// the #2106 R9 re-read-before-write discipline registerRepo follows.
|
||||
const entries = await readRegistry();
|
||||
const idx = isRegistered(entries);
|
||||
if (idx < 0) return; // unregistered concurrently → still a no-op
|
||||
const entry = entries[idx];
|
||||
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
|
||||
entry.branch = branch;
|
||||
if (remaining && remaining.length > 0) entry.branches = remaining;
|
||||
else delete entry.branches;
|
||||
entries[idx] = entry;
|
||||
await writeRegistry(entries);
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown by {@link resolveRegistryEntry} when no registered repo matches
|
||||
* the caller's target string (by alias, basename, remote-inferred name,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ import { getStoragePaths, loadMeta, listRegisteredRepos } from '../../src/storag
|
|||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* #2106 — multi-branch indexing end-to-end. Proves that analyzing a second
|
||||
* branch creates its own index under `.gitnexus/branches/<slug>/` and does NOT
|
||||
* overwrite the primary (flat) index, and that the primary single-branch
|
||||
* layout stays at `.gitnexus/{lbug,meta.json}`.
|
||||
* #2106/#2354 — branch handling end-to-end. Proves that a plain analyze
|
||||
* always updates the flat workspace index (following the checked-out working
|
||||
* tree, no `branches/` sub-directory, no slot-ownership friction), that an
|
||||
* explicit `--branch` run pins a separate index under
|
||||
* `.gitnexus/branches/<slug>/` without touching the flat slot, and that a
|
||||
* pinned sub-index shadowed by a later plain analyze on the same branch is
|
||||
* cleaned up.
|
||||
*/
|
||||
const git = (args: string[], cwd: string): string =>
|
||||
execSync(['git', ...args].join(' '), { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
|
|
@ -37,7 +40,7 @@ describe('multi-branch analyze (#2106)', () => {
|
|||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('indexes a second branch without overwriting the first', async () => {
|
||||
it('a plain analyze follows a branch switch into the flat workspace slot (#2354)', async () => {
|
||||
const tmp = await createTempDir('gitnexus-multibranch-');
|
||||
const repo = tmp.dbPath;
|
||||
try {
|
||||
|
|
@ -52,16 +55,13 @@ describe('multi-branch analyze (#2106)', () => {
|
|||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
|
||||
// Primary branch lands in the flat slot, byte-identical layout.
|
||||
// First analyze lands in the flat slot, byte-identical layout.
|
||||
const flat = getStoragePaths(repo);
|
||||
expect(path.dirname(flat.lbugPath)).toBe(flat.storagePath);
|
||||
expect(existsSync(flat.lbugPath)).toBe(true);
|
||||
const flatMeta = await loadMeta(flat.storagePath);
|
||||
expect(flatMeta?.branch).toBe('main');
|
||||
expect(flatMeta?.lastCommit).toBe(mainCommit);
|
||||
// main records its live chunk keys so a later branch prune can keep them.
|
||||
const mainCacheKeys = flatMeta?.cacheKeys ?? [];
|
||||
expect(mainCacheKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// Switch to a feature branch with different content and re-analyze.
|
||||
git(['checkout', '-b', 'feature/x'], repo);
|
||||
|
|
@ -73,13 +73,60 @@ describe('multi-branch analyze (#2106)', () => {
|
|||
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
|
||||
// The flat (main) index is untouched — NOT overwritten by the feature run.
|
||||
// The flat workspace index followed the working tree — updated in place,
|
||||
// no `branches/` sub-directory, no slot-ownership error or warning.
|
||||
expect(existsSync(flat.lbugPath)).toBe(true);
|
||||
const flatMetaAfter = await loadMeta(flat.storagePath);
|
||||
expect(flatMetaAfter?.branch).toBe('feature/x');
|
||||
expect(flatMetaAfter?.lastCommit).toBe(featureCommit);
|
||||
expect(existsSync(path.join(flat.storagePath, 'branches'))).toBe(false);
|
||||
|
||||
// The registry follows along: one entry, relabelled, no branches[].
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo));
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.branch).toBe('feature/x');
|
||||
expect(entry?.lastCommit).toBe(featureCommit);
|
||||
expect(entry?.branches).toBeUndefined();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it('an explicit --branch run pins a sub-index; a later plain analyze on that branch reclaims it', async () => {
|
||||
const tmp = await createTempDir('gitnexus-multibranch-pin-');
|
||||
const repo = tmp.dbPath;
|
||||
try {
|
||||
git(['init'], repo);
|
||||
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
git(['add', '-A'], repo);
|
||||
commit(repo, 'a');
|
||||
git(['branch', '-M', 'main'], repo);
|
||||
const mainCommit = git(['rev-parse', 'HEAD'], repo);
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
const flat = getStoragePaths(repo);
|
||||
// main records its live chunk keys so a later branch prune can keep them.
|
||||
const mainCacheKeys = (await loadMeta(flat.storagePath))?.cacheKeys ?? [];
|
||||
expect(mainCacheKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// Pin the feature branch into its own sub-index with explicit --branch.
|
||||
git(['checkout', '-b', 'feature/x'], repo);
|
||||
await fs.writeFile(path.join(repo, 'b.ts'), 'export const b = 2;\n');
|
||||
git(['add', '-A'], repo);
|
||||
commit(repo, 'b');
|
||||
const featureCommit = git(['rev-parse', 'HEAD'], repo);
|
||||
|
||||
await runFullAnalysis(repo, { branch: 'feature/x' }, { onProgress: () => {} });
|
||||
|
||||
// The flat (main) index is untouched — NOT overwritten by the pinned run.
|
||||
expect(existsSync(flat.lbugPath)).toBe(true);
|
||||
const flatMetaAfter = await loadMeta(flat.storagePath);
|
||||
expect(flatMetaAfter?.branch).toBe('main');
|
||||
expect(flatMetaAfter?.lastCommit).toBe(mainCommit);
|
||||
|
||||
// The feature index is a separate DB under branches/<slug>/.
|
||||
// The pinned index is a separate DB under branches/<slug>/.
|
||||
const branchPaths = getStoragePaths(repo, 'feature/x');
|
||||
const branchDir = path.dirname(branchPaths.lbugPath);
|
||||
expect(branchDir.includes(path.join('.gitnexus', 'branches'))).toBe(true);
|
||||
|
|
@ -88,7 +135,7 @@ describe('multi-branch analyze (#2106)', () => {
|
|||
expect(branchMeta?.branch).toBe('feature/x');
|
||||
expect(branchMeta?.lastCommit).toBe(featureCommit);
|
||||
|
||||
// #2106 R6: the feature analyze must NOT have evicted main's chunks from
|
||||
// #2106 R6: the pinned analyze must NOT have evicted main's chunks from
|
||||
// the SHARED parse cache (they were unioned in via main's recorded keys).
|
||||
const { loadParseCache } = await import('../../src/storage/parse-cache.js');
|
||||
const sharedCache = await loadParseCache(flat.storagePath);
|
||||
|
|
@ -97,14 +144,25 @@ describe('multi-branch analyze (#2106)', () => {
|
|||
expect(onDisk.has(k), `main chunk ${k} survives the feature prune`).toBe(true);
|
||||
}
|
||||
|
||||
// The global registry keeps one entry per path: primary at top level,
|
||||
// the feature branch nested under branches[] (#2106 U4).
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo));
|
||||
expect(entry).toBeDefined();
|
||||
// The global registry keeps one entry per path: flat label at top level,
|
||||
// the pinned branch nested under branches[] (#2106 U4).
|
||||
let entries = await listRegisteredRepos();
|
||||
let entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo));
|
||||
expect(entry?.branch).toBe('main');
|
||||
expect(entry?.lastCommit).toBe(mainCommit);
|
||||
expect(entry?.branches?.map((b) => b.branch)).toEqual(['feature/x']);
|
||||
|
||||
// A plain analyze on the pinned branch adopts the flat workspace slot
|
||||
// and removes the now-shadowed sub-index (#2354): the flat handle would
|
||||
// always win for this label, leaving the sub-index unreachable bloat.
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
const reclaimed = await loadMeta(flat.storagePath);
|
||||
expect(reclaimed?.branch).toBe('feature/x');
|
||||
expect(reclaimed?.lastCommit).toBe(featureCommit);
|
||||
expect(existsSync(branchDir)).toBe(false);
|
||||
entries = await listRegisteredRepos();
|
||||
entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo));
|
||||
expect(entry?.branch).toBe('feature/x');
|
||||
expect(entry?.branches).toBeUndefined();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
|
|
@ -132,17 +190,17 @@ describe('multi-branch analyze (#2106)', () => {
|
|||
await runFullAnalysis(repo, { force: true }, { onProgress: () => {} });
|
||||
expect((await loadMeta(flat.storagePath))?.branch).toBe('main');
|
||||
|
||||
// Now a feature analyze must still route to a sub-dir (the stamp survived),
|
||||
// leaving the primary index intact rather than claiming the flat slot.
|
||||
// Now an explicit --branch analyze must still route to a sub-dir (the
|
||||
// stamp survived), leaving the flat index intact rather than updating it.
|
||||
git(['checkout', '-b', 'feature/y'], repo);
|
||||
await fs.writeFile(path.join(repo, 'b.ts'), 'export const b = 2;\n');
|
||||
git(['add', '-A'], repo);
|
||||
commit(repo, 'b');
|
||||
await runFullAnalysis(repo, {}, { onProgress: () => {} });
|
||||
await runFullAnalysis(repo, { branch: 'feature/y' }, { onProgress: () => {} });
|
||||
|
||||
const flatMeta = await loadMeta(flat.storagePath);
|
||||
expect(flatMeta?.branch).toBe('main');
|
||||
expect(flatMeta?.lastCommit).toBe(mainCommit); // primary NOT overwritten
|
||||
expect(flatMeta?.lastCommit).toBe(mainCommit); // flat NOT touched by the pinned run
|
||||
expect(existsSync(getStoragePaths(repo, 'feature/y').lbugPath)).toBe(true);
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
|
|
|
|||
89
gitnexus/test/integration/run-analyze-adopt-failure.test.ts
Normal file
89
gitnexus/test/integration/run-analyze-adopt-failure.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* End-of-run adopt is best-effort (#2364 review F5): a completed, registered
|
||||
* analyze must not exit non-zero because the post-registration branch-label
|
||||
* sync failed (e.g. registry write ENOSPC). Integration-level because the
|
||||
* full pipeline opens a real LadybugDB (multi-branch-analyze.test.ts
|
||||
* precedent); the delegating vi.mock makes adoptFlatBranchLabel fail on
|
||||
* demand (vi.spyOn cannot intercept ESM namespace exports).
|
||||
*
|
||||
* Once-mock starvation hazard: the delegating mock intercepts every
|
||||
* repo-manager call in the process — arm mockRejectedValueOnce only
|
||||
* immediately before the call under test.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
type RepoManagerModule = typeof import('../../src/storage/repo-manager.js');
|
||||
|
||||
const rmCtx = vi.hoisted(() => ({
|
||||
adoptMock: vi.fn(),
|
||||
realAdopt: null as RepoManagerModule['adoptFlatBranchLabel'] | null,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<RepoManagerModule>();
|
||||
rmCtx.realAdopt = actual.adoptFlatBranchLabel;
|
||||
rmCtx.adoptMock.mockImplementation(actual.adoptFlatBranchLabel);
|
||||
return {
|
||||
...actual,
|
||||
adoptFlatBranchLabel: rmCtx.adoptMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { runFullAnalysis } from '../../src/core/run-analyze.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
describe('end-of-run adopt is best-effort (#2364 F5)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedGitnexusHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-adopt-besteffort-home-');
|
||||
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
rmCtx.adoptMock.mockReset();
|
||||
rmCtx.adoptMock.mockImplementation(
|
||||
(...args: Parameters<RepoManagerModule['adoptFlatBranchLabel']>) => rmCtx.realAdopt!(...args),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
||||
await tmpHome.cleanup();
|
||||
});
|
||||
|
||||
it('a failed label sync warns and the run still succeeds, already registered', async () => {
|
||||
const tmp = await createTempDir('gitnexus-adopt-besteffort-');
|
||||
const repo = tmp.dbPath;
|
||||
try {
|
||||
execSync('git init', { cwd: repo, stdio: 'pipe' });
|
||||
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
execSync('git add -A', { cwd: repo, stdio: 'pipe' });
|
||||
execSync('git -c user.name=t -c user.email=t@t commit -m a', { cwd: repo, stdio: 'pipe' });
|
||||
execSync('git branch -M main', { cwd: repo, stdio: 'pipe' });
|
||||
|
||||
const logs: string[] = [];
|
||||
rmCtx.adoptMock.mockRejectedValueOnce(new Error('mock registry write failure'));
|
||||
const result = await runFullAnalysis(
|
||||
repo,
|
||||
{},
|
||||
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
|
||||
);
|
||||
|
||||
// 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(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();
|
||||
expect(entries.some((e) => path.resolve(e.path) === path.resolve(repo))).toBe(true);
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
* These are pure unit tests that mock the LadybugDB layer to test
|
||||
* the dispatch and error handling logic in isolation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import fsPromises from 'fs/promises';
|
||||
import os from 'os';
|
||||
|
|
@ -114,7 +114,9 @@ import { CALLEES_TRUNCATED_SENTINEL } from '../../src/core/ingestion/cfg/emit.js
|
|||
import {
|
||||
listRegisteredRepos,
|
||||
cleanupOldKuzuFiles,
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
type RegistryEntry,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { getGitRoot } from '../../src/storage/git.js';
|
||||
import { _captureLogger } from '../../src/core/logger.js';
|
||||
|
|
@ -3503,10 +3505,14 @@ describe('cypher result formatting', () => {
|
|||
describe('LocalBackend.resolveRepo branch scope (#2106)', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
// Per-run unique dir: a fixed shared os.tmpdir() path lets concurrent
|
||||
// vitest runs on one host rm each other's materialized sub-index stub
|
||||
// mid-test (the documented parallel-agents workflow).
|
||||
const MULTI_DIR = mkdtempSync(path.join(os.tmpdir(), 'gnx-2106-multi-'));
|
||||
const BRANCH_ENTRY = {
|
||||
name: 'multi',
|
||||
path: path.join(os.tmpdir(), 'gnx-2106-multi'),
|
||||
storagePath: path.join(os.tmpdir(), 'gnx-2106-multi', '.gitnexus'),
|
||||
path: MULTI_DIR,
|
||||
storagePath: path.join(MULTI_DIR, '.gitnexus'),
|
||||
indexedAt: '2026-06-10T12:00:00Z',
|
||||
lastCommit: 'mainsha',
|
||||
branch: 'main',
|
||||
|
|
@ -3515,25 +3521,39 @@ describe('LocalBackend.resolveRepo branch scope (#2106)', () => {
|
|||
};
|
||||
|
||||
const flatLbug = path.join(BRANCH_ENTRY.storagePath, 'lbug');
|
||||
// The pinned sub-index must exist on disk: applyBranchScope serves a
|
||||
// branches[] summary only when its lbug is really there (#2364 review F1
|
||||
// arm ii — a stale summary must not route to an adopt-deleted dir).
|
||||
const branchLbug = getStoragePaths(BRANCH_ENTRY.path, 'feature/x').lbugPath;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mkdirSync(path.dirname(branchLbug), { recursive: true });
|
||||
writeFileSync(branchLbug, 'stub');
|
||||
backend = new LocalBackend();
|
||||
(listRegisteredRepos as any).mockResolvedValue([BRANCH_ENTRY]);
|
||||
await backend.init();
|
||||
});
|
||||
|
||||
it('no branch param resolves the flat/primary lbug', async () => {
|
||||
afterEach(() => {
|
||||
rmSync(BRANCH_ENTRY.storagePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(MULTI_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('no branch param resolves the flat workspace lbug', async () => {
|
||||
const handle = await backend.resolveRepo('multi');
|
||||
expect(handle.lbugPath).toBe(flatLbug);
|
||||
});
|
||||
|
||||
it('the primary branch name resolves the flat lbug', async () => {
|
||||
it('the workspace-recorded branch name resolves the flat lbug', async () => {
|
||||
const handle = await backend.resolveRepo('multi', 'main');
|
||||
expect(handle.lbugPath).toBe(flatLbug);
|
||||
});
|
||||
|
||||
it('an indexed non-primary branch resolves a branches/<slug> lbug', async () => {
|
||||
it('an indexed pinned branch resolves a branches/<slug> lbug', async () => {
|
||||
const handle = await backend.resolveRepo('multi', 'feature/x');
|
||||
expect(handle.lbugPath).not.toBe(flatLbug);
|
||||
expect(handle.lbugPath).toContain(path.join('.gitnexus', 'branches'));
|
||||
|
|
@ -3544,6 +3564,14 @@ describe('LocalBackend.resolveRepo branch scope (#2106)', () => {
|
|||
|
||||
it('an un-indexed branch throws a clear error', async () => {
|
||||
await expect(backend.resolveRepo('multi', 'nope')).rejects.toThrow(/not indexed/i);
|
||||
// Post-#2354 guidance: a bare `analyze --branch <X>` refuses unless X is
|
||||
// checked out, so the message must lead with the checkout (#2364 F6).
|
||||
await expect(backend.resolveRepo('multi', 'nope')).rejects.toThrow(
|
||||
/workspace index follows the checked-out branch/,
|
||||
);
|
||||
await expect(backend.resolveRepo('multi', 'nope')).rejects.toThrow(
|
||||
/check out "nope" and re-run: gitnexus analyze/,
|
||||
);
|
||||
});
|
||||
|
||||
it('a legacy entry with no top-level branch still routes an indexed branch', async () => {
|
||||
|
|
@ -3555,10 +3583,11 @@ describe('LocalBackend.resolveRepo branch scope (#2106)', () => {
|
|||
expect(handle.lbugPath).toContain(path.join('.gitnexus', 'branches'));
|
||||
});
|
||||
|
||||
it('a legacy entry resolves --branch <primary> via the flat meta (#2106 R4)', async () => {
|
||||
it('a legacy entry resolves --branch <workspace-branch> via the flat meta (#2106 R4)', async () => {
|
||||
// Pre-#2106 flat index: registry entry has no `branch`/`branches`, but the
|
||||
// flat meta.json records the primary. `--branch <primary>` must resolve to
|
||||
// the flat handle (read from meta), while an unindexed branch still errors.
|
||||
// flat meta.json records the workspace branch. `--branch <that branch>`
|
||||
// must resolve to the flat handle (read from meta), while an unindexed
|
||||
// branch still errors.
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2106-legacy-'));
|
||||
const storagePath = path.join(dir, '.gitnexus');
|
||||
mkdirSync(storagePath, { recursive: true });
|
||||
|
|
@ -3579,6 +3608,132 @@ describe('LocalBackend.resolveRepo branch scope (#2106)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('a stale cached handle still resolves the restamped workspace branch via flat meta (#2354)', async () => {
|
||||
// The flat workspace slot follows the checked-out working tree: a plain
|
||||
// analyze after a branch switch restamps the flat meta.json without any
|
||||
// repo-resolution miss that would refresh a long-lived server's handle.
|
||||
// The cached handle still says branch 'main'; the on-disk flat meta is the
|
||||
// truth ('feature/z') and must win over a stale "not indexed" error.
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2354-restamp-'));
|
||||
const storagePath = path.join(dir, '.gitnexus');
|
||||
mkdirSync(storagePath, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(storagePath, 'meta.json'),
|
||||
JSON.stringify({ repoPath: dir, lastCommit: 'zzz', indexedAt: 'now', branch: 'feature/z' }),
|
||||
);
|
||||
try {
|
||||
(listRegisteredRepos as any).mockResolvedValue([
|
||||
{
|
||||
name: 'flipped',
|
||||
path: dir,
|
||||
storagePath,
|
||||
indexedAt: 'now',
|
||||
lastCommit: 'aaa',
|
||||
branch: 'main',
|
||||
},
|
||||
]);
|
||||
await backend.init();
|
||||
const handle = await backend.resolveRepo('flipped', 'feature/z');
|
||||
expect(handle.lbugPath).toBe(path.join(storagePath, 'lbug'));
|
||||
// A genuinely unindexed branch still errors (never serves the wrong DB).
|
||||
await expect(backend.resolveRepo('flipped', 'nope')).rejects.toThrow(/not indexed/i);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('a stale cached label errors instead of serving the flat handle (#2364 F1 arm i)', async () => {
|
||||
// Long-lived server cached branch 'main'; a plain analyze on feature/z
|
||||
// restamped the flat meta (and the pool reinit will hot-swap content).
|
||||
// Requesting the OLD label must error — the flat DB no longer holds main.
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2364-stale-label-'));
|
||||
const storagePath = path.join(dir, '.gitnexus');
|
||||
mkdirSync(storagePath, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(storagePath, 'meta.json'),
|
||||
JSON.stringify({ repoPath: dir, lastCommit: 'zzz', indexedAt: 'now', branch: 'feature/z' }),
|
||||
);
|
||||
try {
|
||||
const entry: RegistryEntry = {
|
||||
name: 'flipped',
|
||||
path: dir,
|
||||
storagePath,
|
||||
indexedAt: 'now',
|
||||
lastCommit: 'aaa',
|
||||
branch: 'main',
|
||||
};
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([entry]);
|
||||
await backend.init();
|
||||
const callsBefore = vi.mocked(listRegisteredRepos).mock.calls.length;
|
||||
await expect(backend.resolveRepo('flipped', 'main')).rejects.toThrow(/not indexed/i);
|
||||
// Exactly one refreshRepos fired for cache coherence (observed via its
|
||||
// unconditional first call — refreshRepos itself is private).
|
||||
expect(vi.mocked(listRegisteredRepos).mock.calls.length - callsBefore).toBe(1);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('a stale summary whose sub-index was adopted falls through to the flat handle (#2364 F1 arm ii)', async () => {
|
||||
// The cached branches[] summary still lists feature/z, but adopt deleted
|
||||
// branches/<slug>/ and the flat slot now owns the label: serve flat.
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2364-adopted-'));
|
||||
const storagePath = path.join(dir, '.gitnexus');
|
||||
mkdirSync(storagePath, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(storagePath, 'meta.json'),
|
||||
JSON.stringify({ repoPath: dir, lastCommit: 'zzz', indexedAt: 'now', branch: 'feature/z' }),
|
||||
);
|
||||
try {
|
||||
const entry: RegistryEntry = {
|
||||
name: 'adopted',
|
||||
path: dir,
|
||||
storagePath,
|
||||
indexedAt: 'now',
|
||||
lastCommit: 'aaa',
|
||||
branch: 'main',
|
||||
branches: [{ branch: 'feature/z', indexedAt: 'now', lastCommit: 'zzz' }],
|
||||
};
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([entry]);
|
||||
await backend.init();
|
||||
const handle = await backend.resolveRepo('adopted', 'feature/z');
|
||||
expect(handle.lbugPath).toBe(path.join(storagePath, 'lbug'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('a dangling summary with a disagreeing flat meta errors honestly (#2364 F3 window)', async () => {
|
||||
// Partial fast-path failure: adopt deleted the sub-index but the flat
|
||||
// meta was never restamped (saveMeta runs last). The degraded state must
|
||||
// yield the not-indexed error — no ghost route, no wrong data.
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-2364-dangling-'));
|
||||
const storagePath = path.join(dir, '.gitnexus');
|
||||
mkdirSync(storagePath, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(storagePath, 'meta.json'),
|
||||
JSON.stringify({ repoPath: dir, lastCommit: 'aaa', indexedAt: 'now', branch: 'main' }),
|
||||
);
|
||||
try {
|
||||
const entry: RegistryEntry = {
|
||||
name: 'dangling',
|
||||
path: dir,
|
||||
storagePath,
|
||||
indexedAt: 'now',
|
||||
lastCommit: 'aaa',
|
||||
branch: 'main',
|
||||
branches: [{ branch: 'feature/z', indexedAt: 'now', lastCommit: 'zzz' }],
|
||||
};
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([entry]);
|
||||
await backend.init();
|
||||
const callsBefore = vi.mocked(listRegisteredRepos).mock.calls.length;
|
||||
await expect(backend.resolveRepo('dangling', 'feature/z')).rejects.toThrow(/not indexed/i);
|
||||
expect(vi.mocked(listRegisteredRepos).mock.calls.length - callsBefore).toBe(1);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('callTool threads the branch param through resolveRepo (un-indexed branch errors)', async () => {
|
||||
// If callTool dropped `branch` from repoParams, this would resolve the flat
|
||||
// handle and NOT throw — so the rejection proves the param is threaded.
|
||||
|
|
|
|||
|
|
@ -112,16 +112,31 @@ describe('status branch rendering (#2106)', () => {
|
|||
expect(out).toContain('up-to-date');
|
||||
});
|
||||
|
||||
it('reports when the checked-out branch is not indexed', async () => {
|
||||
it('falls through to the workspace index when the branch has no pinned index (#2354)', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(getCurrentBranch as any).mockReturnValue('feature/y');
|
||||
(getCurrentCommit as any).mockReturnValue('headsha9');
|
||||
(loadMeta as any).mockResolvedValue(null); // feature/y has no index
|
||||
(loadMeta as any).mockResolvedValue(null); // feature/y has no pinned index
|
||||
|
||||
await statusCommand();
|
||||
const out = output();
|
||||
expect(out).toContain('Branch: feature/y');
|
||||
expect(out).toContain('current branch not indexed');
|
||||
// The flat workspace index (last analyzed on main) is reported, with the
|
||||
// commit comparison saying it lags this branch's tree.
|
||||
expect(out).toContain("Workspace index: last analyzed on 'main'");
|
||||
expect(out).toContain('stale');
|
||||
});
|
||||
|
||||
it('same-commit branch flip reports up-to-date against the workspace index (#2354)', async () => {
|
||||
(findRepo as any).mockResolvedValue(baseRepo);
|
||||
(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();
|
||||
expect(out).toContain("Workspace index: last analyzed on 'main'");
|
||||
expect(out).toContain('up-to-date');
|
||||
});
|
||||
|
||||
it('compares against the branch index when the current branch has one', async () => {
|
||||
|
|
|
|||
134
gitnexus/test/unit/repo-manager-rm-failure.test.ts
Normal file
134
gitnexus/test/unit/repo-manager-rm-failure.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* rm-failure paths for adoptFlatBranchLabel (#2364 review F4).
|
||||
* Separate from repo-manager.test.ts: Vitest cannot vi.spyOn ESM namespace
|
||||
* exports of fs/promises; a delegating vi.mock is required for mock rejects
|
||||
* (same split as repo-manager-ensure-ignore-readonly.test.ts, #1549).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
|
||||
const fsCtx = vi.hoisted(() => ({
|
||||
rmMock: vi.fn(),
|
||||
realRm: null as ((...args: unknown[]) => Promise<unknown>) | null,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs/promises')>();
|
||||
const d = actual.default;
|
||||
fsCtx.realRm = d.rm.bind(d);
|
||||
fsCtx.rmMock.mockImplementation((...args) => fsCtx.realRm!(...args));
|
||||
return {
|
||||
default: new Proxy(d, {
|
||||
get(target, prop) {
|
||||
if (prop === 'rm') return fsCtx.rmMock;
|
||||
const v = Reflect.get(target, prop, target) as unknown;
|
||||
return typeof v === 'function' ? (v as (...args: unknown[]) => unknown).bind(target) : v;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import {
|
||||
adoptFlatBranchLabel,
|
||||
registerRepo,
|
||||
listRegisteredRepos,
|
||||
getStoragePaths,
|
||||
saveMeta,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { _captureLogger } from '../../src/core/logger.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
describe('adoptFlatBranchLabel — rm failure keeps the branch summary (#2364 F4)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedGitnexusHome: string | undefined;
|
||||
|
||||
const metaFor = (branch: string, lastCommit: string): RepoMeta => ({
|
||||
repoPath: '',
|
||||
lastCommit,
|
||||
indexedAt: '2026-07-03T12:00:00.000Z',
|
||||
branch,
|
||||
stats: { files: 1, nodes: 1 },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-rm-failure-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-rm-failure-repo-');
|
||||
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
fsCtx.rmMock.mockClear();
|
||||
fsCtx.rmMock.mockImplementation((...args) => fsCtx.realRm!(...args));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
});
|
||||
|
||||
it('keeps the summary, warns with the errno, and still restamps the label on EBUSY', async () => {
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
||||
const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222'));
|
||||
|
||||
const cap = _captureLogger();
|
||||
fsCtx.rmMock.mockRejectedValueOnce(Object.assign(new Error('mock busy'), { code: 'EBUSY' }));
|
||||
try {
|
||||
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
|
||||
const [entry] = await listRegisteredRepos();
|
||||
// The informational label still restamps…
|
||||
expect(entry.branch).toBe('feature/x');
|
||||
// …but the summary survives so `clean --branch` can still target the dir…
|
||||
expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/x']);
|
||||
// …which is still on disk.
|
||||
await expect(fs.access(path.dirname(metaPath))).resolves.toBeUndefined();
|
||||
expect(
|
||||
cap
|
||||
.records()
|
||||
.some(
|
||||
(r) =>
|
||||
r.level === 40 &&
|
||||
r.code === 'EBUSY' &&
|
||||
typeof r.path === 'string' &&
|
||||
String(r.msg ?? '').includes('clean --branch'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('a later adopt retries the rm and drops the summary once the dir is gone', async () => {
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
||||
const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222'));
|
||||
|
||||
fsCtx.rmMock.mockRejectedValueOnce(Object.assign(new Error('mock busy'), { code: 'EBUSY' }));
|
||||
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
||||
// Retry with the real rm restored: cleanup completes.
|
||||
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
||||
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branch).toBe('feature/x');
|
||||
expect(entry.branches).toBeUndefined();
|
||||
await expect(fs.access(path.dirname(metaPath))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('treats a never-materialized sub-index as gone (summary dropped, idempotent)', async () => {
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
||||
// No saveMeta for the sub-index: nothing on disk, force:true rm is a no-op.
|
||||
|
||||
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
||||
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branch).toBe('feature/x');
|
||||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
loadCLIConfig,
|
||||
registerRepo,
|
||||
removeBranchIndex,
|
||||
adoptFlatBranchLabel,
|
||||
listRegisteredRepos,
|
||||
resolveRegistryEntry,
|
||||
canonicalizePath,
|
||||
|
|
@ -133,6 +134,9 @@ describe('branchSlug (#2106)', () => {
|
|||
});
|
||||
|
||||
// ─── resolveBranchPlacement (#2106 KTD2) ─────────────────────────────
|
||||
// Since #2354 only explicit `--branch` runs consult this (a plain analyze
|
||||
// always targets the flat workspace slot); these cases pin the explicit-run
|
||||
// contract.
|
||||
|
||||
describe('resolveBranchPlacement (#2106)', () => {
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
|
|
@ -172,7 +176,7 @@ describe('resolveBranchPlacement (#2106)', () => {
|
|||
expect(await resolveBranchPlacement(tmpRepo.dbPath, 'main')).toEqual({});
|
||||
});
|
||||
|
||||
it('non-primary checked-out branch → its own sub-directory', async () => {
|
||||
it('explicit label differing from the recorded flat branch → its own sub-directory', async () => {
|
||||
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
|
||||
await saveMeta(storagePath, baseMeta('main'));
|
||||
expect(await resolveBranchPlacement(tmpRepo.dbPath, 'feature')).toEqual({ branch: 'feature' });
|
||||
|
|
@ -967,6 +971,50 @@ describe('registerRepo branch nesting (#2106)', () => {
|
|||
expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/y']);
|
||||
});
|
||||
|
||||
// ─── adoptFlatBranchLabel (#2354) ───────────────────────────────────
|
||||
|
||||
it('adoptFlatBranchLabel relabels the entry and removes a shadowed sub-index', async () => {
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
||||
// Materialize the pinned sub-index on disk so the shadow cleanup has a
|
||||
// real directory to remove.
|
||||
const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222'));
|
||||
|
||||
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
||||
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branch).toBe('feature/x');
|
||||
expect(entry.branches).toBeUndefined(); // shadowed summary dropped
|
||||
await expect(fs.access(path.dirname(metaPath))).rejects.toThrow(); // dir deleted
|
||||
});
|
||||
|
||||
it('adoptFlatBranchLabel keeps other pinned branch summaries', async () => {
|
||||
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 adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
||||
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branch).toBe('feature/x');
|
||||
expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/y']);
|
||||
});
|
||||
|
||||
it('adoptFlatBranchLabel never self-heals an unregistered repo', async () => {
|
||||
// No registerRepo call — the registry has no entry for this path (#2264/#1169).
|
||||
// The no-op must cover the disk too: a materialized pinned sub-index
|
||||
// survives, because the shadow rm only runs for registered repos
|
||||
// (#2364 review F2 — the rm used to fire before the registry check).
|
||||
const { metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
await saveMeta(path.dirname(metaPath), metaFor('feature/x', 'bbb2222'));
|
||||
|
||||
await adoptFlatBranchLabel(tmpRepo.dbPath, 'feature/x');
|
||||
|
||||
expect(await listRegisteredRepos()).toHaveLength(0);
|
||||
await expect(fs.access(path.dirname(metaPath))).resolves.toBeUndefined(); // dir survives
|
||||
});
|
||||
|
||||
// ─── re-read-before-write merge (#2106 R9) ──────────────────────────
|
||||
|
||||
it('a branch run preserves the freshest top-level fields (alias survives)', async () => {
|
||||
|
|
|
|||
163
gitnexus/test/unit/run-analyze-adopt-failure.test.ts
Normal file
163
gitnexus/test/unit/run-analyze-adopt-failure.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Fast-path restamp failure modes (#2364 review F3, test gaps 4 and 7).
|
||||
* Separate from run-analyze.test.ts, which stays pure-real: these scenarios
|
||||
* need a delegating vi.mock of repo-manager (vi.spyOn cannot intercept ESM
|
||||
* namespace exports) to make adoptFlatBranchLabel / saveMeta fail on demand.
|
||||
*
|
||||
* Once-mock starvation hazard: the delegating mock intercepts EVERY
|
||||
* repo-manager call in the process, including this file's own fixture setup
|
||||
* (saveMeta seeds metas) — arm mockRejectedValueOnce only AFTER setup,
|
||||
* immediately before the runFullAnalysis call under test.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
type RepoManagerModule = typeof import('../../src/storage/repo-manager.js');
|
||||
|
||||
const rmCtx = vi.hoisted(() => ({
|
||||
adoptMock: vi.fn(),
|
||||
saveMetaMock: vi.fn(),
|
||||
realAdopt: null as RepoManagerModule['adoptFlatBranchLabel'] | null,
|
||||
realSaveMeta: null as RepoManagerModule['saveMeta'] | null,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<RepoManagerModule>();
|
||||
rmCtx.realAdopt = actual.adoptFlatBranchLabel;
|
||||
rmCtx.realSaveMeta = actual.saveMeta;
|
||||
rmCtx.adoptMock.mockImplementation(actual.adoptFlatBranchLabel);
|
||||
rmCtx.saveMetaMock.mockImplementation(actual.saveMeta);
|
||||
return {
|
||||
...actual,
|
||||
adoptFlatBranchLabel: rmCtx.adoptMock,
|
||||
saveMeta: rmCtx.saveMetaMock,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
getStoragePaths,
|
||||
registerRepo,
|
||||
loadMeta,
|
||||
INCREMENTAL_SCHEMA_VERSION,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { runFullAnalysis } from '../../src/core/run-analyze.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
describe('fast-path restamp failure modes (#2364 F3)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedGitnexusHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-adopt-failure-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-adopt-failure-repo-');
|
||||
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
rmCtx.adoptMock.mockReset();
|
||||
rmCtx.saveMetaMock.mockReset();
|
||||
rmCtx.adoptMock.mockImplementation(
|
||||
(...args: Parameters<RepoManagerModule['adoptFlatBranchLabel']>) => rmCtx.realAdopt!(...args),
|
||||
);
|
||||
rmCtx.saveMetaMock.mockImplementation((...args: Parameters<RepoManagerModule['saveMeta']>) =>
|
||||
rmCtx.realSaveMeta!(...args),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
});
|
||||
|
||||
/** git repo on feature/x at one commit, flat meta labeled main, pinned feature/x sub-index, registered. */
|
||||
const seedFlippedWorkspace = async (): Promise<{
|
||||
flatStorage: string;
|
||||
branchMetaDir: string;
|
||||
}> => {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
execSync('git branch -M main', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git checkout -b feature/x', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
const commit = execSync('git rev-parse HEAD', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const metaFor = (branch: string): RepoMeta => ({
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: commit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
branch,
|
||||
schemaVersion: INCREMENTAL_SCHEMA_VERSION,
|
||||
});
|
||||
const flat = getStoragePaths(tmpRepo.dbPath);
|
||||
await rmCtx.realSaveMeta!(flat.storagePath, metaFor('main'));
|
||||
const branch = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
await rmCtx.realSaveMeta!(path.dirname(branch.metaPath), metaFor('feature/x'));
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('main'));
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
return { flatStorage: flat.storagePath, branchMetaDir: path.dirname(branch.metaPath) };
|
||||
};
|
||||
|
||||
it('a failed adopt keeps the retry guard true and the next run self-heals (gap 4)', async () => {
|
||||
const { flatStorage, branchMetaDir } = await seedFlippedWorkspace();
|
||||
const logs: string[] = [];
|
||||
|
||||
rmCtx.adoptMock.mockRejectedValueOnce(new Error('mock adopt failure'));
|
||||
const first = await runFullAnalysis(tmpRepo.dbPath, {}, { onLog: (m) => logs.push(m) });
|
||||
|
||||
expect(first.alreadyUpToDate).toBe(true);
|
||||
expect(logs.some((m) => m.includes('could not restamp the workspace branch label'))).toBe(true);
|
||||
// saveMeta runs AFTER adopt, so the failed sync left the guard untouched…
|
||||
const stale = await loadMeta(flatStorage);
|
||||
expect(stale?.branch).toBe('main');
|
||||
await expect(fs.access(branchMetaDir)).resolves.toBeUndefined();
|
||||
|
||||
// …and the next same-commit run retries and completes the whole sync.
|
||||
const second = await runFullAnalysis(tmpRepo.dbPath, {}, {});
|
||||
expect(second.alreadyUpToDate).toBe(true);
|
||||
const healed = await loadMeta(flatStorage);
|
||||
expect(healed?.branch).toBe('feature/x');
|
||||
await expect(fs.access(branchMetaDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('adopt is invoked before the meta restamp on a successful flip', async () => {
|
||||
const { flatStorage } = await seedFlippedWorkspace();
|
||||
rmCtx.adoptMock.mockClear();
|
||||
rmCtx.saveMetaMock.mockClear();
|
||||
|
||||
const result = await runFullAnalysis(tmpRepo.dbPath, {}, {});
|
||||
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(rmCtx.adoptMock).toHaveBeenCalledTimes(1);
|
||||
expect(rmCtx.saveMetaMock).toHaveBeenCalledTimes(1);
|
||||
expect(rmCtx.adoptMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
rmCtx.saveMetaMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
const meta = await loadMeta(flatStorage);
|
||||
expect(meta?.branch).toBe('feature/x');
|
||||
});
|
||||
|
||||
it.each(['EROFS', 'EACCES', 'EPERM'] as const)(
|
||||
'"Already up to date" still succeeds when the restamp hits %s (#1549, gap 7)',
|
||||
async (code) => {
|
||||
const { flatStorage } = await seedFlippedWorkspace();
|
||||
const logs: string[] = [];
|
||||
|
||||
rmCtx.saveMetaMock.mockRejectedValueOnce(Object.assign(new Error('mock ro'), { code }));
|
||||
const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onLog: (m) => logs.push(m) });
|
||||
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(logs.some((m) => m.includes('read-only') && m.includes('#1549'))).toBe(true);
|
||||
// The stamp never landed, so the guard stays true for the next run.
|
||||
const meta = await loadMeta(flatStorage);
|
||||
expect(meta?.branch).toBe('main');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
} from '../../src/core/embedding-mode.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
registerRepo,
|
||||
saveMeta,
|
||||
INCREMENTAL_SCHEMA_VERSION,
|
||||
type RepoMeta,
|
||||
|
|
@ -72,7 +74,170 @@ describe('run-analyze module', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('reports isPrimaryBranch false for an up-to-date non-primary branch (#2106 R2)', async () => {
|
||||
it('plain analyze on another branch adopts the flat workspace slot (#2354)', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-workspace-');
|
||||
const tmpHome = await createTempDir('gitnexus-run-analyze-workspace-home-');
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
try {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
execSync('git branch -M main', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git checkout -b feature/x', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
const commit = execSync('git rev-parse HEAD', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
// Flat slot last analyzed on main; feature/x also has a pinned sub-index.
|
||||
// Both metas stamp the current schema version so the run-analyze
|
||||
// schema-mismatch guard (#2289 P1) does not force a rebuild before the
|
||||
// fast path runs.
|
||||
const flat = getStoragePaths(tmpRepo.dbPath);
|
||||
const flatMetaSeed: RepoMeta = {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: commit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
branch: 'main',
|
||||
schemaVersion: INCREMENTAL_SCHEMA_VERSION,
|
||||
};
|
||||
await saveMeta(flat.storagePath, flatMetaSeed);
|
||||
const branch = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
await saveMeta(path.dirname(branch.metaPath), {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: commit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
branch: 'feature/x',
|
||||
schemaVersion: INCREMENTAL_SCHEMA_VERSION,
|
||||
});
|
||||
// Register the repo in an isolated registry: the shadow cleanup only
|
||||
// runs for registered repos (#2364 review F2 — unregistered repos must
|
||||
// never lose a pinned sub-index).
|
||||
await registerRepo(tmpRepo.dbPath, flatMetaSeed);
|
||||
await registerRepo(
|
||||
tmpRepo.dbPath,
|
||||
{ ...flatMetaSeed, branch: 'feature/x' },
|
||||
{ branch: 'feature/x' },
|
||||
);
|
||||
|
||||
// A plain analyze ignores the pinned sub-index and serves the flat
|
||||
// workspace slot; the same-commit clean-tree fast path restamps the
|
||||
// slot's branch label and removes the now-shadowed sub-index.
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} });
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(result.isPrimaryBranch).toBe(true);
|
||||
const flatMeta = await loadMeta(flat.storagePath);
|
||||
expect(flatMeta?.branch).toBe('feature/x');
|
||||
await expect(fs.access(path.dirname(branch.metaPath))).rejects.toThrow();
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('the fast-path restamp leaves an unregistered repo pinned sub-index intact (#2364 F2)', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-unregistered-');
|
||||
const tmpHome = await createTempDir('gitnexus-run-analyze-unregistered-home-');
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
try {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
execSync('git branch -M main', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git checkout -b feature/x', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
const commit = execSync('git rev-parse HEAD', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
const flat = getStoragePaths(tmpRepo.dbPath);
|
||||
await saveMeta(flat.storagePath, {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: commit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
branch: 'main',
|
||||
schemaVersion: INCREMENTAL_SCHEMA_VERSION,
|
||||
});
|
||||
const branch = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
await saveMeta(path.dirname(branch.metaPath), {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: commit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
branch: 'feature/x',
|
||||
schemaVersion: INCREMENTAL_SCHEMA_VERSION,
|
||||
});
|
||||
// Deliberately NO registerRepo: the empty isolated registry makes this
|
||||
// repo unregistered, so the adopt must be a full no-op on disk
|
||||
// (#2264/#1169 no-self-heal, #2364 review F2).
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} });
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
const flatMeta = await loadMeta(flat.storagePath);
|
||||
// The informational flat label still restamps…
|
||||
expect(flatMeta?.branch).toBe('feature/x');
|
||||
// …but the pinned sub-index survives untouched.
|
||||
await expect(fs.access(path.dirname(branch.metaPath))).resolves.toBeUndefined();
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('a detached HEAD at the same commit skips the fast-path restamp (#2364 F3 gap 6)', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-detached-');
|
||||
const tmpHome = await createTempDir('gitnexus-run-analyze-detached-home-');
|
||||
const savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
try {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
execSync('git branch -M main', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
execSync('git checkout --detach', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
const commit = execSync('git rev-parse HEAD', {
|
||||
cwd: tmpRepo.dbPath,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
const flat = getStoragePaths(tmpRepo.dbPath);
|
||||
await saveMeta(flat.storagePath, {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
lastCommit: commit,
|
||||
indexedAt: new Date().toISOString(),
|
||||
branch: 'main',
|
||||
schemaVersion: INCREMENTAL_SCHEMA_VERSION,
|
||||
});
|
||||
|
||||
// Detached HEAD → branchLabel is null → the restamp block must not
|
||||
// fire: the existing stamp survives, mirroring the end-of-run write.
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} });
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
const flatMeta = await loadMeta(flat.storagePath);
|
||||
expect(flatMeta?.branch).toBe('main');
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports isPrimaryBranch false for an up-to-date explicit --branch run (#2106 R2)', async () => {
|
||||
const tmpRepo = await createTempDir('gitnexus-run-analyze-nonprimary-');
|
||||
try {
|
||||
execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' });
|
||||
|
|
@ -87,10 +252,8 @@ describe('run-analyze module', () => {
|
|||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
|
||||
// Flat slot owned by main; feature/x has its own up-to-date branch index.
|
||||
// Both metas stamp the current schema version so the run-analyze
|
||||
// schema-mismatch guard (#2289 P1) does not force a rebuild before the
|
||||
// fast path runs.
|
||||
// Flat slot recorded for main; feature/x has its own up-to-date pinned
|
||||
// sub-index, so an explicit `--branch feature/x` run routes there.
|
||||
const flat = getStoragePaths(tmpRepo.dbPath);
|
||||
await saveMeta(flat.storagePath, {
|
||||
repoPath: tmpRepo.dbPath,
|
||||
|
|
@ -109,9 +272,15 @@ describe('run-analyze module', () => {
|
|||
});
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} });
|
||||
const result = await runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ branch: 'feature/x' },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
expect(result.isPrimaryBranch).toBe(false);
|
||||
// The pinned sub-index is untouched by an explicit branch run.
|
||||
await expect(fs.access(path.dirname(branch.metaPath))).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await tmpRepo.cleanup();
|
||||
}
|
||||
|
|
@ -213,36 +382,6 @@ describe('collectBranchCacheKeys (#2106 R6)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('primaryInversionWarning (#2106 R8)', () => {
|
||||
it('warns when the default branch is not the flat-slot owner', async () => {
|
||||
const { primaryInversionWarning } = await import('../../src/core/run-analyze.js');
|
||||
const w = primaryInversionWarning('main', 'feature/x');
|
||||
expect(w).toContain('default branch "main"');
|
||||
expect(w).toContain('"feature/x" owns the flat slot');
|
||||
expect(w).toContain('clean --branch feature/x');
|
||||
});
|
||||
|
||||
it('does not warn when the default branch is null (no origin/HEAD)', async () => {
|
||||
const { primaryInversionWarning } = await import('../../src/core/run-analyze.js');
|
||||
expect(primaryInversionWarning(null, 'feature/x')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not warn when the default owns the flat slot', async () => {
|
||||
const { primaryInversionWarning } = await import('../../src/core/run-analyze.js');
|
||||
expect(primaryInversionWarning('main', 'main')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('trims both sides so trivial whitespace does not false-warn', async () => {
|
||||
const { primaryInversionWarning } = await import('../../src/core/run-analyze.js');
|
||||
expect(primaryInversionWarning(' main ', 'main')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not warn when there is no flat owner yet', async () => {
|
||||
const { primaryInversionWarning } = await import('../../src/core/run-analyze.js');
|
||||
expect(primaryInversionWarning('main', undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveEmbeddingMode', () => {
|
||||
// Default `analyze` on a repo with existing embeddings: must preserve, must
|
||||
// NOT regenerate, must load the cache so phase 3.5 can re-insert vectors.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue