@rmyndharis/aimhooman 0.1.1 → 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/src/githooks.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
+ import { GIT_TIMEOUT_MS } from './git-environment.mjs';
3
4
  import {
4
5
  accessSync,
5
6
  chmodSync,
@@ -38,13 +39,19 @@ function hooksDir(repo) {
38
39
 
39
40
  const scope = configScope(repo, 'core.hooksPath');
40
41
  const localScope = ['local', 'worktree'].includes(scope);
41
- const repositoryOwned = localScope && repositoryOwnsPath(repo, dir);
42
+ const inside = localScope && repositoryOwnsPath(repo, dir);
43
+ // Inside the worktree is not the same as ours. A hooks directory Git tracks
44
+ // (husky, the vanilla .githooks pattern) is repository content: a dispatcher
45
+ // written there stages this machine's absolute CLI, Node, and PATH for
46
+ // everyone who clones.
47
+ const tracked = inside && trackedPath(repo, dir);
48
+ const repositoryOwned = inside && !tracked;
42
49
  const shared = !repositoryOwned || resolve(dir) === resolve(globalHooksDir());
43
50
  if (shared) {
44
51
  const where = scope ? `${scope} scope` : 'a non-local scope';
45
- const reason = localScope && !repositoryOwned
46
- ? `${where}, outside this repository`
47
- : where;
52
+ let reason = where;
53
+ if (tracked) reason = `${where}, tracked by this repository`;
54
+ else if (localScope && !inside) reason = `${where}, outside this repository`;
48
55
  return {
49
56
  dir,
50
57
  shared: true,
@@ -60,16 +67,43 @@ function hooksDir(repo) {
60
67
  };
61
68
  }
62
69
 
70
+ function pathContains(root, candidate) {
71
+ const rel = relative(root, candidate);
72
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
73
+ }
74
+
63
75
  function repositoryOwnsPath(repo, path) {
64
76
  const candidate = canonicalPath(path);
65
77
  return [repo.root, repo.commonDir, repo.gitDir]
66
78
  .filter(Boolean)
67
79
  .map(canonicalPath)
68
- .some((root) => {
69
- const rel = relative(root, candidate);
70
- return rel === '' || (!rel.startsWith(`..${sep}`)
71
- && rel !== '..' && !isAbsolute(rel));
72
- });
80
+ .some((root) => pathContains(root, candidate));
81
+ }
82
+
83
+ // insideGitDir is the part of repositoryOwnsPath that cannot be shared. A .git
84
+ // belongs to exactly one repository, while a directory in the worktree can be
85
+ // the core.hooksPath of a second one.
86
+ function insideGitDir(repo, path) {
87
+ const candidate = canonicalPath(path);
88
+ return [repo.commonDir, repo.gitDir]
89
+ .filter(Boolean)
90
+ .map(canonicalPath)
91
+ .some((root) => pathContains(root, candidate));
92
+ }
93
+
94
+ // trackedPath reports whether Git tracks anything under path. A Git that cannot
95
+ // answer counts as tracked: refusing to install costs a warning, while guessing
96
+ // wrong commits this machine's absolute paths into a shared repository.
97
+ function trackedPath(repo, path) {
98
+ try {
99
+ return execFileSync(
100
+ 'git',
101
+ ['--literal-pathspecs', 'ls-files', '-z', '--', String(path)],
102
+ { cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }
103
+ ).length > 0;
104
+ } catch {
105
+ return true;
106
+ }
73
107
  }
74
108
 
75
109
  function canonicalPath(path) {
@@ -95,13 +129,14 @@ function effectiveHooksDir(repo) {
95
129
  return execFileSync(
96
130
  'git',
97
131
  ['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
98
- { cwd: repo.root, encoding: 'utf8' }
132
+ { cwd: repo.root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }
99
133
  ).trim();
100
134
  } catch {
101
135
  try {
102
136
  const path = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
103
137
  cwd: repo.root,
104
138
  encoding: 'utf8',
139
+ timeout: GIT_TIMEOUT_MS,
105
140
  }).trim();
106
141
  return isAbsolute(path) ? path : resolve(repo.root, path);
107
142
  } catch {
@@ -115,6 +150,7 @@ function configScope(repo, key) {
115
150
  const line = execFileSync('git', ['config', '--show-scope', '--get', key], {
116
151
  cwd: repo.root,
117
152
  encoding: 'utf8',
153
+ timeout: GIT_TIMEOUT_MS,
118
154
  }).trim();
119
155
  return line.split(/\s/, 1)[0];
120
156
  } catch {
@@ -125,6 +161,7 @@ function configScope(repo, key) {
125
161
  execFileSync('git', ['config', '--local', '--get', key], {
126
162
  cwd: repo.root,
127
163
  stdio: ['ignore', 'ignore', 'ignore'],
164
+ timeout: GIT_TIMEOUT_MS,
128
165
  });
129
166
  return 'local';
130
167
  } catch {
@@ -307,7 +344,7 @@ function installHooksLocked(repo, cliPath, options, dir, warnings) {
307
344
  continue;
308
345
  }
309
346
  if (current && ownedHook(readFileSync(dest, 'utf8'), name)
310
- && !ownedForChain(readFileSync(dest, 'utf8'), name, chainedPath)) {
347
+ && !ownedByRepo(repo, dir, readFileSync(dest, 'utf8'), name, chainedPath)) {
311
348
  warnings.push(`${name} is managed for another repository; refusing to overwrite it`);
312
349
  unsafe = true;
313
350
  }
@@ -381,13 +418,18 @@ function uninstallHooksLocked(repo, dir, warnings) {
381
418
  const chained = join(chainedDir, name);
382
419
  const content = readFileSync(dest, 'utf8');
383
420
  if (!ownedHook(content, name)) continue;
384
- if (!ownedForChain(content, name, chained)) {
385
- warnings.push(`${name} is managed for another repository; refusing to remove it`);
421
+ if (!ownedByRepo(repo, dir, content, name, chained)) {
422
+ failures.push(`${name}: managed for another repository; left in place at ${dest}`);
386
423
  continue;
387
424
  }
388
425
  const predecessor = entry(chained);
389
426
  if (predecessor?.isSymbolicLink()) {
390
- warnings.push(`${name} chained backup is a symlink; refusing to restore it`);
427
+ // Never read or copy through the symlink, but do let go of the
428
+ // dispatcher: holding the repository hostage over a backup we
429
+ // refuse to touch helps nobody.
430
+ warnings.push(`${name} chained backup is a symlink; ${chained} left in place and your original hook was not restored`);
431
+ unlinkSync(dest);
432
+ removed.push(name);
391
433
  continue;
392
434
  }
393
435
  if (predecessor) {
@@ -407,6 +449,34 @@ function uninstallHooksLocked(repo, dir, warnings) {
407
449
  return { removed: removed.sort(), restored: restored.sort(), warnings, failures };
408
450
  }
409
451
 
452
+ // remainingDispatchers lists aimhooman dispatchers a local uninstall was meant
453
+ // to remove and did not. uninstall checks the directory rather than trusting its
454
+ // own report: a refusal that only produced a warning would otherwise be printed
455
+ // under a success headline, and "uninstalled" while four dispatchers still block
456
+ // every commit is the one lie a guard must never tell.
457
+ //
458
+ // A shared hooks directory is skipped because uninstallHooks does not touch one
459
+ // either — the global directory has `uninstall --global`, and a foreign or
460
+ // tracked one is reported by its own warning. This asks hooksDir rather than
461
+ // comparing paths: two spellings of one directory differ on Windows, and
462
+ // deciding ownership by string is the bug this change exists to remove.
463
+ export function remainingDispatchers(repo) {
464
+ const { dir, shared } = hooksDir(repo);
465
+ if (shared) return [];
466
+ return Object.keys(MANAGED).sort().flatMap((name) => {
467
+ const path = join(dir, name);
468
+ try {
469
+ const stat = entry(path);
470
+ if (!stat || stat.isSymbolicLink()) return [];
471
+ return ownedHook(readFileSync(path, 'utf8'), name) ? [path] : [];
472
+ } catch {
473
+ // Unreadable: cannot prove it is gone, so report it rather than
474
+ // claim a clean removal.
475
+ return [path];
476
+ }
477
+ });
478
+ }
479
+
410
480
  // hookDiagnostics reports both dispatcher integrity and whether its configured
411
481
  // command can be reached from the current environment.
412
482
  export function hookDiagnostics(repo) {
@@ -414,8 +484,12 @@ export function hookDiagnostics(repo) {
414
484
  // Global hooks (installGlobalHooks) keep chained backups in the hooks dir
415
485
  // itself; local hooks keep them in repo state via chainDir. Diagnose the
416
486
  // path the dispatcher actually embeds, not the repo-relative default.
487
+ // canonicalPath, not resolve: a HOME behind a symlink (Fedora Silverblue
488
+ // ships /home -> /var/home; NFS and autofs homes are everywhere) makes the
489
+ // two spellings differ, and every global dispatcher would then be diagnosed
490
+ // as belonging to another repository.
417
491
  const globalDir = globalHooksDir();
418
- const chained = resolve(dir) === resolve(globalDir)
492
+ const chained = canonicalPath(dir) === canonicalPath(globalDir)
419
493
  ? join(globalDir, 'chained')
420
494
  : chainDir(repo);
421
495
  return Object.keys(MANAGED).sort().map((name) => {
@@ -458,7 +532,7 @@ export function hookDiagnostics(repo) {
458
532
  reason: inspected.reason,
459
533
  };
460
534
  }
461
- if (!ownedForChain(content, name, base.chainedPath)) {
535
+ if (!ownedByRepo(repo, dir, content, name, base.chainedPath)) {
462
536
  return {
463
537
  ...base,
464
538
  managed: false,
@@ -580,8 +654,20 @@ AIMHOOMAN_CLI=${shq(resolvedCliPath)}
580
654
  AIMHOOMAN_NODE=${shq(nodePath)}
581
655
  AIMHOOMAN_PATH=${shq(shellPathValue)}
582
656
  ${captureTree}${captureTransaction}run_aimhooman() {
583
- if [ ! -f "$AIMHOOMAN_CLI" ] || [ ! -f "$AIMHOOMAN_NODE" ]; then
584
- echo "aimhooman: guard unavailable (aimhooman CLI or Node is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
657
+ if [ ! -f "$AIMHOOMAN_CLI" ]; then
658
+ echo "aimhooman: guard unavailable (aimhooman CLI is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
659
+ return 0
660
+ fi
661
+ if [ ! -f "$AIMHOOMAN_NODE" ]; then
662
+ # Both remedies below are Node programs, so stopping is only fair when a Node
663
+ # exists to run them with. Without one the CLI file is inert: 'init' and
664
+ # 'uninstall' cannot run, and refusing would leave the repository unusable
665
+ # with no way to remove these hooks except deleting them by hand.
666
+ if command -v node >/dev/null 2>&1; then
667
+ echo "aimhooman: the pinned Node interpreter is missing (\$AIMHOOMAN_NODE); the operation was stopped. Run 'aimhooman init' to re-pin it, or 'aimhooman uninstall' to remove the guard." >&2
668
+ return 20
669
+ fi
670
+ echo "aimhooman: guard unavailable (no Node interpreter found); allowing this operation without protection. Install Node and run 'aimhooman init' to restore the guard." >&2
585
671
  return 0
586
672
  fi
587
673
  (
@@ -698,8 +784,16 @@ function ownedHook(content, name) {
698
784
  return inspected.valid || legacyHook(content, name);
699
785
  }
700
786
 
701
- function ownedForChain(content, name, chainedPath) {
787
+ // ownedByRepo decides whether this repository may rewrite or remove a dispatcher.
788
+ // Inside our own .git the fingerprint settles it: we wrote the file, and no
789
+ // second repository can own anything there. The baked chained path only earns a
790
+ // vote where the hooks directory can genuinely be shared — two repositories may
791
+ // point core.hooksPath at one directory, and then it is the only way to tell the
792
+ // installs apart. Comparing paths everywhere means a renamed repository stops
793
+ // recognising the guard it installed itself, with no way to re-pin or remove it.
794
+ function ownedByRepo(repo, dir, content, name, chainedPath) {
702
795
  if (!ownedHook(content, name)) return false;
796
+ if (insideGitDir(repo, dir)) return true;
703
797
  return assignmentValue(content, 'CHAINED') === String(chainedPath);
704
798
  }
705
799
 
package/src/gitx.mjs CHANGED
@@ -12,21 +12,8 @@ import {
12
12
  rmSync,
13
13
  symlinkSync,
14
14
  } from 'node:fs';
15
- import { createHash, randomUUID } from 'node:crypto';
16
15
  import { isAbsolute, join } from 'node:path';
17
- import { gitEnvironment } from './git-environment.mjs';
18
-
19
- export class StateMigrationError extends Error {
20
- constructor(detail, { stateDir, legacyStateDirs = [], diagnostics = [], cause } = {}) {
21
- super(`repository state: ${detail}`);
22
- this.name = 'StateMigrationError';
23
- this.stateDir = stateDir;
24
- this.legacyStateDirs = legacyStateDirs;
25
- this.stateDiagnostics = diagnostics;
26
- this.diagnostics = diagnostics;
27
- if (cause) this.cause = cause;
28
- }
29
- }
16
+ import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
30
17
 
31
18
  export class GitRevisionError extends Error {
32
19
  constructor(revision, detail, cause) {
@@ -53,6 +40,7 @@ function gitBuf(args, cwd, input) {
53
40
  env: gitEnvironment(),
54
41
  encoding: 'buffer',
55
42
  maxBuffer: 64 * 1024 * 1024,
43
+ timeout: GIT_TIMEOUT_MS,
56
44
  // Capture stderr into the thrown error (pipe) instead of letting git's
57
45
  // raw stderr leak to the user's terminal alongside aimhooman's own
58
46
  // diagnostics; stdin is ignored unless input is supplied.
@@ -162,257 +150,17 @@ export function openRepo(cwd = process.cwd()) {
162
150
  const gitDir = gitStr(['rev-parse', '--absolute-git-dir'], root);
163
151
  let commonDir = gitStr(['rev-parse', '--git-common-dir'], root);
164
152
  if (!isAbsolute(commonDir)) commonDir = join(root, commonDir);
165
- const state = prepareSharedState(commonDir, gitDir);
166
153
  return {
167
154
  root,
168
155
  gitDir,
169
156
  commonDir,
170
- stateDir: state.stateDir,
171
- legacyStateDirs: state.legacyStateDirs,
172
- stateDiagnostics: state.diagnostics,
157
+ // Shared by linked worktrees, which is why it hangs off commonDir rather
158
+ // than gitDir. Every released version has written here.
159
+ stateDir: join(commonDir, 'aimhooman'),
173
160
  excludeFile: join(commonDir, 'info', 'exclude'),
174
161
  };
175
162
  }
176
163
 
177
- function prepareSharedState(commonDir, gitDir) {
178
- const stateDir = join(commonDir, 'aimhooman');
179
- const legacyStateDirs = findLegacyStateDirs(commonDir, gitDir, stateDir);
180
- const diagnostics = [];
181
- if (!legacyStateDirs.length) return { stateDir, legacyStateDirs, diagnostics };
182
-
183
- const currentExists = pathExists(stateDir);
184
- let fingerprints;
185
- try {
186
- fingerprints = legacyStateDirs.map((path) => ({ path, fingerprint: stateFingerprint(path) }));
187
- if (currentExists) {
188
- const currentFingerprint = stateFingerprint(stateDir);
189
- const conflicts = fingerprints.filter((entry) => entry.fingerprint !== currentFingerprint);
190
- if (conflicts.length) {
191
- throw stateConflict(stateDir, legacyStateDirs, conflicts.map((entry) => entry.path));
192
- }
193
- archiveLegacyStates(fingerprints, stateDir, diagnostics);
194
- return { stateDir, legacyStateDirs, diagnostics };
195
- }
196
-
197
- const first = fingerprints[0];
198
- const conflicts = fingerprints.filter((entry) => entry.fingerprint !== first.fingerprint);
199
- if (conflicts.length) {
200
- throw stateConflict(stateDir, legacyStateDirs, conflicts.map((entry) => entry.path));
201
- }
202
- try {
203
- copyStateAtomically(first.path, stateDir, first.fingerprint);
204
- } catch (error) {
205
- if (!pathExists(stateDir)) throw error;
206
- const currentFingerprint = stateFingerprint(stateDir);
207
- const conflicts = fingerprints.filter((entry) => entry.fingerprint !== currentFingerprint);
208
- if (conflicts.length) {
209
- throw stateConflict(stateDir, legacyStateDirs, conflicts.map((entry) => entry.path));
210
- }
211
- diagnostics.push({
212
- level: 'info',
213
- code: 'legacy-state-migrated-concurrently',
214
- path: first.path,
215
- stateDir,
216
- message: `shared state at "${stateDir}" was created by another process`,
217
- });
218
- archiveLegacyStates(fingerprints, stateDir, diagnostics);
219
- return { stateDir, legacyStateDirs, diagnostics };
220
- }
221
- diagnostics.push({
222
- level: 'info',
223
- code: 'legacy-state-migrated',
224
- path: first.path,
225
- stateDir,
226
- message: `copied legacy state from "${first.path}" to shared state "${stateDir}"`,
227
- });
228
- archiveLegacyStates(fingerprints, stateDir, diagnostics);
229
- return { stateDir, legacyStateDirs, diagnostics };
230
- } catch (error) {
231
- if (error instanceof StateMigrationError) throw error;
232
- throw new StateMigrationError(`cannot inspect or migrate legacy state: ${error.message}`, {
233
- stateDir,
234
- legacyStateDirs,
235
- diagnostics,
236
- cause: error,
237
- });
238
- }
239
- }
240
-
241
- function findLegacyStateDirs(commonDir, gitDir, stateDir) {
242
- const found = new Set();
243
- const adminDir = join(commonDir, 'worktrees');
244
- let entries = [];
245
- try {
246
- entries = readdirSync(adminDir, { withFileTypes: true });
247
- } catch (error) {
248
- if (error?.code !== 'ENOENT') {
249
- throw new StateMigrationError(`cannot list linked worktrees: ${error.message}`, {
250
- stateDir,
251
- cause: error,
252
- });
253
- }
254
- }
255
- for (const entry of entries) {
256
- if (!entry.isDirectory()) continue;
257
- const candidate = join(adminDir, entry.name, 'aimhooman');
258
- if (pathExists(candidate) && !isCompatibilityLink(candidate, stateDir)) found.add(candidate);
259
- }
260
- const currentLegacy = join(gitDir, 'aimhooman');
261
- if (currentLegacy !== stateDir
262
- && pathExists(currentLegacy)
263
- && !isCompatibilityLink(currentLegacy, stateDir)) {
264
- found.add(currentLegacy);
265
- }
266
- return [...found].sort();
267
- }
268
-
269
- function isCompatibilityLink(path, stateDir) {
270
- try {
271
- if (!lstatSync(path).isSymbolicLink()) return false;
272
- return realpathSync(path) === realpathSync(stateDir);
273
- } catch (error) {
274
- if (error?.code === 'ENOENT') return false;
275
- throw error;
276
- }
277
- }
278
-
279
- function pathExists(path) {
280
- try {
281
- lstatSync(path);
282
- return true;
283
- } catch (error) {
284
- if (error?.code === 'ENOENT') return false;
285
- throw error;
286
- }
287
- }
288
-
289
- function stateFingerprint(root) {
290
- const hash = createHash('sha256');
291
- function visit(path, relative) {
292
- const stat = lstatSync(path);
293
- hash.update(relative);
294
- hash.update('\0');
295
- hash.update(String(stat.mode & 0o777));
296
- hash.update('\0');
297
- if (stat.isDirectory()) {
298
- hash.update('directory\0');
299
- for (const name of readdirSync(path).sort()) {
300
- visit(join(path, name), relative ? join(relative, name) : name);
301
- }
302
- return;
303
- }
304
- if (stat.isFile()) {
305
- hash.update('file\0');
306
- hash.update(readFileSync(path));
307
- hash.update('\0');
308
- return;
309
- }
310
- if (stat.isSymbolicLink()) {
311
- hash.update('symlink\0');
312
- hash.update(readlinkSync(path));
313
- hash.update('\0');
314
- return;
315
- }
316
- throw new Error(`unsupported state entry "${path}"`);
317
- }
318
- visit(root, '');
319
- return hash.digest('hex');
320
- }
321
-
322
- function copyStateAtomically(source, destination, expectedFingerprint) {
323
- const temporary = `${destination}.migrate-${process.pid}-${randomUUID()}`;
324
- try {
325
- cpSync(source, temporary, {
326
- recursive: true,
327
- dereference: false,
328
- errorOnExist: true,
329
- force: false,
330
- preserveTimestamps: true,
331
- });
332
- if (stateFingerprint(temporary) !== expectedFingerprint) {
333
- throw new Error(`legacy state changed while it was copied from "${source}"`);
334
- }
335
- renameSync(temporary, destination);
336
- } catch (error) {
337
- try {
338
- rmSync(temporary, { recursive: true, force: true });
339
- } catch {
340
- // Keep the migration error when cleanup is unavailable.
341
- }
342
- throw error;
343
- }
344
- }
345
-
346
- export function archiveLegacyStates(entries, stateDir, diagnostics) {
347
- for (const entry of entries) {
348
- // A concurrent process may have already archived this legacy state and
349
- // replaced entry.path with a compatibility symlink to stateDir. Renaming
350
- // that symlink would then fingerprint stateDir (mismatching the legacy
351
- // fingerprint taken before the race) and spuriously fail migration. Treat
352
- // an already-symlinked entry as archived-concurrently and move on.
353
- if (isCompatibilityLink(entry.path, stateDir)) {
354
- diagnostics.push({
355
- level: 'info',
356
- code: 'legacy-state-archived-concurrently',
357
- path: entry.path,
358
- stateDir,
359
- message: `legacy state at "${entry.path}" was archived by another process`,
360
- });
361
- continue;
362
- }
363
- const archivedPath = `${entry.path}.migrated-${randomUUID()}`;
364
- let renamed = false;
365
- try {
366
- renameSync(entry.path, archivedPath);
367
- renamed = true;
368
- if (stateFingerprint(archivedPath) !== entry.fingerprint) {
369
- throw new Error(`legacy state changed while it was archived from "${entry.path}"`);
370
- }
371
- symlinkSync(stateDir, entry.path, process.platform === 'win32' ? 'junction' : 'dir');
372
- } catch (error) {
373
- if (!renamed && error?.code === 'ENOENT' && !pathExists(entry.path)) {
374
- diagnostics.push({
375
- level: 'info',
376
- code: 'legacy-state-archived-concurrently',
377
- path: entry.path,
378
- stateDir,
379
- message: `legacy state at "${entry.path}" was archived by another process`,
380
- });
381
- continue;
382
- }
383
- try {
384
- if (isCompatibilityLink(entry.path, stateDir)) rmSync(entry.path, { force: true });
385
- if (!pathExists(entry.path) && pathExists(archivedPath)) renameSync(archivedPath, entry.path);
386
- } catch {
387
- // Keep the migration error; both locations are named in it.
388
- }
389
- throw error;
390
- }
391
- diagnostics.push({
392
- level: 'info',
393
- code: 'legacy-state-archived',
394
- path: entry.path,
395
- archivedPath,
396
- stateDir,
397
- message: `archived legacy state at "${archivedPath}" and linked its old path to "${stateDir}"`,
398
- });
399
- }
400
- }
401
-
402
- function stateConflict(stateDir, legacyStateDirs, conflictingPaths) {
403
- const diagnostics = conflictingPaths.map((path) => ({
404
- level: 'error',
405
- code: 'legacy-state-conflict',
406
- path,
407
- stateDir,
408
- message: `legacy state at "${path}" conflicts with another per-clone state`,
409
- }));
410
- return new StateMigrationError(
411
- `conflicting legacy state found; select which state to keep at "${stateDir}"`,
412
- { stateDir, legacyStateDirs, diagnostics },
413
- );
414
- }
415
-
416
164
  // stagedEntries snapshots index changes, including deletion metadata. Rename
417
165
  // and copy entries use the destination path; object IDs keep blob reads stable.
418
166
  export function stagedEntries(repo) {
@@ -636,21 +384,40 @@ export function withIndexFromTree(repo, treeOid, fn) {
636
384
  }
637
385
  }
638
386
 
639
- // introducedCommits returns each commit added by a proposed branch update,
640
- // independent of reachability through other refs. Using `--not --all` here
641
- // would let an ignored tag/remote ref pre-poison reachability and suppress the
642
- // final scan. A newly created branch has no trusted old tip, so its full ancestry
643
- // is checked; ordinary updates scan exactly old..new.
387
+ // gatedTips lists the local branch tips this guard has already cleared. Only
388
+ // refs/heads/* qualifies, because it is the only namespace cmdRefcheck gates;
389
+ // the refs under review are excluded, since Git already publishes them at the
390
+ // `committed` phase and a ref must never serve as its own proof.
391
+ function gatedTips(repo, reviewing) {
392
+ return gitBuf(['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/'], repo.root)
393
+ .toString('utf8')
394
+ .split('\n')
395
+ .filter(Boolean)
396
+ .map((line) => line.split(' '))
397
+ .filter(([name, oid]) => name && oid && !reviewing.has(name))
398
+ .map(([, oid]) => oid);
399
+ }
400
+
401
+ // introducedCommits returns each commit added by a proposed branch update.
402
+ // Reachability through refs/heads/* is trusted because those commits passed this
403
+ // same guard when their branch was written; every other namespace is not. Using
404
+ // `--not --all` would let an ignored tag or remote ref pre-poison reachability
405
+ // and suppress the scan, so tips come from refs/heads/* alone. A newly created
406
+ // branch is measured against those tips instead of its full ancestry, which
407
+ // would otherwise rescan the entire repository on every `git checkout -b`;
408
+ // ordinary updates scan exactly old..new.
644
409
  export function introducedCommits(repo, updates) {
645
410
  const commits = [];
646
411
  const seen = new Set();
412
+ const reviewing = new Set(updates.map((update) => update?.ref).filter(Boolean));
413
+ const gated = gatedTips(repo, reviewing);
647
414
  for (const update of updates) {
648
415
  const oldObjectId = update?.oldObjectId;
649
416
  const newObjectId = update?.newObjectId;
650
417
  assertOid(oldObjectId);
651
418
  assertOid(newObjectId);
652
419
  const revisions = /^0+$/.test(oldObjectId)
653
- ? [newObjectId]
420
+ ? [newObjectId, ...(gated.length ? ['--not', ...gated] : [])]
654
421
  : [newObjectId, `^${oldObjectId}`];
655
422
  const resolved = gitBuf(['rev-list', '--reverse', ...revisions], repo.root)
656
423
  .toString('utf8')
@@ -686,12 +453,28 @@ export function unstagePaths(repo, paths) {
686
453
  cwd: repo.root,
687
454
  env: gitEnvironment(),
688
455
  input: pathspecs,
456
+ timeout: GIT_TIMEOUT_MS,
689
457
  stdio: ['pipe', 'ignore', 'pipe'],
690
458
  };
691
459
  let hasHead = true;
692
460
  try {
693
- execFileSync('git', ['rev-parse', '--verify', 'HEAD'], opts);
694
- } catch {
461
+ // Deliberately not `opts`: rev-parse never reads stdin, so feeding it the
462
+ // pathspec makes git exit before draining the pipe, and a pathspec over the
463
+ // ~64 KiB pipe buffer fails the write with EPIPE. That read as "no HEAD" and
464
+ // sent a repository that has one down the `rm --cached` branch, staging the
465
+ // deletion of every tracked path this was asked to restore.
466
+ execFileSync('git', ['rev-parse', '--verify', 'HEAD'], {
467
+ cwd: repo.root,
468
+ env: gitEnvironment(),
469
+ timeout: GIT_TIMEOUT_MS,
470
+ stdio: ['ignore', 'ignore', 'pipe'],
471
+ });
472
+ } catch (error) {
473
+ // Only git's own verdict may select the branch. An unborn HEAD exits 128;
474
+ // a timeout or a missing git carries no exit status, and guessing "no HEAD"
475
+ // there would delete instead of restore. Fail closed and let the caller stop
476
+ // the commit rather than silently rewrite the index.
477
+ if (typeof error?.status !== 'number') throw error;
695
478
  hasHead = false;
696
479
  }
697
480
  if (hasHead) {
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { GitRevisionError } from './gitx.mjs';
5
- import { gitEnvironment } from './git-environment.mjs';
5
+ import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
6
6
 
7
7
  export const EMPTY_HISTORY_OID = '0'.repeat(40);
8
8
 
@@ -12,6 +12,7 @@ function gitBuffer(repo, args, input) {
12
12
  env: gitEnvironment(),
13
13
  encoding: 'buffer',
14
14
  maxBuffer: 128 * 1024 * 1024,
15
+ timeout: GIT_TIMEOUT_MS,
15
16
  ...(input === undefined ? {} : { input }),
16
17
  });
17
18
  }
@@ -26,6 +27,7 @@ function gitStringQuiet(repo, args) {
26
27
  env: gitEnvironment(),
27
28
  encoding: 'utf8',
28
29
  maxBuffer: 128 * 1024 * 1024,
30
+ timeout: GIT_TIMEOUT_MS,
29
31
  stdio: ['ignore', 'pipe', 'pipe'],
30
32
  }).trim();
31
33
  }
@@ -223,6 +225,7 @@ function isAncestor(repo, ancestor, descendant) {
223
225
  cwd: repo.root,
224
226
  env: gitEnvironment(),
225
227
  stdio: ['ignore', 'ignore', 'pipe'],
228
+ timeout: GIT_TIMEOUT_MS,
226
229
  });
227
230
  return true;
228
231
  } catch (error) {