gitnexus 1.6.10-rc.136 → 1.6.10-rc.138
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
CHANGED
|
@@ -311,6 +311,31 @@ validates the returned vector's length):
|
|
|
311
311
|
|
|
312
312
|
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. Retry and pacing settings are provider-neutral; provider-specific limits should be supplied through configuration. When unset, local embeddings are used unchanged.
|
|
313
313
|
|
|
314
|
+
## JVM Package Sibling Injection
|
|
315
|
+
|
|
316
|
+
Java and Kotlin files in the same package receive implicit sibling class bindings
|
|
317
|
+
to resolve same-package references. By default, GitNexus injects at most 200
|
|
318
|
+
siblings per module scope, nearest first by path. Set
|
|
319
|
+
`GITNEXUS_MAX_INJECTED_SIBLINGS=0` to remove that per-file limit; this can
|
|
320
|
+
substantially increase indexing work for large packages.
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
export GITNEXUS_MAX_INJECTED_SIBLINGS=200
|
|
324
|
+
gitnexus analyze .
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
When the limit truncates a file's sibling set, that file is marked
|
|
328
|
+
visibility-incomplete: same-package references still resolve through the
|
|
329
|
+
injected siblings, but wildcard-import attribution (used by the Spring
|
|
330
|
+
bean/DI/config passes) is disabled for it rather than resolved against a
|
|
331
|
+
partial view. Analyze logs a `sibling injection truncated` warning naming how
|
|
332
|
+
many files were affected.
|
|
333
|
+
|
|
334
|
+
Packages with more than 500 files are a separate, fixed limit: they are skipped
|
|
335
|
+
entirely (logged as `skipping package with N files`) and every file in them is
|
|
336
|
+
marked visibility-incomplete. `GITNEXUS_MAX_INJECTED_SIBLINGS` does not lift
|
|
337
|
+
that skip — including at `0`.
|
|
338
|
+
|
|
314
339
|
## Multi-Repo Support
|
|
315
340
|
|
|
316
341
|
GitNexus supports indexing multiple repositories. Each `gitnexus analyze` registers the repo in a global registry (`~/.gitnexus/registry.json`). The MCP server serves all indexed repos automatically.
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { logger } from '../../../logger.js';
|
|
2
2
|
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
|
|
3
|
+
/** Packages larger than this get no implicit sibling visibility at all — every
|
|
4
|
+
* file in them is marked incomplete. `GITNEXUS_MAX_INJECTED_SIBLINGS` bounds
|
|
5
|
+
* injection *within* a package and does not lift this skip. */
|
|
3
6
|
const MAX_PACKAGE_FILES = 500;
|
|
7
|
+
const DEFAULT_MAX_INJECTED_SIBLINGS = 200;
|
|
8
|
+
function getMaxInjectedSiblings() {
|
|
9
|
+
const raw = process.env.GITNEXUS_MAX_INJECTED_SIBLINGS;
|
|
10
|
+
if (raw === undefined || raw === '')
|
|
11
|
+
return DEFAULT_MAX_INJECTED_SIBLINGS;
|
|
12
|
+
const parsed = Number(raw);
|
|
13
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_MAX_INJECTED_SIBLINGS;
|
|
14
|
+
}
|
|
4
15
|
export function createJvmPackageSiblingVisibility(options) {
|
|
5
16
|
const incompleteFiles = new Set();
|
|
6
17
|
function populateNamespaceSiblings(parsedFiles, indexes, ctx) {
|
|
@@ -40,6 +51,8 @@ export function createJvmPackageSiblingVisibility(options) {
|
|
|
40
51
|
logger.warn(`[${options.languageLabel}-package-siblings] ${unknownPackageFiles.size} file(s) lacked reliable package facts; wildcard attribution disabled for this language workspace`);
|
|
41
52
|
}
|
|
42
53
|
const augmentations = indexes.bindingAugmentations;
|
|
54
|
+
const maxInjectedSiblings = getMaxInjectedSiblings();
|
|
55
|
+
let truncatedFiles = 0;
|
|
43
56
|
for (const bucket of buckets.values()) {
|
|
44
57
|
if (bucket.moduleScopes.length < 2)
|
|
45
58
|
continue;
|
|
@@ -60,25 +73,53 @@ export function createJvmPackageSiblingVisibility(options) {
|
|
|
60
73
|
classDefs.push({ def, filePath: parsed.filePath });
|
|
61
74
|
}
|
|
62
75
|
}
|
|
76
|
+
// Per-bucket lookups: the per-file loop below is O(files²) over these,
|
|
77
|
+
// so split each path into segments once here instead of re-splitting it
|
|
78
|
+
// on every pairwise proximity comparison, and address siblings by path
|
|
79
|
+
// so a truncated scope can merge its bounded set without rescanning the
|
|
80
|
+
// whole package.
|
|
81
|
+
const segmentsByPath = new Map();
|
|
82
|
+
const moduleScopeByPath = new Map();
|
|
83
|
+
const parsedByPath = new Map();
|
|
84
|
+
for (const parsed of bucket.parsed) {
|
|
85
|
+
segmentsByPath.set(parsed.filePath, pathSegments(parsed.filePath));
|
|
86
|
+
parsedByPath.set(parsed.filePath, parsed);
|
|
87
|
+
}
|
|
88
|
+
for (const sibling of bucket.moduleScopes)
|
|
89
|
+
moduleScopeByPath.set(sibling.filePath, sibling);
|
|
90
|
+
const allSiblingPaths = [...parsedByPath.keys()];
|
|
63
91
|
for (const { filePath, scope } of bucket.moduleScopes) {
|
|
64
92
|
let scopeAug = augmentations.get(scope.id);
|
|
65
93
|
if (scopeAug === undefined) {
|
|
66
94
|
scopeAug = new Map();
|
|
67
95
|
augmentations.set(scope.id, scopeAug);
|
|
68
96
|
}
|
|
97
|
+
const ownSegments = segmentsByPath.get(filePath) ?? pathSegments(filePath);
|
|
69
98
|
const proximityCache = new Map();
|
|
70
99
|
const candidates = classDefs.filter((candidate) => candidate.filePath !== filePath);
|
|
71
100
|
for (const candidate of candidates) {
|
|
72
101
|
if (!proximityCache.has(candidate.filePath)) {
|
|
73
|
-
proximityCache.set(candidate.filePath, sharedSegmentCount(candidate.filePath,
|
|
102
|
+
proximityCache.set(candidate.filePath, sharedSegmentCount(segmentsByPath.get(candidate.filePath) ?? [], ownSegments));
|
|
74
103
|
}
|
|
75
104
|
}
|
|
105
|
+
// Nearest-first by shared path prefix. Ties (the common case in a flat
|
|
106
|
+
// package, where every sibling shares the same directory) keep the
|
|
107
|
+
// walker's traversal order, so the retained set is deterministic but
|
|
108
|
+
// arbitrary among equally-near candidates — which is why truncation
|
|
109
|
+
// marks the file incomplete below rather than being treated as exact.
|
|
76
110
|
candidates.sort((a, b) => (proximityCache.get(b.filePath) ?? 0) - (proximityCache.get(a.filePath) ?? 0));
|
|
77
111
|
const injectedIds = new Set();
|
|
78
|
-
|
|
112
|
+
const injectedPaths = new Set();
|
|
113
|
+
let truncated = false;
|
|
114
|
+
for (const { def, filePath: defPath } of candidates) {
|
|
79
115
|
if (injectedIds.has(def.nodeId) || def.qualifiedName === undefined)
|
|
80
116
|
continue;
|
|
117
|
+
if (maxInjectedSiblings > 0 && injectedIds.size >= maxInjectedSiblings) {
|
|
118
|
+
truncated = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
81
121
|
injectedIds.add(def.nodeId);
|
|
122
|
+
injectedPaths.add(defPath);
|
|
82
123
|
const simpleName = def.qualifiedName.includes('.')
|
|
83
124
|
? def.qualifiedName.slice(def.qualifiedName.lastIndexOf('.') + 1)
|
|
84
125
|
: def.qualifiedName;
|
|
@@ -87,17 +128,40 @@ export function createJvmPackageSiblingVisibility(options) {
|
|
|
87
128
|
scopeAug.set(simpleName, bindings);
|
|
88
129
|
bindings.push({ def, origin: 'namespace' });
|
|
89
130
|
}
|
|
131
|
+
if (truncated) {
|
|
132
|
+
// Sibling visibility for this file is now partial, and downstream
|
|
133
|
+
// Spring attribution treats a complete flag as "this package's names
|
|
134
|
+
// are fully known". Marking it incomplete keeps wildcard attribution
|
|
135
|
+
// conservative instead of silently resolving against a truncated set.
|
|
136
|
+
incompleteFiles.add(filePath);
|
|
137
|
+
truncatedFiles++;
|
|
138
|
+
}
|
|
139
|
+
// Keep the two halves of "what this file can see" in agreement: when
|
|
140
|
+
// the cap truncated the binding set, merge type bindings from the same
|
|
141
|
+
// bounded sibling set only — and iterate that set directly, so the cap
|
|
142
|
+
// bounds the merge work too instead of just filtering a full scan. An
|
|
143
|
+
// untruncated scope merges every sibling as before, including ones that
|
|
144
|
+
// contribute no class-like def.
|
|
90
145
|
const typeBindings = scope.typeBindings;
|
|
91
|
-
|
|
92
|
-
|
|
146
|
+
const mergePaths = truncated ? injectedPaths : allSiblingPaths;
|
|
147
|
+
// Module scopes first, then class scopes: module-level bindings win on
|
|
148
|
+
// a name collision, which the two-pass order (not file order) encodes.
|
|
149
|
+
for (const siblingPath of mergePaths) {
|
|
150
|
+
if (siblingPath === filePath)
|
|
151
|
+
continue;
|
|
152
|
+
const sibling = moduleScopeByPath.get(siblingPath);
|
|
153
|
+
if (sibling === undefined)
|
|
93
154
|
continue;
|
|
94
155
|
for (const [name, ref] of sibling.scope.typeBindings) {
|
|
95
156
|
if (!typeBindings.has(name))
|
|
96
157
|
typeBindings.set(name, ref);
|
|
97
158
|
}
|
|
98
159
|
}
|
|
99
|
-
for (const
|
|
100
|
-
if (
|
|
160
|
+
for (const siblingPath of mergePaths) {
|
|
161
|
+
if (siblingPath === filePath)
|
|
162
|
+
continue;
|
|
163
|
+
const sibling = parsedByPath.get(siblingPath);
|
|
164
|
+
if (sibling === undefined)
|
|
101
165
|
continue;
|
|
102
166
|
for (const siblingScope of sibling.scopes) {
|
|
103
167
|
if (siblingScope.kind !== 'Class')
|
|
@@ -110,15 +174,19 @@ export function createJvmPackageSiblingVisibility(options) {
|
|
|
110
174
|
}
|
|
111
175
|
}
|
|
112
176
|
}
|
|
177
|
+
if (truncatedFiles > 0) {
|
|
178
|
+
logger.warn(`[${options.languageLabel}-package-siblings] sibling injection truncated at ${maxInjectedSiblings} per file for ${truncatedFiles} file(s); wildcard attribution disabled for those files (raise or unset GITNEXUS_MAX_INJECTED_SIBLINGS to widen, 0 for unbounded)`);
|
|
179
|
+
}
|
|
113
180
|
}
|
|
114
181
|
return {
|
|
115
182
|
populateNamespaceSiblings,
|
|
116
183
|
isVisibilityIncomplete: (filePath) => incompleteFiles.has(filePath),
|
|
117
184
|
};
|
|
118
185
|
}
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
186
|
+
function pathSegments(filePath) {
|
|
187
|
+
return filePath.replace(/\\/g, '/').split('/');
|
|
188
|
+
}
|
|
189
|
+
function sharedSegmentCount(aSegments, bSegments) {
|
|
122
190
|
let index = 0;
|
|
123
191
|
while (index < aSegments.length &&
|
|
124
192
|
index < bSegments.length &&
|
|
@@ -837,44 +837,8 @@ export declare class RegistryNameCollisionError extends Error {
|
|
|
837
837
|
readonly kind: "RegistryNameCollisionError";
|
|
838
838
|
constructor(registryName: string, existingPath: string, requestedPath: string);
|
|
839
839
|
}
|
|
840
|
-
/**
|
|
841
|
-
* Register (add or update) a repo in the global registry.
|
|
842
|
-
* Called after `gitnexus analyze` completes.
|
|
843
|
-
*
|
|
844
|
-
* Name resolution precedence (#829, #979):
|
|
845
|
-
* 1. explicit `opts.name` (from `analyze --name <alias>`)
|
|
846
|
-
* 2. preserved alias on an existing entry for this path
|
|
847
|
-
* 3. `git config --get remote.origin.url` repo name (#979 — recovers
|
|
848
|
-
* a meaningful name for monorepo subprojects, git worktrees, and
|
|
849
|
-
* Gas-Town-style `<rig>/refinery/rig/` layouts where the basename
|
|
850
|
-
* is generic)
|
|
851
|
-
* 4. `path.basename(repoPath)` (the original default)
|
|
852
|
-
*
|
|
853
|
-
* Duplicate-name guard: if another path already uses the resolved
|
|
854
|
-
* `name`, throw {@link RegistryNameCollisionError} unless
|
|
855
|
-
* `opts.allowDuplicateName` is set. The guard ONLY fires when the user explicitly passed a
|
|
856
|
-
* `name`; un-aliased basename collisions continue to register silently
|
|
857
|
-
* so existing users who don't know about `--name` see no behaviour
|
|
858
|
-
* change.
|
|
859
|
-
*
|
|
860
|
-
* Returns the `name` that was actually written to the registry — the
|
|
861
|
-
* caller can re-use it to keep AGENTS.md / skill files aligned with the
|
|
862
|
-
* MCP-visible repo name (#979).
|
|
863
|
-
*/
|
|
864
840
|
export declare const registerRepo: (repoPath: string, meta: RepoMeta, opts?: RegisterRepoOptions) => Promise<string>;
|
|
865
|
-
/**
|
|
866
|
-
* Remove a repo from the global registry.
|
|
867
|
-
* Called after `gitnexus clean`.
|
|
868
|
-
*/
|
|
869
841
|
export declare const unregisterRepo: (repoPath: string) => Promise<void>;
|
|
870
|
-
/**
|
|
871
|
-
* Remove a single non-primary branch's summary from a repo's registry entry
|
|
872
|
-
* (#2106 R7). Called by `gitnexus clean --branch`. Returns `true` when a
|
|
873
|
-
* matching `branches[]` summary was found and removed; `false` otherwise (so
|
|
874
|
-
* the CLI can report "no such indexed branch" without crashing). The top-level
|
|
875
|
-
* primary entry is left intact; an empty `branches[]` is dropped to keep the
|
|
876
|
-
* registry shape legacy-clean.
|
|
877
|
-
*/
|
|
878
842
|
export declare const removeBranchIndex: (repoPath: string, branch: string) => Promise<boolean>;
|
|
879
843
|
/**
|
|
880
844
|
* Record that the flat workspace slot now serves `branch` (#2354).
|
|
@@ -892,6 +856,12 @@ export declare const removeBranchIndex: (repoPath: string, branch: string) => Pr
|
|
|
892
856
|
* a no-op — including the sub-index deletion, which only runs for registered
|
|
893
857
|
* repos (never self-heals an unregistered repo, per #2264/#1169; the registry
|
|
894
858
|
* check precedes the rm per #2364 review F2) — and no subprocess is spawned.
|
|
859
|
+
*
|
|
860
|
+
* Only the closing re-read/mutate/write runs under the registry lock. The
|
|
861
|
+
* recursive `rm` stays outside it — mirroring `clean.ts`, which deletes the
|
|
862
|
+
* branch directory before calling the (locked) `removeBranchIndex` — so a slow
|
|
863
|
+
* delete (large sub-index, AV scan, network mount) never blocks every other
|
|
864
|
+
* registry operation on the machine.
|
|
895
865
|
*/
|
|
896
866
|
export declare const adoptFlatBranchLabel: (repoPath: string, branch: string) => Promise<void>;
|
|
897
867
|
/**
|
|
@@ -22,6 +22,7 @@ import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
|
|
|
22
22
|
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
|
23
23
|
import { retryRename } from './fs-atomic.js';
|
|
24
24
|
import { logger } from '../core/logger.js';
|
|
25
|
+
import { acquireIndexLock, IndexLockTimeoutError } from './index-lock.js';
|
|
25
26
|
import { branchSlug, BRANCHES_DIR, resolveBranchPlacement, } from './branch-index.js';
|
|
26
27
|
// Re-export the #2106 branch primitives (extracted to branch-index.ts, R10) so
|
|
27
28
|
// existing `repo-manager` import sites and tests keep working unchanged.
|
|
@@ -753,6 +754,65 @@ export const getGlobalDir = () => {
|
|
|
753
754
|
export const getGlobalRegistryPath = () => {
|
|
754
755
|
return path.join(getGlobalDir(), 'registry.json');
|
|
755
756
|
};
|
|
757
|
+
/**
|
|
758
|
+
* Lock namespace for the global registry.
|
|
759
|
+
*
|
|
760
|
+
* Deliberately a dedicated sub-directory rather than {@link getGlobalDir}
|
|
761
|
+
* itself: an index slot's lock dir is always `<repo>/.gitnexus` (or
|
|
762
|
+
* `<repo>/.gitnexus/branches/<slug>`), so for a repository rooted at the
|
|
763
|
+
* user's home directory — dotfiles-at-`$HOME` is a real layout — the per-repo
|
|
764
|
+
* analyze lock and the global-dir lock would resolve to the SAME directory.
|
|
765
|
+
* `acquireIndexLock` is not reentrant, so `runFullAnalysis` (which holds the
|
|
766
|
+
* per-repo lock across its whole pipeline) would then self-deadlock the moment
|
|
767
|
+
* it reached `registerRepo`/`adoptFlatBranchLabel`. No repo's index slot can
|
|
768
|
+
* ever be named `registry-lock`, so this namespace cannot collide.
|
|
769
|
+
*/
|
|
770
|
+
const getRegistryLockDir = () => path.join(getGlobalDir(), 'registry-lock');
|
|
771
|
+
/**
|
|
772
|
+
* Wait ceiling for the registry lock. A registry transaction is a sub-second
|
|
773
|
+
* JSON read/merge/write, so it must NOT inherit the index lock's 10-minute
|
|
774
|
+
* default (sized for multi-minute analyze runs): `gitnexus augment` runs on
|
|
775
|
+
* every editor/agent tool call with a documented sub-500ms cold-start budget
|
|
776
|
+
* and reaches this lock via `listRegisteredRepos({ validate: true })`.
|
|
777
|
+
*/
|
|
778
|
+
const REGISTRY_LOCK_TIMEOUT_MS = 5_000;
|
|
779
|
+
/**
|
|
780
|
+
* Serialize global registry read/merge/write transactions across processes.
|
|
781
|
+
*
|
|
782
|
+
* The registry is shared by every indexed repository, so per-index locks do
|
|
783
|
+
* not protect this file. Reuse the cross-platform index lock primitive with a
|
|
784
|
+
* registry-private lock namespace; the handle is kernel-owned on supported
|
|
785
|
+
* platforms and crash-reclaimable by the existing fallback.
|
|
786
|
+
*
|
|
787
|
+
* On timeout the transaction proceeds UNLOCKED rather than throwing: the lock
|
|
788
|
+
* closes a lost-update race that existed unguarded before #2716, so degrading
|
|
789
|
+
* to the old best-effort behaviour is strictly better than failing an
|
|
790
|
+
* `analyze`/`list`/`augment` outright on a wedged lock (a stale pid-reuse
|
|
791
|
+
* ghost on platforms without start-time verification can look live forever).
|
|
792
|
+
*/
|
|
793
|
+
const withRegistryLock = async (operation) => {
|
|
794
|
+
let lock = null;
|
|
795
|
+
try {
|
|
796
|
+
lock = await acquireIndexLock(getRegistryLockDir(), {
|
|
797
|
+
timeoutMs: REGISTRY_LOCK_TIMEOUT_MS,
|
|
798
|
+
// Registry contention was previously invisible: `acquireIndexLock`'s own
|
|
799
|
+
// `log` texts name an "analyze" holder, which misattributes a registry
|
|
800
|
+
// wait, so surface a registry-specific line instead (#2716 review).
|
|
801
|
+
onWaitStart: () => logger.info('Waiting for another GitNexus process to finish a registry update…'),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
if (!(err instanceof IndexLockTimeoutError))
|
|
806
|
+
throw err;
|
|
807
|
+
logger.warn({ timeoutMs: REGISTRY_LOCK_TIMEOUT_MS }, 'Timed out waiting for the global registry lock; proceeding without it. A concurrent registry write may be lost.');
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
return await operation();
|
|
811
|
+
}
|
|
812
|
+
finally {
|
|
813
|
+
lock?.release();
|
|
814
|
+
}
|
|
815
|
+
};
|
|
756
816
|
/**
|
|
757
817
|
* Read the global registry. Returns empty array if not found.
|
|
758
818
|
*/
|
|
@@ -859,7 +919,7 @@ const hasCustomAlias = (entry, inferredName) => {
|
|
|
859
919
|
* caller can re-use it to keep AGENTS.md / skill files aligned with the
|
|
860
920
|
* MCP-visible repo name (#979).
|
|
861
921
|
*/
|
|
862
|
-
|
|
922
|
+
const registerRepoUnlocked = async (repoPath, meta, opts) => {
|
|
863
923
|
// Preserve the caller's chosen path form in the registry — don't
|
|
864
924
|
// canonicalise at write time. This matters for two reasons:
|
|
865
925
|
// 1. `list` and error messages show the path the user actually
|
|
@@ -1017,11 +1077,12 @@ export const registerRepo = async (repoPath, meta, opts) => {
|
|
|
1017
1077
|
await writeRegistry(fresh);
|
|
1018
1078
|
return name;
|
|
1019
1079
|
};
|
|
1080
|
+
export const registerRepo = async (repoPath, meta, opts) => withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts));
|
|
1020
1081
|
/**
|
|
1021
1082
|
* Remove a repo from the global registry.
|
|
1022
1083
|
* Called after `gitnexus clean`.
|
|
1023
1084
|
*/
|
|
1024
|
-
|
|
1085
|
+
const unregisterRepoUnlocked = async (repoPath) => {
|
|
1025
1086
|
// Canonicalise BOTH sides so an unregister call issued with the
|
|
1026
1087
|
// symlink form (`/var/folders/.../repo`) still matches an entry
|
|
1027
1088
|
// written with the realpath form (`/private/var/folders/.../repo`),
|
|
@@ -1032,6 +1093,7 @@ export const unregisterRepo = async (repoPath) => {
|
|
|
1032
1093
|
const filtered = entries.filter((e) => !registryPathEquals(canonicalizePath(e.path), resolved));
|
|
1033
1094
|
await writeRegistry(filtered);
|
|
1034
1095
|
};
|
|
1096
|
+
export const unregisterRepo = async (repoPath) => withRegistryLock(() => unregisterRepoUnlocked(repoPath));
|
|
1035
1097
|
/**
|
|
1036
1098
|
* Remove a single non-primary branch's summary from a repo's registry entry
|
|
1037
1099
|
* (#2106 R7). Called by `gitnexus clean --branch`. Returns `true` when a
|
|
@@ -1040,7 +1102,7 @@ export const unregisterRepo = async (repoPath) => {
|
|
|
1040
1102
|
* primary entry is left intact; an empty `branches[]` is dropped to keep the
|
|
1041
1103
|
* registry shape legacy-clean.
|
|
1042
1104
|
*/
|
|
1043
|
-
|
|
1105
|
+
const removeBranchIndexUnlocked = async (repoPath, branch) => {
|
|
1044
1106
|
const resolved = canonicalizePath(repoPath);
|
|
1045
1107
|
const entries = await readRegistry();
|
|
1046
1108
|
const idx = entries.findIndex((e) => registryPathEquals(canonicalizePath(e.path), resolved));
|
|
@@ -1061,6 +1123,7 @@ export const removeBranchIndex = async (repoPath, branch) => {
|
|
|
1061
1123
|
await writeRegistry(entries);
|
|
1062
1124
|
return true;
|
|
1063
1125
|
};
|
|
1126
|
+
export const removeBranchIndex = async (repoPath, branch) => withRegistryLock(() => removeBranchIndexUnlocked(repoPath, branch));
|
|
1064
1127
|
/**
|
|
1065
1128
|
* Record that the flat workspace slot now serves `branch` (#2354).
|
|
1066
1129
|
*
|
|
@@ -1077,6 +1140,12 @@ export const removeBranchIndex = async (repoPath, branch) => {
|
|
|
1077
1140
|
* a no-op — including the sub-index deletion, which only runs for registered
|
|
1078
1141
|
* repos (never self-heals an unregistered repo, per #2264/#1169; the registry
|
|
1079
1142
|
* check precedes the rm per #2364 review F2) — and no subprocess is spawned.
|
|
1143
|
+
*
|
|
1144
|
+
* Only the closing re-read/mutate/write runs under the registry lock. The
|
|
1145
|
+
* recursive `rm` stays outside it — mirroring `clean.ts`, which deletes the
|
|
1146
|
+
* branch directory before calling the (locked) `removeBranchIndex` — so a slow
|
|
1147
|
+
* delete (large sub-index, AV scan, network mount) never blocks every other
|
|
1148
|
+
* registry operation on the machine.
|
|
1080
1149
|
*/
|
|
1081
1150
|
export const adoptFlatBranchLabel = async (repoPath, branch) => {
|
|
1082
1151
|
const canonicalInput = canonicalizePath(repoPath);
|
|
@@ -1121,26 +1190,28 @@ export const adoptFlatBranchLabel = async (repoPath, branch) => {
|
|
|
1121
1190
|
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.');
|
|
1122
1191
|
}
|
|
1123
1192
|
}
|
|
1124
|
-
// Re-read AFTER the potentially slow recursive rm
|
|
1125
|
-
// multi-writer whole-file overwrite, and writing a pre-rm
|
|
1126
|
-
// silently clobber concurrent registerRepo/removeBranchIndex
|
|
1127
|
-
// the #2106 R9 re-read-before-write discipline registerRepo follows.
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1193
|
+
// Re-read AFTER the potentially slow recursive rm, and under the lock: the
|
|
1194
|
+
// registry is a multi-writer whole-file overwrite, and writing a pre-rm
|
|
1195
|
+
// snapshot would silently clobber concurrent registerRepo/removeBranchIndex
|
|
1196
|
+
// writers — the #2106 R9 re-read-before-write discipline registerRepo follows.
|
|
1197
|
+
await withRegistryLock(async () => {
|
|
1198
|
+
const entries = await readRegistry();
|
|
1199
|
+
const idx = isRegistered(entries);
|
|
1200
|
+
if (idx < 0)
|
|
1201
|
+
return; // unregistered concurrently → still a no-op
|
|
1202
|
+
const entry = entries[idx];
|
|
1203
|
+
const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
|
|
1204
|
+
const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
|
|
1205
|
+
if (entry.branch === branch && !droppedSummary)
|
|
1206
|
+
return; // already coherent
|
|
1207
|
+
entry.branch = branch;
|
|
1208
|
+
if (remaining && remaining.length > 0)
|
|
1209
|
+
entry.branches = remaining;
|
|
1210
|
+
else
|
|
1211
|
+
delete entry.branches;
|
|
1212
|
+
entries[idx] = entry;
|
|
1213
|
+
await writeRegistry(entries);
|
|
1214
|
+
});
|
|
1144
1215
|
};
|
|
1145
1216
|
/**
|
|
1146
1217
|
* Thrown by {@link resolveRegistryEntry} when no registered repo matches
|
|
@@ -1454,9 +1525,19 @@ export const listRegisteredRepos = async (opts) => {
|
|
|
1454
1525
|
valid.push(entry);
|
|
1455
1526
|
}
|
|
1456
1527
|
}
|
|
1457
|
-
// If we pruned any entries, save the cleaned registry
|
|
1528
|
+
// If we pruned any entries, save the cleaned registry — under the lock, and
|
|
1529
|
+
// only then. The validation walk above is read-only (an fs.access per entry,
|
|
1530
|
+
// slow on a network mount or a large registry) and the common case prunes
|
|
1531
|
+
// nothing, so holding the global lock across it would serialize every
|
|
1532
|
+
// `gitnexus augment` behind unrelated registry work for no benefit. Re-read
|
|
1533
|
+
// inside the lock and drop the provably-absent paths from that fresh
|
|
1534
|
+
// snapshot, so a concurrent registration in the validation window survives.
|
|
1458
1535
|
if (valid.length !== entries.length) {
|
|
1459
|
-
|
|
1536
|
+
const pruned = new Set(entries.filter((entry) => !valid.includes(entry)).map((entry) => entry.path));
|
|
1537
|
+
await withRegistryLock(async () => {
|
|
1538
|
+
const fresh = await readRegistry();
|
|
1539
|
+
await writeRegistry(fresh.filter((entry) => !pruned.has(entry.path)));
|
|
1540
|
+
});
|
|
1460
1541
|
}
|
|
1461
1542
|
return valid;
|
|
1462
1543
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitnexus",
|
|
3
|
-
"version": "1.6.10-rc.
|
|
3
|
+
"version": "1.6.10-rc.138",
|
|
4
4
|
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
|
5
5
|
"author": "Abhigyan Patwari",
|
|
6
6
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
@@ -63,6 +63,9 @@ const PLATFORM_LOGIC = [
|
|
|
63
63
|
'test/unit/lbug-config-pagesize.test.ts',
|
|
64
64
|
'test/unit/worker-pool-windows-quarantine.test.ts',
|
|
65
65
|
'test/unit/lbug-pool-fts-load.test.ts',
|
|
66
|
+
// Global registry writes use the platform-specific index-lock backend
|
|
67
|
+
// (Windows named pipe, Linux socket, or macOS file lock). This includes the
|
|
68
|
+
// overlapping-registration regression from #2716 on every OS matrix.
|
|
66
69
|
'test/unit/repo-manager.test.ts',
|
|
67
70
|
'test/unit/repo-manager-finalize-invariant.test.ts',
|
|
68
71
|
'test/unit/git-utils.test.ts',
|