gitnexus 1.6.9-rc.45 → 1.6.9-rc.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli/analyze.js +5 -4
- package/dist/cli/i18n/en.d.ts +2 -2
- package/dist/cli/i18n/en.js +2 -2
- package/dist/cli/i18n/resources.d.ts +3 -3
- package/dist/cli/i18n/zh-CN.d.ts +1 -1
- package/dist/cli/i18n/zh-CN.js +2 -2
- package/dist/cli/index.js +4 -4
- package/dist/cli/status.js +9 -9
- package/dist/core/run-analyze.d.ts +12 -18
- package/dist/core/run-analyze.js +61 -40
- package/dist/mcp/local/local-backend.d.ts +22 -8
- package/dist/mcp/local/local-backend.js +91 -26
- package/dist/mcp/tools.js +4 -3
- package/dist/storage/branch-index.d.ts +12 -10
- package/dist/storage/branch-index.js +12 -10
- package/dist/storage/repo-manager.d.ts +19 -0
- package/dist/storage/repo-manager.js +82 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -144,7 +144,7 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
|
|
|
144
144
|
| `group_list` | List configured repository groups |
|
|
145
145
|
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
|
|
146
146
|
|
|
147
|
-
> 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
|
|
147
|
+
> 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`.
|
|
148
148
|
|
|
149
149
|
## MCP Resources
|
|
150
150
|
|
package/dist/cli/analyze.js
CHANGED
|
@@ -1005,10 +1005,11 @@ const analyzeCommandImpl = async (inputPath, cliOptions) => {
|
|
|
1005
1005
|
// preserving the rest of the block (incl. --skills community rows). No-op
|
|
1006
1006
|
// when the value already matches, so a routine up-to-date run is silent
|
|
1007
1007
|
// (#1996 tri-review P2).
|
|
1008
|
-
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the
|
|
1009
|
-
//
|
|
1010
|
-
// analyze must not churn the committed AGENTS.md — this
|
|
1011
|
-
// in-pipeline `if (!placement.branch)` gate around
|
|
1008
|
+
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the flat
|
|
1009
|
+
// WORKSPACE index (#2106 R2, #2354). A pinned --branch sub-index's
|
|
1010
|
+
// up-to-date analyze must not churn the committed AGENTS.md — this
|
|
1011
|
+
// mirrors the in-pipeline `if (!placement.branch)` gate around
|
|
1012
|
+
// generateAIContextFiles.
|
|
1012
1013
|
let baseRefRefreshed = [];
|
|
1013
1014
|
if (result.isPrimaryBranch !== false) {
|
|
1014
1015
|
try {
|
package/dist/cli/i18n/en.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare const en: {
|
|
|
28
28
|
readonly 'status.currentCommit': "Current commit";
|
|
29
29
|
readonly 'status.branch': "Branch";
|
|
30
30
|
readonly 'status.detached': "(detached HEAD)";
|
|
31
|
-
readonly 'status.
|
|
31
|
+
readonly 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)";
|
|
32
32
|
readonly 'status.status': "Status";
|
|
33
33
|
readonly 'status.upToDate': "✅ up-to-date";
|
|
34
34
|
readonly 'status.stale': "⚠️ stale (re-run gitnexus analyze)";
|
|
@@ -172,7 +172,7 @@ export declare const en: {
|
|
|
172
172
|
readonly 'help.option.force.confirmation': "Skip confirmation prompt";
|
|
173
173
|
readonly 'help.option.uninstall.force': "Apply the changes (default is a dry-run preview)";
|
|
174
174
|
readonly 'help.option.clean.all': "Clean all indexed repos";
|
|
175
|
-
readonly 'help.option.clean.branch': "Delete only the named branch index (not the
|
|
175
|
+
readonly 'help.option.clean.branch': "Delete only the named branch index (not the workspace index)";
|
|
176
176
|
readonly 'help.option.clean.lbugSidecars': "Clean quarantined LadybugDB missing-shadow WAL sidecars";
|
|
177
177
|
readonly 'help.option.wiki.force': "Force full regeneration even if up to date";
|
|
178
178
|
readonly 'help.option.wiki.provider': "LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)";
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -28,7 +28,7 @@ export const en = {
|
|
|
28
28
|
'status.currentCommit': 'Current commit',
|
|
29
29
|
'status.branch': 'Branch',
|
|
30
30
|
'status.detached': '(detached HEAD)',
|
|
31
|
-
'status.
|
|
31
|
+
'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)",
|
|
32
32
|
'status.status': 'Status',
|
|
33
33
|
'status.upToDate': '✅ up-to-date',
|
|
34
34
|
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
|
|
@@ -172,7 +172,7 @@ export const en = {
|
|
|
172
172
|
'help.option.force.confirmation': 'Skip confirmation prompt',
|
|
173
173
|
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
|
|
174
174
|
'help.option.clean.all': 'Clean all indexed repos',
|
|
175
|
-
'help.option.clean.branch': 'Delete only the named branch index (not the
|
|
175
|
+
'help.option.clean.branch': 'Delete only the named branch index (not the workspace index)',
|
|
176
176
|
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
|
|
177
177
|
'help.option.wiki.force': 'Force full regeneration even if up to date',
|
|
178
178
|
'help.option.wiki.provider': 'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)',
|
|
@@ -29,7 +29,7 @@ export declare const cliResources: {
|
|
|
29
29
|
readonly 'status.currentCommit': "Current commit";
|
|
30
30
|
readonly 'status.branch': "Branch";
|
|
31
31
|
readonly 'status.detached': "(detached HEAD)";
|
|
32
|
-
readonly 'status.
|
|
32
|
+
readonly 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)";
|
|
33
33
|
readonly 'status.status': "Status";
|
|
34
34
|
readonly 'status.upToDate': "✅ up-to-date";
|
|
35
35
|
readonly 'status.stale': "⚠️ stale (re-run gitnexus analyze)";
|
|
@@ -173,7 +173,7 @@ export declare const cliResources: {
|
|
|
173
173
|
readonly 'help.option.force.confirmation': "Skip confirmation prompt";
|
|
174
174
|
readonly 'help.option.uninstall.force': "Apply the changes (default is a dry-run preview)";
|
|
175
175
|
readonly 'help.option.clean.all': "Clean all indexed repos";
|
|
176
|
-
readonly 'help.option.clean.branch': "Delete only the named branch index (not the
|
|
176
|
+
readonly 'help.option.clean.branch': "Delete only the named branch index (not the workspace index)";
|
|
177
177
|
readonly 'help.option.clean.lbugSidecars': "Clean quarantined LadybugDB missing-shadow WAL sidecars";
|
|
178
178
|
readonly 'help.option.wiki.force': "Force full regeneration even if up to date";
|
|
179
179
|
readonly 'help.option.wiki.provider': "LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)";
|
|
@@ -271,7 +271,7 @@ export declare const cliResources: {
|
|
|
271
271
|
'status.currentCommit': string;
|
|
272
272
|
'status.branch': string;
|
|
273
273
|
'status.detached': string;
|
|
274
|
-
'status.
|
|
274
|
+
'status.workspaceIndexLabel': string;
|
|
275
275
|
'status.status': string;
|
|
276
276
|
'status.upToDate': string;
|
|
277
277
|
'status.stale': string;
|
package/dist/cli/i18n/zh-CN.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare const zhCN: {
|
|
|
28
28
|
'status.currentCommit': string;
|
|
29
29
|
'status.branch': string;
|
|
30
30
|
'status.detached': string;
|
|
31
|
-
'status.
|
|
31
|
+
'status.workspaceIndexLabel': string;
|
|
32
32
|
'status.status': string;
|
|
33
33
|
'status.upToDate': string;
|
|
34
34
|
'status.stale': string;
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -28,7 +28,7 @@ export const zhCN = {
|
|
|
28
28
|
'status.currentCommit': '当前提交',
|
|
29
29
|
'status.branch': '分支',
|
|
30
30
|
'status.detached': '(分离 HEAD)',
|
|
31
|
-
'status.
|
|
31
|
+
'status.workspaceIndexLabel': "工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)",
|
|
32
32
|
'status.status': '状态',
|
|
33
33
|
'status.upToDate': '✅ 已是最新',
|
|
34
34
|
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
|
|
@@ -172,7 +172,7 @@ export const zhCN = {
|
|
|
172
172
|
'help.option.force.confirmation': '跳过确认提示',
|
|
173
173
|
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
|
|
174
174
|
'help.option.clean.all': '清理所有已索引仓库',
|
|
175
|
-
'help.option.clean.branch': '
|
|
175
|
+
'help.option.clean.branch': '仅删除指定分支的索引(不影响工作区索引)',
|
|
176
176
|
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
|
|
177
177
|
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
|
178
178
|
'help.option.wiki.provider': 'LLM 提供商:openai、openrouter、azure、custom、cursor、claude、codex 或 opencode(默认:openai)',
|
package/dist/cli/index.js
CHANGED
|
@@ -49,9 +49,9 @@ program
|
|
|
49
49
|
'for supported languages. Opt-in; off by default. (#2081 M1)')
|
|
50
50
|
.option('--default-branch <branch>', 'Default branch used in the generated regression-compare example (base_ref). ' +
|
|
51
51
|
'Falls back to .gitnexusrc, then auto-detected origin/HEAD, then "main".')
|
|
52
|
-
.option('--branch <name>', '
|
|
53
|
-
'
|
|
54
|
-
'
|
|
52
|
+
.option('--branch <name>', 'Pin the working tree into a dedicated per-branch index slot (multi-branch indexing). ' +
|
|
53
|
+
'Without this flag, analyze always updates the workspace index, which follows the ' +
|
|
54
|
+
'checked-out working tree. Distinct from --default-branch (cosmetic base_ref).')
|
|
55
55
|
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
|
56
56
|
.option('--skip-skills', 'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
|
|
57
57
|
'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
|
|
@@ -158,7 +158,7 @@ program
|
|
|
158
158
|
.description('Delete GitNexus index for current repo')
|
|
159
159
|
.option('-f, --force', 'Skip confirmation prompt')
|
|
160
160
|
.option('--all', 'Clean all indexed repos')
|
|
161
|
-
.option('--branch <name>', 'Delete only the named branch index (not the
|
|
161
|
+
.option('--branch <name>', 'Delete only the named branch index (not the workspace index)')
|
|
162
162
|
.option('--lbug-sidecars', 'Clean quarantined LadybugDB missing-shadow WAL sidecars')
|
|
163
163
|
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
|
|
164
164
|
program
|
package/dist/cli/status.js
CHANGED
|
@@ -30,25 +30,25 @@ export const statusCommand = async () => {
|
|
|
30
30
|
}
|
|
31
31
|
const currentCommit = getCurrentCommit(repo.repoPath);
|
|
32
32
|
const currentBranch = getCurrentBranch(repo.repoPath);
|
|
33
|
-
// Pick the index matching the checked-out branch (#2106).
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
33
|
+
// Pick the index matching the checked-out branch (#2106/#2354). A pinned
|
|
34
|
+
// `--branch` sub-index for the current branch wins; otherwise report the
|
|
35
|
+
// flat workspace index, which follows the checked-out working tree — the
|
|
36
|
+
// commit comparison below then says whether it needs a re-analyze. Legacy/
|
|
37
|
+
// no-branch metas and detached HEAD also fall through to the flat index.
|
|
37
38
|
let activeMeta = repo.meta;
|
|
38
|
-
let
|
|
39
|
+
let workspaceLagsBranch = false;
|
|
39
40
|
if (currentBranch && repo.meta.branch && currentBranch !== repo.meta.branch) {
|
|
40
41
|
const { metaPath } = getStoragePaths(repo.repoPath, currentBranch);
|
|
41
42
|
const branchMeta = await loadMeta(path.dirname(metaPath));
|
|
42
43
|
if (branchMeta)
|
|
43
44
|
activeMeta = branchMeta;
|
|
44
45
|
else
|
|
45
|
-
|
|
46
|
+
workspaceLagsBranch = true;
|
|
46
47
|
}
|
|
47
48
|
console.log(`${t('status.repository')}: ${repo.repoPath}`);
|
|
48
49
|
console.log(`${t('status.branch')}: ${currentBranch ?? t('status.detached')}`);
|
|
49
|
-
if (
|
|
50
|
-
console.log(
|
|
51
|
-
return;
|
|
50
|
+
if (workspaceLagsBranch) {
|
|
51
|
+
console.log(t('status.workspaceIndexLabel', { primary: repo.meta.branch ?? '' }));
|
|
52
52
|
}
|
|
53
53
|
const isUpToDate = currentCommit === activeMeta.lastCommit;
|
|
54
54
|
console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
|
|
@@ -103,13 +103,13 @@ export interface AnalyzeOptions {
|
|
|
103
103
|
*/
|
|
104
104
|
defaultBranch?: string;
|
|
105
105
|
/**
|
|
106
|
-
* Index-branch selector (#2106). Distinct from `defaultBranch` (which
|
|
107
|
-
* affects generated AGENTS.md/CLAUDE.md base_ref text). When set, this
|
|
108
|
-
*
|
|
109
|
-
* the
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
106
|
+
* Index-branch selector (#2106, #2354). Distinct from `defaultBranch` (which
|
|
107
|
+
* only affects generated AGENTS.md/CLAUDE.md base_ref text). When set, this
|
|
108
|
+
* run is pinned to a per-branch index slot (`branches/<slug>/`) unless the
|
|
109
|
+
* label matches the flat slot's recorded branch. When `undefined`, the run
|
|
110
|
+
* always targets the flat workspace slot, which follows the checked-out
|
|
111
|
+
* working tree; the auto-detected branch is only recorded as the slot's
|
|
112
|
+
* informational label. Detached HEAD / non-git also map to the flat slot.
|
|
113
113
|
*/
|
|
114
114
|
branch?: string;
|
|
115
115
|
/**
|
|
@@ -176,10 +176,12 @@ export interface AnalyzeResult {
|
|
|
176
176
|
*/
|
|
177
177
|
ftsSkipped?: boolean;
|
|
178
178
|
/**
|
|
179
|
-
* True when the index this run produced/validated is the
|
|
180
|
-
* (#2106 R2
|
|
181
|
-
*
|
|
179
|
+
* True when the index this run produced/validated is the flat workspace
|
|
180
|
+
* slot (#2106 R2, inverted by #2354 to follow the checked-out branch).
|
|
181
|
+
* `false` for a pinned `--branch` sub-index. Lets the CLI skip repo-root
|
|
182
|
+
* AGENTS.md/CLAUDE.md refreshes (e.g. the base_ref fast-path) for a pinned
|
|
182
183
|
* branch analyze, mirroring the in-pipeline `if (!placement.branch)` gate.
|
|
184
|
+
* (The historical "primary" name is kept — it is public API surface.)
|
|
183
185
|
*/
|
|
184
186
|
isPrimaryBranch?: boolean;
|
|
185
187
|
}
|
|
@@ -197,14 +199,6 @@ export declare const PHASE_LABELS: Record<string, string>;
|
|
|
197
199
|
* the {@link AnalyzeCallbacks} interface — it never writes to stdout/stderr
|
|
198
200
|
* directly and never calls `process.exit()`.
|
|
199
201
|
*/
|
|
200
|
-
/**
|
|
201
|
-
* Build the primary-inversion warning (#2106 R8), or `undefined` when there is
|
|
202
|
-
* nothing to warn about. Pure + exported for testing. Both inputs are trimmed
|
|
203
|
-
* (a diagnostic — a missed warning is low-harm; a false warning is the thing to
|
|
204
|
-
* avoid). `defaultBranch` is the repo's `origin/HEAD` branch (null when unset,
|
|
205
|
-
* e.g. fresh clones / CI), `flatOwner` is the branch that owns the flat slot.
|
|
206
|
-
*/
|
|
207
|
-
export declare const primaryInversionWarning: (defaultBranch: string | null | undefined, flatOwner: string | null | undefined) => string | undefined;
|
|
208
202
|
/**
|
|
209
203
|
* Collect the recorded parse-cache chunk keys across the flat + every branch
|
|
210
204
|
* metadata directory under a flat `.gitnexus` storage, EXCLUDING `excludeDir`
|
package/dist/core/run-analyze.js
CHANGED
|
@@ -18,7 +18,7 @@ import { createSearchFTSIndexes, initialiseSearchFTSStemmer, verifySearchFTSInde
|
|
|
18
18
|
import { cjkSegmentationModeMismatch, getSearchFTSCjkSegmentation, initialiseSearchFTSCjkSegmentation, } from './search/cjk-segmentation.js';
|
|
19
19
|
import { resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
|
|
20
20
|
import { startWalCheckpointDriver, } from './lbug/wal-checkpoint-driver.js';
|
|
21
|
-
import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, isRepoRegistered, cleanupOldKuzuFiles, reconcileMetadataFiles, isMissingFilesystemError, INDEX_METADATA_FILE, INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js';
|
|
21
|
+
import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, adoptFlatBranchLabel, isReadOnlyFilesystemError, isRepoRegistered, cleanupOldKuzuFiles, reconcileMetadataFiles, isMissingFilesystemError, INDEX_METADATA_FILE, INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js';
|
|
22
22
|
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
|
|
23
23
|
import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_CDG_EDGES_PER_FUNCTION, } from './ingestion/cfg/emit.js';
|
|
24
24
|
import { DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from './ingestion/taint/propagate.js';
|
|
@@ -31,7 +31,7 @@ import { extractChangedSubgraph, computeEffectiveWriteSet, } from './incremental
|
|
|
31
31
|
import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
|
|
32
32
|
import { loadParseCache, saveParseCache, pruneCache, PARSE_CACHE_VERSION, } from '../storage/parse-cache.js';
|
|
33
33
|
import { getDurableParsedFileDir, pruneAndSaveDurableParsedFileStore, } from '../storage/parsedfile-store.js';
|
|
34
|
-
import { getCurrentCommit, getCurrentBranch,
|
|
34
|
+
import { getCurrentCommit, getCurrentBranch, getRemoteUrl, hasGitDir, getInferredRepoName, resolveRepoIdentityRoot, } from '../storage/git.js';
|
|
35
35
|
import { generateAIContextFiles } from '../cli/ai-context.js';
|
|
36
36
|
import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
|
|
37
37
|
import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
|
|
@@ -79,22 +79,6 @@ export const PHASE_LABELS = {
|
|
|
79
79
|
* the {@link AnalyzeCallbacks} interface — it never writes to stdout/stderr
|
|
80
80
|
* directly and never calls `process.exit()`.
|
|
81
81
|
*/
|
|
82
|
-
/**
|
|
83
|
-
* Build the primary-inversion warning (#2106 R8), or `undefined` when there is
|
|
84
|
-
* nothing to warn about. Pure + exported for testing. Both inputs are trimmed
|
|
85
|
-
* (a diagnostic — a missed warning is low-harm; a false warning is the thing to
|
|
86
|
-
* avoid). `defaultBranch` is the repo's `origin/HEAD` branch (null when unset,
|
|
87
|
-
* e.g. fresh clones / CI), `flatOwner` is the branch that owns the flat slot.
|
|
88
|
-
*/
|
|
89
|
-
export const primaryInversionWarning = (defaultBranch, flatOwner) => {
|
|
90
|
-
const norm = (s) => s?.trim() || undefined;
|
|
91
|
-
const d = norm(defaultBranch);
|
|
92
|
-
const o = norm(flatOwner);
|
|
93
|
-
if (!d || !o || d === o)
|
|
94
|
-
return undefined;
|
|
95
|
-
return (`Warning: the default branch "${d}" is not the primary index — "${o}" owns the flat slot. ` +
|
|
96
|
-
`Run \`gitnexus clean --branch ${o}\` then re-index on "${d}", or query it explicitly with \`--branch ${d}\`.`);
|
|
97
|
-
};
|
|
98
82
|
/**
|
|
99
83
|
* Collect the recorded parse-cache chunk keys across the flat + every branch
|
|
100
84
|
* metadata directory under a flat `.gitnexus` storage, EXCLUDING `excludeDir`
|
|
@@ -293,13 +277,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
|
|
|
293
277
|
}
|
|
294
278
|
const repoHasGit = hasGitDir(repoPath);
|
|
295
279
|
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
|
296
|
-
// ── #2106: resolve which branch slot this run writes to
|
|
280
|
+
// ── #2106/#2354: resolve which branch slot this run writes to ─────────
|
|
297
281
|
// `branchLabel` is the branch identity recorded in meta.json (incl. the
|
|
298
|
-
//
|
|
299
|
-
// lbug/meta paths stay byte-identical to single-branch behavior) and
|
|
300
|
-
// a `branches/<slug>/` sub-directory.
|
|
301
|
-
//
|
|
302
|
-
//
|
|
282
|
+
// flat workspace slot). `placement.branch` is undefined for the flat slot
|
|
283
|
+
// (the lbug/meta paths stay byte-identical to single-branch behavior) and
|
|
284
|
+
// set for a `branches/<slug>/` sub-directory. Only an explicit `--branch`
|
|
285
|
+
// can route to a sub-directory; a plain analyze ALWAYS targets the flat
|
|
286
|
+
// slot, which follows the checked-out working tree (#2354) — the
|
|
287
|
+
// auto-detected branch (null for detached HEAD / non-git) is recorded as
|
|
288
|
+
// the slot's informational label only.
|
|
303
289
|
// Normalize the auto-detected branch the same way an explicit `--branch` is
|
|
304
290
|
// validated (#2106 R1): a git ref the branch-name rules forbid (backtick,
|
|
305
291
|
// `~ ^ : ? *`, leading `-`, `..`) becomes `null` → the flat slot, matching
|
|
@@ -318,7 +304,7 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
|
|
|
318
304
|
`Check out "${options.branch}" before indexing it, or omit --branch to index the current branch.`);
|
|
319
305
|
}
|
|
320
306
|
const branchLabel = options.branch ?? checkedOutBranch;
|
|
321
|
-
const placement = await resolveBranchPlacement(repoPath, branchLabel);
|
|
307
|
+
const placement = options.branch ? await resolveBranchPlacement(repoPath, branchLabel) : {};
|
|
322
308
|
const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch);
|
|
323
309
|
// metaPath now points to the metadata file (gitnexus.json) in a branch-specific directory.
|
|
324
310
|
// metaDir is the directory containing the metadata file (and branch-specific DBs).
|
|
@@ -336,21 +322,6 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
|
|
|
336
322
|
log(`Metadata reconciliation failed (non-critical${code ? `, ${code}` : ''}); continuing.`);
|
|
337
323
|
}
|
|
338
324
|
const existingMeta = await loadMeta(metaDir);
|
|
339
|
-
// ── #2106 (R8): warn when the repo's default branch is not the primary ──
|
|
340
|
-
// A non-default branch can own the flat slot (it was indexed first). That
|
|
341
|
-
// index is still fully queryable via `--branch`, so this is an ergonomics
|
|
342
|
-
// wart, not data loss — we only warn (no risky relocation of a live DB).
|
|
343
|
-
if (repoHasGit) {
|
|
344
|
-
// Who owns the flat slot after this run? For a flat/primary run it is this
|
|
345
|
-
// run's resolved label (carrying an existing stamp forward); for a branch
|
|
346
|
-
// run the flat owner is unchanged, so read the flat meta.
|
|
347
|
-
const flatOwner = placement.branch
|
|
348
|
-
? (await loadMeta(storagePath))?.branch
|
|
349
|
-
: (branchLabel ?? existingMeta?.branch);
|
|
350
|
-
const warning = primaryInversionWarning(getDefaultBranch(repoPath), flatOwner);
|
|
351
|
-
if (warning)
|
|
352
|
-
log(warning);
|
|
353
|
-
}
|
|
354
325
|
// ── FTS-only repair path ────────────────────────────────────────────
|
|
355
326
|
if (options.repairFts) {
|
|
356
327
|
if (!existingMeta) {
|
|
@@ -550,6 +521,38 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
|
|
|
550
521
|
// opt-in branch so the common fast path keeps its single-stat cost.
|
|
551
522
|
const healUnregistered = options.allowDuplicateName === true && !(await isRepoRegistered(repoPath));
|
|
552
523
|
if (!dirty && !healUnregistered) {
|
|
524
|
+
// ── #2354: restamp the workspace label on a same-commit branch flip ──
|
|
525
|
+
// The flat slot follows the checked-out working tree; a branch switch
|
|
526
|
+
// at the SAME commit with a clean tree changes nothing the pipeline
|
|
527
|
+
// must rebuild, but the slot's informational `branch` label (and the
|
|
528
|
+
// registry copy that query-side branch scoping reads) would go stale.
|
|
529
|
+
// Detached HEAD / non-git (branchLabel === null) keeps the existing
|
|
530
|
+
// stamp, mirroring the end-of-run meta write.
|
|
531
|
+
if (!placement.branch && branchLabel && existingMeta.branch !== branchLabel) {
|
|
532
|
+
// Adopt first, stamp last (#2364 review F3): this block's retry
|
|
533
|
+
// guard is `existingMeta.branch !== branchLabel`, so stamping the
|
|
534
|
+
// meta before the registry/shadow cleanup would flip the guard and
|
|
535
|
+
// lock in any partial failure — with saveMeta last, a failed adopt
|
|
536
|
+
// leaves the guard true and the next same-commit run self-heals
|
|
537
|
+
// (adopt is idempotent). The whole sync is best-effort: the label
|
|
538
|
+
// is informational and the flat DB content is byte-valid for both
|
|
539
|
+
// labels here (same commit, clean tree), so an "Already up to
|
|
540
|
+
// date" run must not fail over it; read-only storage — the
|
|
541
|
+
// documented Docker :ro workflow (#1549) — degrades to a warning.
|
|
542
|
+
try {
|
|
543
|
+
await adoptFlatBranchLabel(repoPath, branchLabel);
|
|
544
|
+
await saveMeta(metaDir, { ...existingMeta, branch: branchLabel });
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
// EACCES/EPERM also arise from ownership problems and transient
|
|
548
|
+
// Windows locks, so keep the real error visible alongside the
|
|
549
|
+
// #1549 read-only hint instead of replacing it.
|
|
550
|
+
const reason = isReadOnlyFilesystemError(err)
|
|
551
|
+
? `${err.message} — storage may be read-only (#1549)`
|
|
552
|
+
: err.message;
|
|
553
|
+
log(`Warning: could not restamp the workspace branch label (${reason}); will retry on the next run.`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
553
556
|
await ensureGitNexusIgnored(repoPath);
|
|
554
557
|
return {
|
|
555
558
|
// `resolveRepoIdentityRoot` collapses worktree roots to the
|
|
@@ -1201,6 +1204,24 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
|
|
|
1201
1204
|
// top-level fields (#2106).
|
|
1202
1205
|
branch: placement.branch,
|
|
1203
1206
|
});
|
|
1207
|
+
// ── #2354: the flat workspace slot has adopted this run's branch ──────
|
|
1208
|
+
// Drop a now-shadowed `branches/<slug>/` sub-index for the same label
|
|
1209
|
+
// (unreachable once the flat slot serves it) and align the registry's
|
|
1210
|
+
// top-level branch label. Best-effort like the parse-cache save above
|
|
1211
|
+
// (#2364 review F5): the index is complete and registered, and a failure
|
|
1212
|
+
// here leaves only a stale registry label / undeleted shadowed dir —
|
|
1213
|
+
// never wrong routing, because the flat meta this run already stamped is
|
|
1214
|
+
// what applyBranchScope trusts. Retried by the next content-changing run
|
|
1215
|
+
// (same-commit fast-path runs skip it: their guard compares the
|
|
1216
|
+
// already-stamped meta label).
|
|
1217
|
+
if (!placement.branch && branchLabel) {
|
|
1218
|
+
try {
|
|
1219
|
+
await adoptFlatBranchLabel(repoPath, branchLabel);
|
|
1220
|
+
}
|
|
1221
|
+
catch (e) {
|
|
1222
|
+
log(`Warning: could not sync the workspace branch label (${e.message}); continuing.`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1204
1225
|
// Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
|
|
1205
1226
|
await ensureGitNexusIgnored(repoPath);
|
|
1206
1227
|
// ── Generate AI context files (best-effort) ───────────────────────
|
|
@@ -67,7 +67,7 @@ interface RepoHandle {
|
|
|
67
67
|
stats?: RegistryEntry['stats'];
|
|
68
68
|
/** Primary/flat branch name, when known (#2106). */
|
|
69
69
|
branch?: string;
|
|
70
|
-
/**
|
|
70
|
+
/** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
|
|
71
71
|
branches?: BranchSummary[];
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
@@ -122,7 +122,7 @@ export interface RepoListing {
|
|
|
122
122
|
}>;
|
|
123
123
|
/** Primary/flat branch name, when known (#2106). */
|
|
124
124
|
branch?: string;
|
|
125
|
-
/**
|
|
125
|
+
/** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
|
|
126
126
|
branches?: Array<Omit<BranchSummary, 'stats'>>;
|
|
127
127
|
}
|
|
128
128
|
/** Continuation metadata for the paginated `list_repos` MCP tool (#2119). */
|
|
@@ -236,14 +236,28 @@ export declare class LocalBackend {
|
|
|
236
236
|
/**
|
|
237
237
|
* Re-point a resolved repo handle at a specific branch index (#2106).
|
|
238
238
|
*
|
|
239
|
-
* - No `branch` (default) → the
|
|
239
|
+
* - No `branch` (default) → the flat workspace handle, unchanged (backward
|
|
240
240
|
* compatible: every existing caller passes no branch).
|
|
241
|
-
* - `branch` equal to the
|
|
242
|
-
*
|
|
241
|
+
* - `branch` equal to the flat slot's **on-disk** recorded branch → the
|
|
242
|
+
* flat handle. The disk meta is read before any cached state is trusted
|
|
243
|
+
* (#2364 review F1): the flat slot follows the checked-out working tree
|
|
244
|
+
* (#2354), so a plain analyze after a branch switch restamps the meta
|
|
245
|
+
* without any repo-resolution miss that would refresh a long-lived
|
|
246
|
+
* server's cached handle — the cached label can otherwise serve another
|
|
247
|
+
* branch's content under the old name (the pool staleness reinit
|
|
248
|
+
* hot-swaps content without updating `handle.branch`).
|
|
249
|
+
* - `branch` matching an indexed pinned branch → a handle whose
|
|
243
250
|
* `lbugPath` points at `branches/<slug>/lbug`; the connection pool keys by
|
|
244
|
-
* `lbugPath`, so this is the only change needed to scope every tool.
|
|
245
|
-
*
|
|
246
|
-
*
|
|
251
|
+
* `lbugPath`, so this is the only change needed to scope every tool. The
|
|
252
|
+
* sub-index lbug must actually exist on disk — `adoptFlatBranchLabel`
|
|
253
|
+
* deletes the whole dir when the flat slot takes ownership, and a stale
|
|
254
|
+
* cached summary must not route to the deleted path.
|
|
255
|
+
* - Cached `handle.branch` is trusted only when there is no readable flat
|
|
256
|
+
* meta to contradict it (legacy shapes, #2106 R4).
|
|
257
|
+
* - Any miss → a clear error (never a silently-empty result against the
|
|
258
|
+
* wrong DB), after exactly one `refreshRepos()` so newly-pinned branches
|
|
259
|
+
* and restamped labels the cached handle predates resolve on the next
|
|
260
|
+
* call.
|
|
247
261
|
*/
|
|
248
262
|
private applyBranchScope;
|
|
249
263
|
/**
|
|
@@ -809,45 +809,110 @@ export class LocalBackend {
|
|
|
809
809
|
/**
|
|
810
810
|
* Re-point a resolved repo handle at a specific branch index (#2106).
|
|
811
811
|
*
|
|
812
|
-
* - No `branch` (default) → the
|
|
812
|
+
* - No `branch` (default) → the flat workspace handle, unchanged (backward
|
|
813
813
|
* compatible: every existing caller passes no branch).
|
|
814
|
-
* - `branch` equal to the
|
|
815
|
-
*
|
|
814
|
+
* - `branch` equal to the flat slot's **on-disk** recorded branch → the
|
|
815
|
+
* flat handle. The disk meta is read before any cached state is trusted
|
|
816
|
+
* (#2364 review F1): the flat slot follows the checked-out working tree
|
|
817
|
+
* (#2354), so a plain analyze after a branch switch restamps the meta
|
|
818
|
+
* without any repo-resolution miss that would refresh a long-lived
|
|
819
|
+
* server's cached handle — the cached label can otherwise serve another
|
|
820
|
+
* branch's content under the old name (the pool staleness reinit
|
|
821
|
+
* hot-swaps content without updating `handle.branch`).
|
|
822
|
+
* - `branch` matching an indexed pinned branch → a handle whose
|
|
816
823
|
* `lbugPath` points at `branches/<slug>/lbug`; the connection pool keys by
|
|
817
|
-
* `lbugPath`, so this is the only change needed to scope every tool.
|
|
818
|
-
*
|
|
819
|
-
*
|
|
824
|
+
* `lbugPath`, so this is the only change needed to scope every tool. The
|
|
825
|
+
* sub-index lbug must actually exist on disk — `adoptFlatBranchLabel`
|
|
826
|
+
* deletes the whole dir when the flat slot takes ownership, and a stale
|
|
827
|
+
* cached summary must not route to the deleted path.
|
|
828
|
+
* - Cached `handle.branch` is trusted only when there is no readable flat
|
|
829
|
+
* meta to contradict it (legacy shapes, #2106 R4).
|
|
830
|
+
* - Any miss → a clear error (never a silently-empty result against the
|
|
831
|
+
* wrong DB), after exactly one `refreshRepos()` so newly-pinned branches
|
|
832
|
+
* and restamped labels the cached handle predates resolve on the next
|
|
833
|
+
* call.
|
|
820
834
|
*/
|
|
821
835
|
async applyBranchScope(handle, branch) {
|
|
822
836
|
if (!branch)
|
|
823
837
|
return handle;
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
838
|
+
// At most one cache refresh per resolution: enough for the NEXT call to
|
|
839
|
+
// see fresh handles, without paying two registry re-scans when several
|
|
840
|
+
// stale arms fire in one degraded resolution.
|
|
841
|
+
let refreshed = false;
|
|
842
|
+
const refreshOnce = async () => {
|
|
843
|
+
if (refreshed)
|
|
844
|
+
return;
|
|
845
|
+
refreshed = true;
|
|
846
|
+
await this.refreshRepos().catch(() => { });
|
|
847
|
+
};
|
|
848
|
+
// One small JSON read per scoped call; mid-run meta writes preserve the
|
|
849
|
+
// old label until the end-of-run atomic stamp (run-analyze dirty stamps
|
|
850
|
+
// spread the existing meta), so this read never runs ahead of the DB.
|
|
851
|
+
const flatMeta = await loadMeta(path.dirname(handle.lbugPath));
|
|
852
|
+
if (flatMeta?.branch && flatMeta.branch === branch) {
|
|
853
|
+
// The disk meta decides routing, so it also supplies the metadata —
|
|
854
|
+
// the cached handle's label/commit/stats can predate the restamp.
|
|
829
855
|
return {
|
|
830
856
|
...handle,
|
|
831
|
-
|
|
832
|
-
indexedAt:
|
|
833
|
-
lastCommit:
|
|
834
|
-
stats:
|
|
857
|
+
branch: flatMeta.branch,
|
|
858
|
+
indexedAt: flatMeta.indexedAt ?? handle.indexedAt,
|
|
859
|
+
lastCommit: flatMeta.lastCommit ?? handle.lastCommit,
|
|
860
|
+
stats: flatMeta.stats ?? handle.stats,
|
|
835
861
|
};
|
|
836
862
|
}
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
// the flat
|
|
841
|
-
|
|
842
|
-
if (
|
|
843
|
-
const
|
|
844
|
-
|
|
863
|
+
// A registry entry claiming `branch` both as the flat label AND as a
|
|
864
|
+
// pinned summary is an adopt-degraded state (rm kept the summary while
|
|
865
|
+
// the label restamped) — never serve the possibly stale-vintage pin for
|
|
866
|
+
// a label the flat slot claims; fall through to the honest error.
|
|
867
|
+
const summary = handle.branch !== branch ? handle.branches?.find((b) => b.branch === branch) : undefined;
|
|
868
|
+
if (summary) {
|
|
869
|
+
const { lbugPath } = getStoragePaths(handle.repoPath, branch);
|
|
870
|
+
// The lbug is the artifact the pool opens, so its presence is the
|
|
871
|
+
// serviceability truth — a half-deleted dir can outlive its meta.json
|
|
872
|
+
// while the lbug is gone, and vice versa (#2364 review F1 arm ii).
|
|
873
|
+
// Only provably-absent errno counts as missing: a transient EACCES/EIO
|
|
874
|
+
// on a healthy pinned sub-index must serve the handle (the pool open
|
|
875
|
+
// surfaces the real error) rather than a false "not indexed".
|
|
876
|
+
const probeCode = await fs.access(lbugPath).then(() => null, (e) => e?.code ?? 'UNKNOWN');
|
|
877
|
+
const subIndexMissing = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';
|
|
878
|
+
if (!subIndexMissing) {
|
|
879
|
+
return {
|
|
880
|
+
...handle,
|
|
881
|
+
lbugPath,
|
|
882
|
+
indexedAt: summary.indexedAt,
|
|
883
|
+
lastCommit: summary.lastCommit,
|
|
884
|
+
stats: summary.stats,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
// Stale summary (sub-index adopted/deleted): refresh so later calls see
|
|
888
|
+
// fresh handles, then fall through — the flat meta above is the truth.
|
|
889
|
+
await refreshOnce();
|
|
890
|
+
}
|
|
891
|
+
if (handle.branch && handle.branch === branch) {
|
|
892
|
+
// No readable flat meta (missing/corrupt — loadMeta → null): keep the
|
|
893
|
+
// pre-#2354 trust in the cached label (#2106 R4 legacy shapes). A
|
|
894
|
+
// readable meta that names another branch means the label is stale.
|
|
895
|
+
if (!flatMeta?.branch)
|
|
845
896
|
return handle;
|
|
846
897
|
}
|
|
847
|
-
|
|
848
|
-
|
|
898
|
+
// Every miss refreshes once before erroring: newly-pinned branches and
|
|
899
|
+
// restamped labels the cached handle predates become resolvable on the
|
|
900
|
+
// caller's next attempt (the cache otherwise only refreshes on repo-
|
|
901
|
+
// resolution misses and list_repos).
|
|
902
|
+
await refreshOnce();
|
|
903
|
+
// The flat slot's label comes from the authoritative meta when readable —
|
|
904
|
+
// never echo a cached label the meta just contradicted (a "not indexed:
|
|
905
|
+
// main / indexed: main" self-contradiction). Cached summaries may still
|
|
906
|
+
// lag; they are a hint, not a promise.
|
|
907
|
+
const flatLabel = flatMeta?.branch ?? handle.branch;
|
|
908
|
+
const indexed = [flatLabel, ...(handle.branches?.map((b) => b.branch) ?? [])].filter((b) => Boolean(b) && b !== branch);
|
|
909
|
+
const available = indexed.length > 0 ? indexed.join(', ') : '(workspace only)';
|
|
910
|
+
// Post-#2354 a bare `analyze --branch <X>` refuses to run unless X is
|
|
911
|
+
// checked out, so the guidance must lead with the checkout (#2364 F6).
|
|
849
912
|
throw new Error(`Branch "${branch}" is not indexed for "${handle.name}". ` +
|
|
850
|
-
`Indexed branches: ${available}.
|
|
913
|
+
`Indexed branches: ${available}. The workspace index follows the ` +
|
|
914
|
+
`checked-out branch — check out "${branch}" and re-run: gitnexus analyze ` +
|
|
915
|
+
`(add --branch ${branch} while it is checked out to pin a separate sub-index).`);
|
|
851
916
|
}
|
|
852
917
|
/**
|
|
853
918
|
* Try to resolve a repo from the in-memory cache. Returns null on miss.
|
package/dist/mcp/tools.js
CHANGED
|
@@ -834,10 +834,11 @@ for (const tool of GITNEXUS_TOOLS) {
|
|
|
834
834
|
if (tool.inputSchema.properties.branch)
|
|
835
835
|
continue;
|
|
836
836
|
// Optional — `required` is left unchanged so omitting `branch` keeps today's
|
|
837
|
-
//
|
|
837
|
+
// workspace-index behavior. Ignored in group mode (repo starts "@").
|
|
838
838
|
tool.inputSchema.properties.branch = {
|
|
839
839
|
type: 'string',
|
|
840
|
-
description: 'Optional: scope to a
|
|
841
|
-
'Omit for the
|
|
840
|
+
description: 'Optional: scope to a pinned branch index (multi-branch repos, #2106). ' +
|
|
841
|
+
'Omit for the workspace index, which follows the checked-out working tree. ' +
|
|
842
|
+
'Ignored in group mode.',
|
|
842
843
|
};
|
|
843
844
|
}
|
|
@@ -34,18 +34,20 @@ export declare const BRANCHES_DIR = "branches";
|
|
|
34
34
|
*/
|
|
35
35
|
export declare const branchSlug: (rawRef: string) => string;
|
|
36
36
|
/**
|
|
37
|
-
* Decide where
|
|
38
|
-
* or a per-branch sub-directory (#2106 KTD2).
|
|
37
|
+
* Decide where an EXPLICIT `--branch` run's index lives: the flat workspace
|
|
38
|
+
* slot or a per-branch sub-directory (#2106 KTD2, #2354).
|
|
39
39
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* the
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* Only explicit `--branch` runs consult this — a plain analyze always targets
|
|
41
|
+
* the flat slot, which follows the checked-out working tree (#2354; gated at
|
|
42
|
+
* the `runFullAnalysis` call site). Returns `{}` for the flat placement
|
|
43
|
+
* (byte-identical layout) or `{ branch }` for a `branches/<slug>/`
|
|
44
|
+
* sub-directory: when the requested label matches the flat slot's recorded
|
|
45
|
+
* `branch` label the run updates the flat slot in place (identical content —
|
|
46
|
+
* `--branch` requires the label to be checked out); any other label gets its
|
|
47
|
+
* own pinned sub-directory that plain analyzes won't touch.
|
|
45
48
|
*
|
|
46
|
-
* `
|
|
47
|
-
*
|
|
48
|
-
* folder, or CI checkout — always maps to the flat slot.
|
|
49
|
+
* A `null` label — detached HEAD, non-git folder, or CI checkout — always
|
|
50
|
+
* maps to the flat slot.
|
|
49
51
|
*/
|
|
50
52
|
export declare const resolveBranchPlacement: (repoPath: string, label: string | null) => Promise<{
|
|
51
53
|
branch?: string;
|
|
@@ -28,18 +28,20 @@ export const branchSlug = (rawRef) => {
|
|
|
28
28
|
return `${safe}-${hash}`;
|
|
29
29
|
};
|
|
30
30
|
/**
|
|
31
|
-
* Decide where
|
|
32
|
-
* or a per-branch sub-directory (#2106 KTD2).
|
|
31
|
+
* Decide where an EXPLICIT `--branch` run's index lives: the flat workspace
|
|
32
|
+
* slot or a per-branch sub-directory (#2106 KTD2, #2354).
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* the
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* Only explicit `--branch` runs consult this — a plain analyze always targets
|
|
35
|
+
* the flat slot, which follows the checked-out working tree (#2354; gated at
|
|
36
|
+
* the `runFullAnalysis` call site). Returns `{}` for the flat placement
|
|
37
|
+
* (byte-identical layout) or `{ branch }` for a `branches/<slug>/`
|
|
38
|
+
* sub-directory: when the requested label matches the flat slot's recorded
|
|
39
|
+
* `branch` label the run updates the flat slot in place (identical content —
|
|
40
|
+
* `--branch` requires the label to be checked out); any other label gets its
|
|
41
|
+
* own pinned sub-directory that plain analyzes won't touch.
|
|
39
42
|
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
42
|
-
* folder, or CI checkout — always maps to the flat slot.
|
|
43
|
+
* A `null` label — detached HEAD, non-git folder, or CI checkout — always
|
|
44
|
+
* maps to the flat slot.
|
|
43
45
|
*/
|
|
44
46
|
export const resolveBranchPlacement = async (repoPath, label) => {
|
|
45
47
|
// Detached HEAD / non-git / no label → flat (CI-safe, byte-identical).
|
|
@@ -381,6 +381,7 @@ export declare const reconcileMetadataFiles: (repoPath: string) => Promise<boole
|
|
|
381
381
|
* Find .gitnexus by walking up from a starting path
|
|
382
382
|
*/
|
|
383
383
|
export declare const findRepo: (startPath: string) => Promise<IndexedRepo | null>;
|
|
384
|
+
export declare function isReadOnlyFilesystemError(err: unknown): boolean;
|
|
384
385
|
/**
|
|
385
386
|
* True for errors that prove a path is absent (ENOENT/ENOTDIR) — as opposed
|
|
386
387
|
* to transient/permission failures (EIO/EACCES/EBUSY…) where the file may
|
|
@@ -500,6 +501,24 @@ export declare const unregisterRepo: (repoPath: string) => Promise<void>;
|
|
|
500
501
|
* registry shape legacy-clean.
|
|
501
502
|
*/
|
|
502
503
|
export declare const removeBranchIndex: (repoPath: string, branch: string) => Promise<boolean>;
|
|
504
|
+
/**
|
|
505
|
+
* Record that the flat workspace slot now serves `branch` (#2354).
|
|
506
|
+
*
|
|
507
|
+
* The flat index follows the checked-out working tree, so when a plain
|
|
508
|
+
* analyze lands on a branch that also has a pinned `branches/<slug>/`
|
|
509
|
+
* sub-index, that sub-index becomes permanently shadowed — explicit
|
|
510
|
+
* `--branch` runs re-resolve to the flat slot and query-side branch scoping
|
|
511
|
+
* serves the flat handle first. Delete the shadowed directory and drop its
|
|
512
|
+
* registry summary in the same pass (leaving either half behind would strand
|
|
513
|
+
* un-cleanable disk bloat), and refresh the entry's top-level `branch` label
|
|
514
|
+
* so `list`/`list_repos`/branch-scoped queries stay coherent.
|
|
515
|
+
*
|
|
516
|
+
* Deliberately narrow for the analyze fast path: a missing registry entry is
|
|
517
|
+
* a no-op — including the sub-index deletion, which only runs for registered
|
|
518
|
+
* repos (never self-heals an unregistered repo, per #2264/#1169; the registry
|
|
519
|
+
* check precedes the rm per #2364 review F2) — and no subprocess is spawned.
|
|
520
|
+
*/
|
|
521
|
+
export declare const adoptFlatBranchLabel: (repoPath: string, branch: string) => Promise<void>;
|
|
503
522
|
/**
|
|
504
523
|
* Thrown by {@link resolveRegistryEntry} when no registered repo matches
|
|
505
524
|
* the caller's target string (by alias, basename, remote-inferred name,
|
|
@@ -423,7 +423,7 @@ export const findRepo = async (startPath) => {
|
|
|
423
423
|
}
|
|
424
424
|
return null;
|
|
425
425
|
};
|
|
426
|
-
function isReadOnlyFilesystemError(err) {
|
|
426
|
+
export function isReadOnlyFilesystemError(err) {
|
|
427
427
|
const code = err?.code;
|
|
428
428
|
return code === 'EROFS' || code === 'EACCES' || code === 'EPERM';
|
|
429
429
|
}
|
|
@@ -831,6 +831,87 @@ export const removeBranchIndex = async (repoPath, branch) => {
|
|
|
831
831
|
await writeRegistry(entries);
|
|
832
832
|
return true;
|
|
833
833
|
};
|
|
834
|
+
/**
|
|
835
|
+
* Record that the flat workspace slot now serves `branch` (#2354).
|
|
836
|
+
*
|
|
837
|
+
* The flat index follows the checked-out working tree, so when a plain
|
|
838
|
+
* analyze lands on a branch that also has a pinned `branches/<slug>/`
|
|
839
|
+
* sub-index, that sub-index becomes permanently shadowed — explicit
|
|
840
|
+
* `--branch` runs re-resolve to the flat slot and query-side branch scoping
|
|
841
|
+
* serves the flat handle first. Delete the shadowed directory and drop its
|
|
842
|
+
* registry summary in the same pass (leaving either half behind would strand
|
|
843
|
+
* un-cleanable disk bloat), and refresh the entry's top-level `branch` label
|
|
844
|
+
* so `list`/`list_repos`/branch-scoped queries stay coherent.
|
|
845
|
+
*
|
|
846
|
+
* Deliberately narrow for the analyze fast path: a missing registry entry is
|
|
847
|
+
* a no-op — including the sub-index deletion, which only runs for registered
|
|
848
|
+
* repos (never self-heals an unregistered repo, per #2264/#1169; the registry
|
|
849
|
+
* check precedes the rm per #2364 review F2) — and no subprocess is spawned.
|
|
850
|
+
*/
|
|
851
|
+
export const adoptFlatBranchLabel = async (repoPath, branch) => {
|
|
852
|
+
const canonicalInput = canonicalizePath(repoPath);
|
|
853
|
+
const isRegistered = (list) => list.findIndex((e) => registryPathEquals(canonicalizePath(e.path), canonicalInput));
|
|
854
|
+
// Cheap membership gate only (#2364 review F2): never touch the disk for an
|
|
855
|
+
// unregistered repo. The mutate below re-reads its own fresh snapshot.
|
|
856
|
+
if (isRegistered(await readRegistry()) < 0)
|
|
857
|
+
return; // no-op, disk included (no self-heal)
|
|
858
|
+
const resolved = path.resolve(repoPath);
|
|
859
|
+
const { storagePath } = getStoragePaths(resolved);
|
|
860
|
+
// Remove a shadowed sub-index directory, mirroring `clean --branch`'s
|
|
861
|
+
// containment guard: the target MUST live under .gitnexus/branches/.
|
|
862
|
+
const branchDir = path.join(storagePath, BRANCHES_DIR, branchSlug(branch));
|
|
863
|
+
const branchesRoot = path.join(storagePath, BRANCHES_DIR) + path.sep;
|
|
864
|
+
let dirGone = false;
|
|
865
|
+
if (branchDir.startsWith(branchesRoot)) {
|
|
866
|
+
let rmError;
|
|
867
|
+
await fs.rm(branchDir, { recursive: true, force: true }).catch((err) => {
|
|
868
|
+
rmError = err;
|
|
869
|
+
});
|
|
870
|
+
// The registry summary may be dropped only for a verifiably-gone
|
|
871
|
+
// directory: `clean --branch` resolves its target solely via the
|
|
872
|
+
// recorded summary, so dropping it while the dir survives (e.g. Windows
|
|
873
|
+
// EBUSY on an lbug held open by a live MCP server) would strand
|
|
874
|
+
// un-cleanable disk bloat (#2364 review F4). A resolved force:true rm
|
|
875
|
+
// proves absence; on failure, probe the disk and treat only
|
|
876
|
+
// provably-absent errno as gone — EACCES/EIO are "not provably absent",
|
|
877
|
+
// the same polarity as listRegisteredRepos({ validate: true }).
|
|
878
|
+
if (!rmError) {
|
|
879
|
+
dirGone = true;
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
const probeCode = await fs.access(branchDir).then(() => null, (e) => e?.code ?? 'UNKNOWN');
|
|
883
|
+
dirGone = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';
|
|
884
|
+
}
|
|
885
|
+
if (dirGone) {
|
|
886
|
+
// Non-recursive by design: only removes the parent when no other pinned
|
|
887
|
+
// sub-index remains, so an empty branches/ dir doesn't read as "pinned".
|
|
888
|
+
await fs.rmdir(path.join(storagePath, BRANCHES_DIR)).catch(() => { });
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
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.');
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// Re-read AFTER the potentially slow recursive rm: the registry is a
|
|
895
|
+
// multi-writer whole-file overwrite, and writing a pre-rm snapshot would
|
|
896
|
+
// silently clobber concurrent registerRepo/removeBranchIndex writers —
|
|
897
|
+
// the #2106 R9 re-read-before-write discipline registerRepo follows.
|
|
898
|
+
const entries = await readRegistry();
|
|
899
|
+
const idx = isRegistered(entries);
|
|
900
|
+
if (idx < 0)
|
|
901
|
+
return; // unregistered concurrently → still a no-op
|
|
902
|
+
const entry = entries[idx];
|
|
903
|
+
const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
|
|
904
|
+
const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
|
|
905
|
+
if (entry.branch === branch && !droppedSummary)
|
|
906
|
+
return; // already coherent
|
|
907
|
+
entry.branch = branch;
|
|
908
|
+
if (remaining && remaining.length > 0)
|
|
909
|
+
entry.branches = remaining;
|
|
910
|
+
else
|
|
911
|
+
delete entry.branches;
|
|
912
|
+
entries[idx] = entry;
|
|
913
|
+
await writeRegistry(entries);
|
|
914
|
+
};
|
|
834
915
|
/**
|
|
835
916
|
* Thrown by {@link resolveRegistryEntry} when no registered repo matches
|
|
836
917
|
* the caller's target string (by alias, basename, remote-inferred name,
|
package/package.json
CHANGED