gitnexus 1.6.5-rc.20 → 1.6.5-rc.21

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/dist/cli/setup.js CHANGED
@@ -308,6 +308,12 @@ async function installClaudeCodeHooks(result) {
308
308
  catch {
309
309
  // Script not found in source — skip
310
310
  }
311
+ try {
312
+ await fs.copyFile(path.join(pluginHooksPath, 'hook-lock.cjs'), path.join(destHooksDir, 'hook-lock.cjs'));
313
+ }
314
+ catch {
315
+ // Helper not found in source — skip
316
+ }
311
317
  const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
312
318
  // Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
313
319
  // The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
@@ -14,6 +14,7 @@
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
  const { spawnSync } = require('child_process');
17
+ const { acquireHookSlot } = require('./hook-lock.cjs');
17
18
 
18
19
  /**
19
20
  * Read JSON input from stdin synchronously.
@@ -207,7 +208,8 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
207
208
  function handlePreToolUse(input) {
208
209
  const cwd = input.cwd || process.cwd();
209
210
  if (!path.isAbsolute(cwd)) return;
210
- if (!findGitNexusDir(cwd)) return;
211
+ const gitNexusDir = findGitNexusDir(cwd);
212
+ if (!gitNexusDir) return;
211
213
 
212
214
  const toolName = input.tool_name || '';
213
215
  const toolInput = input.tool_input || {};
@@ -217,6 +219,9 @@ function handlePreToolUse(input) {
217
219
  const pattern = extractPattern(toolName, toolInput);
218
220
  if (!pattern || pattern.length < 3) return;
219
221
 
222
+ const release = acquireHookSlot(gitNexusDir);
223
+ if (!release) return;
224
+
220
225
  const cliPath = resolveCliPath();
221
226
  let result = '';
222
227
  try {
@@ -226,6 +231,8 @@ function handlePreToolUse(input) {
226
231
  }
227
232
  } catch {
228
233
  /* graceful failure */
234
+ } finally {
235
+ release();
229
236
  }
230
237
 
231
238
  if (result && result.trim()) {
@@ -0,0 +1,119 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const HOOK_LOCK_SUBDIR = '.hook-locks';
5
+ const HOOK_LOCK_MAX_INFLIGHT = 3;
6
+ const HOOK_LOCK_STALE_MS = 30000;
7
+
8
+ function acquireHookSlot(gitNexusDir) {
9
+ const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
10
+ try {
11
+ fs.mkdirSync(lockDir, { recursive: true });
12
+ } catch {
13
+ // Cannot create lock dir (read-only fs, cross-user perm denial, out of
14
+ // inodes, etc.) — fail closed by returning null. Caller skips augment.
15
+ // Fail-open here would let N concurrent hooks all proceed unguarded and
16
+ // reintroduce the #1486 fan-out the guard exists to prevent.
17
+ return null;
18
+ }
19
+
20
+ const myPidStr = String(process.pid);
21
+
22
+ for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
23
+ const slotPath = path.join(lockDir, `slot-${slot}.lock`);
24
+ for (let attempt = 0; attempt < 2; attempt++) {
25
+ try {
26
+ fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
27
+ let released = false;
28
+ const release = () => {
29
+ if (released) return;
30
+ released = true;
31
+ try {
32
+ // Only unlink if we still own the slot. If we appeared stale and
33
+ // another hook took over, the file now belongs to it — leave alone.
34
+ const content = fs.readFileSync(slotPath, 'utf-8').trim();
35
+ if (content === myPidStr) fs.unlinkSync(slotPath);
36
+ } catch {
37
+ /* already removed or unreadable */
38
+ }
39
+ };
40
+ process.on('exit', release);
41
+ return release;
42
+ } catch {
43
+ // Slot exists. Decide whether to take it over.
44
+ // Open once and inspect mtime + content via the same fd so there's
45
+ // no TOCTOU between the metadata check and the content read
46
+ // (codeql js/file-system-race).
47
+ let fd;
48
+ try {
49
+ fd = fs.openSync(slotPath, 'r');
50
+ } catch {
51
+ continue; // Vanished between EEXIST and open — retry this slot.
52
+ }
53
+ let isLive = false;
54
+ let mtimeMs = Date.now();
55
+ try {
56
+ mtimeMs = fs.fstatSync(fd).mtimeMs;
57
+ const buf = Buffer.alloc(32);
58
+ const n = fs.readSync(fd, buf, 0, 32, 0);
59
+ const ownerStr = buf.slice(0, n).toString('utf-8').trim();
60
+ if (ownerStr === '') {
61
+ // Owner created the file but hasn't written its PID yet. The
62
+ // wx open+write window is microseconds; give it the benefit
63
+ // of the doubt and treat as live.
64
+ isLive = true;
65
+ } else {
66
+ const owner = Number.parseInt(ownerStr, 10);
67
+ if (Number.isFinite(owner) && owner > 0) {
68
+ try {
69
+ process.kill(owner, 0);
70
+ isLive = true;
71
+ } catch (e) {
72
+ // ESRCH = process gone → treat as dead. EPERM = process exists
73
+ // but owned by another user (cross-user lock dir) → still alive,
74
+ // keep the slot. Anything else: be conservative, assume alive.
75
+ if (e && e.code === 'ESRCH') {
76
+ isLive = false;
77
+ } else {
78
+ isLive = true;
79
+ }
80
+ }
81
+ }
82
+ }
83
+ } catch {
84
+ /* unreadable — treat as dead */
85
+ } finally {
86
+ try {
87
+ fs.closeSync(fd);
88
+ } catch {
89
+ /* already closed */
90
+ }
91
+ }
92
+ // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
93
+ // a slow-but-alive hook is never wrongly evicted. For older slots,
94
+ // age is the final arbiter as a defense against PID reuse on long-
95
+ // abandoned slots. 30s >> the 7s augment timeout, so a healthy run
96
+ // never crosses this threshold.
97
+ if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
98
+ isLive = false;
99
+ }
100
+ if (isLive) break; // Try the next slot.
101
+ try {
102
+ fs.unlinkSync(slotPath);
103
+ } catch {
104
+ /* another hook beat us to it — retry will hit EEXIST */
105
+ }
106
+ // Loop and retry this slot.
107
+ }
108
+ }
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ module.exports = {
115
+ HOOK_LOCK_SUBDIR,
116
+ HOOK_LOCK_MAX_INFLIGHT,
117
+ HOOK_LOCK_STALE_MS,
118
+ acquireHookSlot,
119
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.5-rc.20",
3
+ "version": "1.6.5-rc.21",
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",