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, 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
- for (const { def } of candidates) {
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
- for (const sibling of bucket.moduleScopes) {
92
- if (sibling.filePath === filePath)
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 sibling of bucket.parsed) {
100
- if (sibling.filePath === filePath)
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 sharedSegmentCount(a, b) {
120
- const aSegments = a.replace(/\\/g, '/').split('/');
121
- const bSegments = b.replace(/\\/g, '/').split('/');
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
- export const registerRepo = async (repoPath, meta, opts) => {
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
- export const unregisterRepo = async (repoPath) => {
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
- export const removeBranchIndex = async (repoPath, branch) => {
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: the registry is a
1125
- // multi-writer whole-file overwrite, and writing a pre-rm snapshot would
1126
- // silently clobber concurrent registerRepo/removeBranchIndex writers —
1127
- // the #2106 R9 re-read-before-write discipline registerRepo follows.
1128
- const entries = await readRegistry();
1129
- const idx = isRegistered(entries);
1130
- if (idx < 0)
1131
- return; // unregistered concurrently → still a no-op
1132
- const entry = entries[idx];
1133
- const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
1134
- const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
1135
- if (entry.branch === branch && !droppedSummary)
1136
- return; // already coherent
1137
- entry.branch = branch;
1138
- if (remaining && remaining.length > 0)
1139
- entry.branches = remaining;
1140
- else
1141
- delete entry.branches;
1142
- entries[idx] = entry;
1143
- await writeRegistry(entries);
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
- await writeRegistry(valid);
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.136",
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',