@gethmy/harness 1.4.0 → 1.6.0

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/worktree.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { log } from "./log.js";
5
5
  import { installCommand } from "./pm.js";
6
+ import { containedEnv, GIT_NO_HOOKS } from "./run-containment.js";
6
7
 
7
8
  const TAG = "worktree";
8
9
 
@@ -47,7 +48,7 @@ export function fetchBaseBranch(
47
48
  baseBranch: string,
48
49
  attempts = 3,
49
50
  fetchImpl: (root: string, branch: string) => void = (root, branch) =>
50
- execFileSync("git", ["fetch", "origin", branch], {
51
+ execFileSync("git", [...GIT_NO_HOOKS, "fetch", "origin", branch], {
51
52
  cwd: root,
52
53
  stdio: "pipe",
53
54
  }),
@@ -241,13 +242,20 @@ export function fetchExistingBranch(
241
242
  lsRemoteImpl: (root: string, branch: string) => void = (root, branch) =>
242
243
  execFileSync(
243
244
  "git",
244
- ["ls-remote", "--exit-code", "origin", `refs/heads/${branch}`],
245
+ [
246
+ ...GIT_NO_HOOKS,
247
+ "ls-remote",
248
+ "--exit-code",
249
+ "origin",
250
+ `refs/heads/${branch}`,
251
+ ],
245
252
  { cwd: root, stdio: "pipe" },
246
253
  ),
247
254
  fetchImpl: (root: string, branch: string) => void = (root, branch) =>
248
255
  execFileSync(
249
256
  "git",
250
257
  [
258
+ ...GIT_NO_HOOKS,
251
259
  "fetch",
252
260
  "origin",
253
261
  `+refs/heads/${branch}:refs/remotes/origin/${branch}`,
@@ -336,7 +344,7 @@ export interface CreateWorktreeOptions {
336
344
  */
337
345
  export function readWorktreeHead(worktreePath: string): string | null {
338
346
  try {
339
- return execFileSync("git", ["rev-parse", "HEAD"], {
347
+ return execFileSync("git", [...GIT_NO_HOOKS, "rev-parse", "HEAD"], {
340
348
  cwd: worktreePath,
341
349
  encoding: "utf-8",
342
350
  }).trim();
@@ -351,9 +359,13 @@ export function createWorktree(
351
359
  branchName: string,
352
360
  opts: CreateWorktreeOptions = {},
353
361
  ): string {
354
- const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
355
- encoding: "utf-8",
356
- }).trim();
362
+ const repoRoot = execFileSync(
363
+ "git",
364
+ [...GIT_NO_HOOKS, "rev-parse", "--show-toplevel"],
365
+ {
366
+ encoding: "utf-8",
367
+ },
368
+ ).trim();
357
369
 
358
370
  const worktreeDir = resolve(repoRoot, basePath, branchName);
359
371
 
@@ -368,10 +380,14 @@ export function createWorktree(
368
380
  // `--expire=now` overrides `gc.worktreePruneExpire` (default 3 months) so
369
381
  // freshly-orphaned entries are removed immediately.
370
382
  try {
371
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
372
- cwd: repoRoot,
373
- stdio: "pipe",
374
- });
383
+ execFileSync(
384
+ "git",
385
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
386
+ {
387
+ cwd: repoRoot,
388
+ stdio: "pipe",
389
+ },
390
+ );
375
391
  } catch {
376
392
  // non-fatal
377
393
  }
@@ -405,7 +421,15 @@ export function createWorktree(
405
421
  try {
406
422
  execFileSync(
407
423
  "git",
408
- ["worktree", "add", "-B", branchName, worktreeDir, startRef],
424
+ [
425
+ ...GIT_NO_HOOKS,
426
+ "worktree",
427
+ "add",
428
+ "-B",
429
+ branchName,
430
+ worktreeDir,
431
+ startRef,
432
+ ],
409
433
  { cwd: repoRoot, stdio: "pipe" },
410
434
  );
411
435
  } catch (err) {
@@ -422,24 +446,32 @@ export function createWorktree(
422
446
  removeWorktreeHoldingBranch(repoRoot, branchName, worktreeDir);
423
447
  // Remove any registered worktree at this path (phantom or otherwise).
424
448
  try {
425
- execFileSync("git", ["worktree", "remove", worktreeDir, "--force"], {
426
- cwd: repoRoot,
427
- stdio: "pipe",
428
- });
449
+ execFileSync(
450
+ "git",
451
+ [...GIT_NO_HOOKS, "worktree", "remove", worktreeDir, "--force"],
452
+ {
453
+ cwd: repoRoot,
454
+ stdio: "pipe",
455
+ },
456
+ );
429
457
  } catch {
430
458
  // best-effort
431
459
  }
432
460
  // Force-prune any stale worktree admin entries referencing this branch.
433
461
  try {
434
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
435
- cwd: repoRoot,
436
- stdio: "pipe",
437
- });
462
+ execFileSync(
463
+ "git",
464
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
465
+ {
466
+ cwd: repoRoot,
467
+ stdio: "pipe",
468
+ },
469
+ );
438
470
  } catch {
439
471
  // best-effort
440
472
  }
441
473
  try {
442
- execFileSync("git", ["branch", "-D", branchName], {
474
+ execFileSync("git", [...GIT_NO_HOOKS, "branch", "-D", branchName], {
443
475
  cwd: repoRoot,
444
476
  stdio: "pipe",
445
477
  });
@@ -448,18 +480,40 @@ export function createWorktree(
448
480
  }
449
481
  execFileSync(
450
482
  "git",
451
- ["worktree", "add", "-B", branchName, worktreeDir, startRef],
483
+ [
484
+ ...GIT_NO_HOOKS,
485
+ "worktree",
486
+ "add",
487
+ "-B",
488
+ branchName,
489
+ worktreeDir,
490
+ startRef,
491
+ ],
452
492
  { cwd: repoRoot, stdio: "pipe" },
453
493
  );
454
494
  }
455
495
 
456
- // Install dependencies in the worktree
496
+ // Install dependencies in the worktree.
497
+ //
498
+ // `ignoreScripts: true` and `containedEnv()` (#988). This runs in the DAEMON
499
+ // process with no sandbox, and on a retry, resume or steering re-spawn the
500
+ // worktree is cut from the run's OWN branch tip — so the `package.json` being
501
+ // installed is one a previous contained run committed. With lifecycle scripts
502
+ // enabled that is a `postinstall` executing as the operator with the full
503
+ // environment, which is the same reason `ci-patch.ts` passes `ignoreScripts`
504
+ // on the repair checkout.
505
+ //
506
+ // The cost is real and accepted: a repo whose install genuinely needs a
507
+ // `postinstall` gets an incomplete `node_modules` here. The catch below
508
+ // already treats a failed install as survivable, and the verification step
509
+ // reports what actually breaks.
457
510
  log.info(TAG, "Installing dependencies in worktree...");
458
511
  try {
459
- execSync(installCommand(), {
512
+ execSync(installCommand(true), {
460
513
  cwd: worktreeDir,
461
514
  stdio: "pipe",
462
515
  timeout: 60_000,
516
+ env: containedEnv(),
463
517
  });
464
518
  } catch {
465
519
  log.warn(TAG, "Install failed (may be fine if deps are hoisted)");
@@ -494,9 +548,13 @@ export function cleanupWorktree(
494
548
  worktreePath: string,
495
549
  branchName?: string,
496
550
  ): void {
497
- const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
498
- encoding: "utf-8",
499
- }).trim();
551
+ const repoRoot = execFileSync(
552
+ "git",
553
+ [...GIT_NO_HOOKS, "rev-parse", "--show-toplevel"],
554
+ {
555
+ encoding: "utf-8",
556
+ },
557
+ ).trim();
500
558
 
501
559
  if (existsSync(worktreePath)) {
502
560
  // Never escalate on a container: `git worktree remove` would fail on it
@@ -510,10 +568,14 @@ export function cleanupWorktree(
510
568
  );
511
569
  }
512
570
  try {
513
- execFileSync("git", ["worktree", "remove", worktreePath, "--force"], {
514
- cwd: repoRoot,
515
- stdio: "pipe",
516
- });
571
+ execFileSync(
572
+ "git",
573
+ [...GIT_NO_HOOKS, "worktree", "remove", worktreePath, "--force"],
574
+ {
575
+ cwd: repoRoot,
576
+ stdio: "pipe",
577
+ },
578
+ );
517
579
  log.info(TAG, `Removed worktree: ${worktreePath}`);
518
580
  } catch (err) {
519
581
  log.warn(
@@ -526,10 +588,14 @@ export function cleanupWorktree(
526
588
  }
527
589
  // Prune stale worktree entries
528
590
  try {
529
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
530
- cwd: repoRoot,
531
- stdio: "pipe",
532
- });
591
+ execFileSync(
592
+ "git",
593
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
594
+ {
595
+ cwd: repoRoot,
596
+ stdio: "pipe",
597
+ },
598
+ );
533
599
  } catch {
534
600
  // best-effort
535
601
  }
@@ -541,10 +607,14 @@ export function cleanupWorktree(
541
607
  // "Failed to remove worktree cleanly" warning. Quietly prune any stale
542
608
  // registration instead; the branch delete below still runs.
543
609
  try {
544
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
545
- cwd: repoRoot,
546
- stdio: "pipe",
547
- });
610
+ execFileSync(
611
+ "git",
612
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
613
+ {
614
+ cwd: repoRoot,
615
+ stdio: "pipe",
616
+ },
617
+ );
548
618
  } catch {
549
619
  // best-effort
550
620
  }
@@ -553,7 +623,7 @@ export function cleanupWorktree(
553
623
  // Delete the branch so it doesn't block future runs
554
624
  if (branchName) {
555
625
  try {
556
- execFileSync("git", ["branch", "-D", branchName], {
626
+ execFileSync("git", [...GIT_NO_HOOKS, "branch", "-D", branchName], {
557
627
  cwd: repoRoot,
558
628
  stdio: "pipe",
559
629
  });
@@ -590,11 +660,15 @@ export function removeWorktreeHoldingBranch(
590
660
  ): string | null {
591
661
  let listing: string;
592
662
  try {
593
- listing = execFileSync("git", ["worktree", "list", "--porcelain"], {
594
- cwd: repoRoot,
595
- encoding: "utf-8",
596
- stdio: ["ignore", "pipe", "pipe"],
597
- });
663
+ listing = execFileSync(
664
+ "git",
665
+ [...GIT_NO_HOOKS, "worktree", "list", "--porcelain"],
666
+ {
667
+ cwd: repoRoot,
668
+ encoding: "utf-8",
669
+ stdio: ["ignore", "pipe", "pipe"],
670
+ },
671
+ );
598
672
  } catch {
599
673
  return null;
600
674
  }
@@ -622,10 +696,14 @@ export function removeWorktreeHoldingBranch(
622
696
  if (exceptDir && resolve(holderPath) === resolve(exceptDir)) return null;
623
697
 
624
698
  try {
625
- execFileSync("git", ["worktree", "remove", holderPath, "--force"], {
626
- cwd: repoRoot,
627
- stdio: "pipe",
628
- });
699
+ execFileSync(
700
+ "git",
701
+ [...GIT_NO_HOOKS, "worktree", "remove", holderPath, "--force"],
702
+ {
703
+ cwd: repoRoot,
704
+ stdio: "pipe",
705
+ },
706
+ );
629
707
  log.warn(
630
708
  TAG,
631
709
  `Evicted worktree ${holderPath} holding branch ${branchName} so it can be reused (#732)`,
@@ -642,10 +720,14 @@ export function removeWorktreeHoldingBranch(
642
720
  // Clear the now-orphaned admin entry so `git branch -D` / `worktree add` see a
643
721
  // free branch.
644
722
  try {
645
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
646
- cwd: repoRoot,
647
- stdio: "pipe",
648
- });
723
+ execFileSync(
724
+ "git",
725
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
726
+ {
727
+ cwd: repoRoot,
728
+ stdio: "pipe",
729
+ },
730
+ );
649
731
  } catch {
650
732
  // best-effort
651
733
  }
@@ -654,9 +736,13 @@ export function removeWorktreeHoldingBranch(
654
736
 
655
737
  /** Resolve the main repo root, independent of any worktree cwd. */
656
738
  function resolveRepoRoot(): string {
657
- return execFileSync("git", ["rev-parse", "--show-toplevel"], {
658
- encoding: "utf-8",
659
- }).trim();
739
+ return execFileSync(
740
+ "git",
741
+ [...GIT_NO_HOOKS, "rev-parse", "--show-toplevel"],
742
+ {
743
+ encoding: "utf-8",
744
+ },
745
+ ).trim();
660
746
  }
661
747
 
662
748
  /**
@@ -669,7 +755,13 @@ function localBranchExists(branchName: string, repoRoot: string): boolean {
669
755
  try {
670
756
  execFileSync(
671
757
  "git",
672
- ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
758
+ [
759
+ ...GIT_NO_HOOKS,
760
+ "show-ref",
761
+ "--verify",
762
+ "--quiet",
763
+ `refs/heads/${branchName}`,
764
+ ],
673
765
  { cwd: repoRoot, stdio: "ignore" },
674
766
  );
675
767
  return true;
@@ -708,7 +800,7 @@ export function branchAheadOfItsRemote(
708
800
  try {
709
801
  const out = execFileSync(
710
802
  "git",
711
- ["rev-list", branchName, "--not", "--remotes=origin"],
803
+ [...GIT_NO_HOOKS, "rev-list", branchName, "--not", "--remotes=origin"],
712
804
  // stdio pipes stderr (rather than inheriting it) so any git diagnostic is
713
805
  // captured with the throw, never leaked to the daemon's own stderr.
714
806
  { cwd: repoRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },