@rmyndharis/aimhooman 0.1.0 → 0.1.2

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/bin/aimhooman.mjs CHANGED
@@ -3,24 +3,24 @@ import { execFileSync } from 'node:child_process';
3
3
  import { chmodSync, lstatSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
6
7
  import { newEngineWithDiagnostics } from '../src/scan.mjs';
7
8
  import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
8
9
  import {
9
10
  GitRevisionError,
10
11
  gitConfig,
11
- hasStagedChanges,
12
12
  introducedCommits,
13
13
  openRepo,
14
14
  readCommitPath,
15
15
  readStagedPath,
16
+ stagedPaths,
16
17
  stagedRenameSources,
17
18
  unstagePaths,
18
19
  withIndexFromTree,
19
20
  } from '../src/gitx.mjs';
20
21
  import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
21
22
  import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
22
- import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
23
- import { runHook } from '../src/hook.mjs';
23
+ import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
24
24
  import { ArgumentError, parseArguments } from '../src/args.mjs';
25
25
  import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
26
26
  import { resolvePolicy } from '../src/policy-resolver.mjs';
@@ -38,8 +38,7 @@ const LIFECYCLE_LOCK_OPTIONS = { retries: 1000 };
38
38
  function tryRepo() {
39
39
  try {
40
40
  return openRepo();
41
- } catch (error) {
42
- if (error?.name === 'StateMigrationError') throw error;
41
+ } catch {
43
42
  return null;
44
43
  }
45
44
  }
@@ -49,6 +48,7 @@ function currentRepositoryIsBare() {
49
48
  return execFileSync('git', ['rev-parse', '--is-bare-repository'], {
50
49
  encoding: 'utf8',
51
50
  stdio: ['ignore', 'pipe', 'ignore'],
51
+ timeout: GIT_TIMEOUT_MS,
52
52
  }).trim() === 'true';
53
53
  } catch {
54
54
  return false;
@@ -160,10 +160,17 @@ function emitDiagnostics(diagnostics = []) {
160
160
  }
161
161
 
162
162
  function incompleteMessage(scan) {
163
- const skipped = Object.entries(scan.stats?.skipped || {})
163
+ const reasons = scan.stats?.skipped || {};
164
+ const skipped = Object.entries(reasons)
164
165
  .map(([reason, count]) => `${reason}=${count}`)
165
166
  .join(', ');
166
- return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; reduce the target or limits and retry\n`;
167
+ // Every other reason is a size or budget the caller can shrink. A pack that
168
+ // will not compile is not, and the warning above already names the file and
169
+ // the error, so point at that instead of misdirecting to the limits.
170
+ const hint = reasons['local-pack-error']
171
+ ? 'fix the reported rule pack and retry'
172
+ : 'reduce the target or limits and retry';
173
+ return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
167
174
  }
168
175
 
169
176
  function snapshotFile(path) {
@@ -194,6 +201,7 @@ function gitConfigAtScope(root, scope, key) {
194
201
  cwd: root,
195
202
  encoding: 'utf8',
196
203
  stdio: ['ignore', 'pipe', 'ignore'],
204
+ timeout: GIT_TIMEOUT_MS,
197
205
  }).trim();
198
206
  } catch {
199
207
  return '';
@@ -201,7 +209,7 @@ function gitConfigAtScope(root, scope, key) {
201
209
  }
202
210
 
203
211
  function gitVersion() {
204
- try { return execFileSync('git', ['--version'], { encoding: 'utf8' }).trim(); }
212
+ try { return execFileSync('git', ['--version'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim(); }
205
213
  catch { return 'Git unavailable'; }
206
214
  }
207
215
 
@@ -284,9 +292,41 @@ function cmdPrecommit(args) {
284
292
  }
285
293
  }
286
294
  for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
295
+ // The empty-commit hint is derived from the staged paths captured before
296
+ // repair, not from a second git read after `git restore --staged`. That
297
+ // post-repair read followed an index write and could transiently report
298
+ // the wrong state under heavy CI load, flaking the repair tests.
299
+ // unstagePaths is atomic (it throws on failure), so when every staged
300
+ // path is a repair target the index matches HEAD afterward. The capture
301
+ // is best-effort and runs before the repair: if this read fails the
302
+ // unstage still runs and the hint is omitted (empty stays false) rather
303
+ // than skipping the repair or blocking the commit.
304
+ let stagedBefore;
305
+ try {
306
+ stagedBefore = stagedPaths(repo);
307
+ } catch {
308
+ stagedBefore = null;
309
+ }
287
310
  unstagePaths(repo, [...unstageTargets]);
288
- let empty = false;
289
- try { empty = !hasStagedChanges(repo); } catch { /* could not re-read */ }
311
+ // Under heavy CI load a `git restore --staged` or the rename-source
312
+ // detection above can transiently leave a target staged, which flakes
313
+ // the repair tests and would let an artifact ride through. Re-detect
314
+ // rename sources and re-unstage any still-staged target until every
315
+ // target is gone or the budget is reached.
316
+ for (let attempt = 0; attempt < 4; attempt += 1) {
317
+ let stillStaged;
318
+ try {
319
+ for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
320
+ stillStaged = new Set(stagedPaths(repo));
321
+ } catch {
322
+ break;
323
+ }
324
+ const pending = [...unstageTargets].filter((path) => stillStaged.has(path));
325
+ if (!pending.length) break;
326
+ unstagePaths(repo, pending);
327
+ }
328
+ const empty = stagedBefore !== null
329
+ && stagedBefore.every((path) => unstageTargets.has(path));
290
330
  process.stderr.write(
291
331
  `aimhooman: unstaged ${paths.length} AI artifact(s) from this commit: ${paths.map(visible).join(', ')}${empty ? ' (nothing else staged — the commit will be empty)' : ''}\n`
292
332
  );
@@ -456,7 +496,7 @@ function cmdRefcheck(args) {
456
496
  return 30;
457
497
  }
458
498
  if (/^0+$/.test(newObjectId)) continue;
459
- updates.push({ oldObjectId, newObjectId });
499
+ updates.push({ oldObjectId, newObjectId, ref });
460
500
  }
461
501
 
462
502
  let commits;
@@ -619,7 +659,7 @@ function cmdInit(args) {
619
659
  const aimDir = globalHooksDir();
620
660
  let existing = '';
621
661
  try {
622
- existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
662
+ existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
623
663
  } catch { /* unset */ }
624
664
  if (existing && existing !== aimDir) {
625
665
  console.error(
@@ -632,7 +672,7 @@ function cmdInit(args) {
632
672
  // could silently disable a system-wide hook manager, so refuse here too.
633
673
  let systemHooksPath = '';
634
674
  try {
635
- systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
675
+ systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
636
676
  } catch { /* unset or no system config */ }
637
677
  if (systemHooksPath) {
638
678
  console.error(
@@ -656,7 +696,7 @@ function cmdInit(args) {
656
696
  console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
657
697
  return 20;
658
698
  }
659
- execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir]);
699
+ execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
660
700
  } catch (error) {
661
701
  const rollbackFailures = [];
662
702
  for (const snapshot of hookSnapshots.reverse()) {
@@ -666,8 +706,8 @@ function cmdInit(args) {
666
706
  }
667
707
  }
668
708
  try {
669
- if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing]);
670
- else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore' });
709
+ if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
710
+ else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
671
711
  } catch (rollbackError) {
672
712
  rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
673
713
  }
@@ -728,7 +768,13 @@ function cmdInit(args) {
728
768
  rep = installHooks(repo, CLI_PATH);
729
769
  const activeHooks = installedHooks(repo);
730
770
  if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
731
- throw new Error('hook installation incomplete; repository guard is not active');
771
+ // installHooks declines rather than throws when the hooks directory is
772
+ // not ours, and its warnings are the only record of why. They are
773
+ // printed on the success path only, so carry them into the failure or
774
+ // the user is told nothing but "incomplete". The prefix is load-bearing:
775
+ // the exit-code branch below matches on it.
776
+ const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
777
+ throw new Error(`hook installation incomplete; ${cause}repository guard is not active`);
732
778
  }
733
779
  saveConfig(repo.stateDir, { profile });
734
780
  applyExclude(repo.excludeFile, patternsForRules(eng.rules));
@@ -816,7 +862,6 @@ function cmdStatus(args) {
816
862
  console.log(`overrides: ${overrides.allow.length} allow, ${overrides.deny.length} deny`);
817
863
  console.log(`excludes: ${excludeError ? `unknown (malformed markers: ${excludeError}; run: aimhooman init)` : excludes.current ? 'current' : excludes.installed ? 'out of date (run: aimhooman init)' : 'not installed (run: aimhooman init)'}`);
818
864
  for (const error of errors) console.log(`warning: ${error.message}`);
819
- for (const diagnostic of repo.stateDiagnostics || []) console.log(`${diagnostic.level || 'info'}: ${diagnostic.message}`);
820
865
  const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
821
866
  const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
822
867
  console.log(`hooks path: local=${localHooks || 'unset'}, global=${globalHooks || 'unset'}`);
@@ -885,6 +930,19 @@ function cmdOverride(args, allow) {
885
930
  `secret rules cannot be allowed at --scope rule (that would suppress every matching secret path under every profile); use --scope secret-path <path> to allow a specific secret path`,
886
931
  );
887
932
  }
933
+ // A path that matches a secret rule cannot be silenced by a path (or rule)
934
+ // allow: only --scope secret-path suppresses secret findings, deliberately,
935
+ // so a local override cannot hide a possible leaked key. Without this check
936
+ // a bare `allow .env.minimal` would report success but leave the block in
937
+ // place, which reads as a broken allow.
938
+ if (allow && scope !== 'secret-path'
939
+ && engine.checkPaths([target]).some((finding) => finding.category === 'secret')) {
940
+ throw new ArgumentError(
941
+ `"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
942
+ + '(a local override must not hide a possible leaked key); '
943
+ + 'use --scope secret-path to explicitly allow this specific path',
944
+ );
945
+ }
888
946
  if (scope === 'secret-path' && !allow) {
889
947
  throw new ArgumentError('--scope secret-path is only valid with allow');
890
948
  }
@@ -1370,10 +1428,6 @@ function cmdDoctor(args) {
1370
1428
  } else {
1371
1429
  console.log(`ok host adapters present (${adapters.present}/${adapters.total})`);
1372
1430
  }
1373
- for (const diagnostic of repo.stateDiagnostics || []) {
1374
- console.log(`${diagnostic.level === 'error' ? 'x' : '!'} state: ${diagnostic.message}`);
1375
- if (diagnostic.level === 'error') ok = false;
1376
- }
1377
1431
  const gitRuntime = supportedGitVersion();
1378
1432
  if (gitRuntime.supported) {
1379
1433
  console.log(`ok runtime Node ${process.versions.node}; ${gitRuntime.display}`);
@@ -1441,7 +1495,7 @@ function cmdUninstall(args) {
1441
1495
  const aimDir = globalHooksDir();
1442
1496
  const readGlobalHooksPath = () => {
1443
1497
  try {
1444
- return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
1498
+ return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
1445
1499
  } catch { return ''; }
1446
1500
  };
1447
1501
  const current = readGlobalHooksPath();
@@ -1449,7 +1503,7 @@ function cmdUninstall(args) {
1449
1503
  if (current === aimDir) {
1450
1504
  unsetAttempted = true;
1451
1505
  try {
1452
- execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'] });
1506
+ execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
1453
1507
  } catch { /* may have failed; re-verify below */ }
1454
1508
  // A read-only or locked ~/.gitconfig can make --unset fail silently.
1455
1509
  // Do not remove the dispatchers while core.hooksPath still resolves to
@@ -1484,11 +1538,20 @@ function cmdUninstall(args) {
1484
1538
  return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
1485
1539
  const rep = uninstallHooks(repo);
1486
1540
  removeExclude(repo.excludeFile);
1487
- console.log('aimhooman: uninstalled');
1541
+ // Trust the directory, not the report. Every refusal below leaves a working
1542
+ // dispatcher behind, and one printed under "uninstalled" reads as done.
1543
+ const remaining = remainingDispatchers(repo);
1544
+ if (remaining.length) {
1545
+ console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
1546
+ for (const path of remaining) console.error(` ${path}`);
1547
+ console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
1548
+ } else {
1549
+ console.log('aimhooman: uninstalled');
1550
+ }
1488
1551
  if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
1489
1552
  if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
1490
1553
  for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1491
- for (const f of rep.failures || []) console.log(` warning: ${f}`);
1554
+ for (const f of rep.failures || []) console.error(` failure: ${f}`);
1492
1555
  const unrestored = purge ? unrestoredChainedBackups(repo) : [];
1493
1556
  if (purge) {
1494
1557
  // Never wipe stateDir while a predecessor hook backup is still on disk:
@@ -1516,6 +1579,7 @@ function cmdUninstall(args) {
1516
1579
  try {
1517
1580
  globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
1518
1581
  encoding: 'utf8',
1582
+ timeout: GIT_TIMEOUT_MS,
1519
1583
  }).trim();
1520
1584
  } catch { /* unset */ }
1521
1585
  if (globalHooksPath === aimDir) {
@@ -1523,7 +1587,7 @@ function cmdUninstall(args) {
1523
1587
  console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
1524
1588
  console.log(' run `aimhooman uninstall --global` to remove it.');
1525
1589
  }
1526
- return rep.failures?.length || unrestored.length ? 30 : 0;
1590
+ return remaining.length || rep.failures?.length || unrestored.length ? 30 : 0;
1527
1591
  }, LIFECYCLE_LOCK_OPTIONS);
1528
1592
  }
1529
1593
 
@@ -1563,7 +1627,14 @@ if (argv[0] === 'hook') {
1563
1627
  console.error('aimhooman: hook requires exactly one supported event');
1564
1628
  process.exit(20);
1565
1629
  }
1566
- runHook(hookArgs)
1630
+ // hook.mjs (the PreToolUse shell parser) is by far the largest module and is
1631
+ // only needed for the `hook` subcommand. Loading it lazily cuts the heaviest
1632
+ // parse cost from every other command's startup, so the lifecycle-lock
1633
+ // candidate is published earlier. This widens the margin under the queue-wait
1634
+ // budget rather than guaranteeing it: openRepo's git spawns still set the
1635
+ // floor on slow runners.
1636
+ import('../src/hook.mjs')
1637
+ .then(({ runHook }) => runHook(hookArgs))
1567
1638
  .then((code) => {
1568
1639
  // Drain the permission decision before exiting: on a piped stdout,
1569
1640
  // process.exit() can cut off the emit() write and drop a deny. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
5
5
  "homepage": "https://github.com/rmyndharis/aimhooman#readme",
6
6
  "repository": {
@@ -1,14 +1,14 @@
1
1
  [
2
2
  {
3
3
  "id": "attribution.claude-coauthor",
4
- "version": 1,
4
+ "version": 2,
5
5
  "provider": "claude-code",
6
6
  "category": "ai-attribution",
7
7
  "confidence": "high",
8
8
  "kind": "message",
9
9
  "match": {
10
10
  "content": [
11
- "(?i)^\\s*co-authored-by:\\s*(?:claude|claude code)\\s*<noreply@anthropic\\.com>\\s*$"
11
+ "(?i)^\\s*co-authored-by:[^<>]+<noreply@anthropic\\.com>\\s*$"
12
12
  ]
13
13
  },
14
14
  "actions": {
@@ -49,14 +49,14 @@
49
49
  },
50
50
  {
51
51
  "id": "attribution.codex-coauthor",
52
- "version": 1,
52
+ "version": 2,
53
53
  "provider": "codex",
54
54
  "category": "ai-attribution",
55
55
  "confidence": "high",
56
56
  "kind": "message",
57
57
  "match": {
58
58
  "content": [
59
- "(?i)^\\s*co-authored-by:\\s*(?:openai\\s+)?codex\\s*<noreply@openai\\.com>\\s*$"
59
+ "(?i)^\\s*co-authored-by:[^<>]+<noreply@openai\\.com>\\s*$"
60
60
  ]
61
61
  },
62
62
  "actions": {
@@ -95,7 +95,7 @@
95
95
  },
96
96
  {
97
97
  "id": "attribution.generated-with",
98
- "version": 1,
98
+ "version": 2,
99
99
  "provider": "generic",
100
100
  "category": "ai-attribution",
101
101
  "confidence": "high",
@@ -103,7 +103,7 @@
103
103
  "match": {
104
104
  "content": [
105
105
  "(?i)^\\s*generated\\s+(?:with|by)\\s+(?:claude(?:\\s+code)?|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)[.!]?\\s*$",
106
- "(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[claude code\\]\\(https://claude\\.ai/code\\)\\s*$"
106
+ "(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[(?:claude(?:\\s+code)?|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)\\]\\([^)]*\\)\\s*$"
107
107
  ]
108
108
  },
109
109
  "actions": {
@@ -126,7 +126,7 @@
126
126
  "kind": "message",
127
127
  "match": {
128
128
  "content": [
129
- "(?i)^\\s*[a-z-]+-by:\\s*[^<>]+\\s*<noreply@(anthropic|openai)\\.com>\\s*$"
129
+ "(?i)^\\s*[a-z-]+-by:[^<>]+<noreply@(anthropic|openai)\\.com>\\s*$"
130
130
  ]
131
131
  },
132
132
  "actions": {
@@ -39,7 +39,7 @@ export function atomicWrite(file, data, options = {}) {
39
39
  operations.sync(descriptor);
40
40
  operations.close(descriptor);
41
41
  descriptor = undefined;
42
- operations.rename(temporary, file);
42
+ renameWithRetry(operations, temporary, file, options.renameRetries);
43
43
  try {
44
44
  syncDirectory(directory, operations);
45
45
  } catch (error) {
@@ -57,6 +57,30 @@ export function atomicWrite(file, data, options = {}) {
57
57
  }
58
58
  }
59
59
 
60
+ // Windows fails a rename with EPERM/EACCES/EBUSY while another handle is open on
61
+ // either path — an antivirus or indexer scanning the file aimhooman just wrote is
62
+ // enough, and it clears within milliseconds. Observed on CI as a lock contender
63
+ // dying at its ticket publication, which then read as a lifecycle-queue timeout.
64
+ // The rename is the atomic commit point, so a retry either lands the complete
65
+ // file or leaves the original untouched; it can never publish a partial write.
66
+ // A non-transient error (and every code on other platforms) still throws at once.
67
+ const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
68
+
69
+ function renameWithRetry(operations, temporary, file, retries = 20) {
70
+ for (let attempt = 0; ; attempt += 1) {
71
+ try {
72
+ operations.rename(temporary, file);
73
+ return;
74
+ } catch (error) {
75
+ const retryable = process.platform === 'win32'
76
+ && TRANSIENT_RENAME_CODES.has(error?.code)
77
+ && attempt + 1 < retries;
78
+ if (!retryable) throw error;
79
+ waitForLock(5);
80
+ }
81
+ }
82
+ }
83
+
60
84
  function existingMode(file) {
61
85
  try {
62
86
  return statSync(file).mode & 0o777;
@@ -87,6 +111,7 @@ function syncDirectory(directory, operations) {
87
111
  }
88
112
 
89
113
  const LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
114
+ const IDENTITY_PROBE_TIMEOUT_MS = 5_000;
90
115
 
91
116
  function waitForLock(milliseconds) {
92
117
  if (milliseconds > 0) Atomics.wait(LOCK_WAIT_BUFFER, 0, 0, milliseconds);
@@ -112,6 +137,12 @@ function processIdentity(pid) {
112
137
  const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
113
138
  encoding: 'utf8',
114
139
  stdio: ['ignore', 'pipe', 'ignore'],
140
+ // This is the only spawn on the lock path, and it runs on macOS
141
+ // and BSD alone: Linux reads /proc and Windows returns early.
142
+ // The retry loop below budgets milliseconds per attempt, so a
143
+ // probe that stalls must lose rather than stretch the loop. A
144
+ // timeout throws into the catch below, which fails safe.
145
+ timeout: IDENTITY_PROBE_TIMEOUT_MS,
115
146
  }).trim();
116
147
  return started ? `ps:${started}` : null;
117
148
  }
@@ -3,3 +3,13 @@
3
3
  export function gitEnvironment(environment = process.env) {
4
4
  return { ...environment, GIT_NO_REPLACE_OBJECTS: '1' };
5
5
  }
6
+
7
+ // execFileSync has no default timeout, so a git child that never exits blocks
8
+ // the caller forever. One did: it held a CI runner until the platform's 6-hour
9
+ // ceiling killed the job, with no error to read afterwards.
10
+ //
11
+ // Real calls here finish in milliseconds. This bound exists to end a stuck one,
12
+ // not to pace a slow one, so it sits far above any legitimate call: a huge
13
+ // repository on a slow disk stays well inside it, and a hang now raises an
14
+ // error naming the command instead of going quiet.
15
+ export const GIT_TIMEOUT_MS = 120_000;