@gethmy/harness 1.3.0 → 1.5.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
  }),
@@ -114,6 +115,102 @@ export function resolveWorktreeStartRef(
114
115
  return `origin/${baseBranch}`;
115
116
  }
116
117
 
118
+ /** The branch a run should actually build on, decided against origin (#930). */
119
+ export interface ContinuationTarget {
120
+ /** The branch the run must use — possibly the approved-rename sibling. */
121
+ branchName: string;
122
+ /** Whether `createWorktree` should continue the branch's own pushed tip. */
123
+ continueExisting: boolean;
124
+ /**
125
+ * The probe's answer for `branchName`: does it exist on origin? Hand it to
126
+ * `createWorktree` (`opts.branchExistsOnOrigin`) so the same ref is not
127
+ * ls-remote'd + fetched a second time inside `resolveWorktreeStartRef` on
128
+ * every continued run. When true, the probe (`fetchExistingBranch`) has
129
+ * already fetched the ref, so `refs/remotes/origin/<branchName>` resolves
130
+ * locally — the promise `createWorktree` needs to skip its own probe.
131
+ */
132
+ existsOnOrigin: boolean;
133
+ /** Why — one legible word for the worker's log line. */
134
+ reason: "requested" | "exists_on_origin" | "approved_rename" | "fresh";
135
+ }
136
+
137
+ /**
138
+ * Final continuation decision for an implement run, consulted against origin
139
+ * right before `createWorktree` (#930).
140
+ *
141
+ * The description-based guard (`recordedBranchForCard` in @harmony/shared)
142
+ * answers from what the card RECORDS — and the record has two known failure
143
+ * modes, both in the data-loss class: a verify-failed run pushes its branch
144
+ * but dies before `postSummary` writes the record (or a human deletes the
145
+ * auto-appended block as routine cleanup), and a review approval renames the
146
+ * ref on origin while the record write can fail or predate the rewrite. So
147
+ * this helper asks origin itself:
148
+ *
149
+ * - The branch EXISTS on origin → continue it, whatever the description says.
150
+ * `git worktree add -B` would reset it and completion would force-push over
151
+ * it; the guard's contract is "failing safe costs a redundant continuation;
152
+ * failing open costs the user's work" — this makes that hold even when the
153
+ * record is gone.
154
+ * - The branch is GONE but its approved-rename sibling
155
+ * (`<approvedPrefix><rest>` for a `<failedPrefix><rest>` name) exists →
156
+ * continue the sibling. That is the ref the approval moved the work to;
157
+ * rebuilding `<failedPrefix><rest>` from origin/<base> would reimplement
158
+ * from scratch and the next approval would force-rename over the reviewed
159
+ * branch behind its open PR.
160
+ * - Neither exists → nothing on origin to lose; pass `continueRequested`
161
+ * through unchanged, and `createWorktree` falls back to a fresh branch
162
+ * exactly as before.
163
+ *
164
+ * The probe's answer travels with the target (`existsOnOrigin`) so the caller
165
+ * can hand it to `createWorktree` and origin is asked exactly ONCE per run —
166
+ * without it, a continued run ls-remote'd + fetched the same ref a second time
167
+ * inside `resolveWorktreeStartRef` (#930 review).
168
+ *
169
+ * `branchExistsOnOrigin` is a thunk for the same reason as
170
+ * `resolveWorktreeStartRef`'s — the decision is unit-testable without git.
171
+ * Callers pass `fetchExistingBranch`, which fails CLOSED (throws
172
+ * `WorktreeBaseError` on an unprobeable remote) — a transient network error
173
+ * must requeue the run, never read as "branch absent" (#637 follow-up).
174
+ */
175
+ export function resolveContinuationTarget(
176
+ branchName: string,
177
+ continueRequested: boolean,
178
+ failedBranchPrefix: string,
179
+ approvedBranchPrefix: string,
180
+ branchExistsOnOrigin: (ref: string) => boolean,
181
+ ): ContinuationTarget {
182
+ if (branchExistsOnOrigin(branchName)) {
183
+ return {
184
+ branchName,
185
+ continueExisting: true,
186
+ existsOnOrigin: true,
187
+ reason: continueRequested ? "requested" : "exists_on_origin",
188
+ };
189
+ }
190
+ if (
191
+ failedBranchPrefix &&
192
+ approvedBranchPrefix &&
193
+ branchName.startsWith(failedBranchPrefix)
194
+ ) {
195
+ const sibling =
196
+ approvedBranchPrefix + branchName.slice(failedBranchPrefix.length);
197
+ if (branchExistsOnOrigin(sibling)) {
198
+ return {
199
+ branchName: sibling,
200
+ continueExisting: true,
201
+ existsOnOrigin: true,
202
+ reason: "approved_rename",
203
+ };
204
+ }
205
+ }
206
+ return {
207
+ branchName,
208
+ continueExisting: continueRequested,
209
+ existsOnOrigin: false,
210
+ reason: "fresh",
211
+ };
212
+ }
213
+
117
214
  /**
118
215
  * Fetch a branch from origin so `origin/<branchName>` resolves locally, and
119
216
  * report whether the branch exists on the remote.
@@ -145,13 +242,20 @@ export function fetchExistingBranch(
145
242
  lsRemoteImpl: (root: string, branch: string) => void = (root, branch) =>
146
243
  execFileSync(
147
244
  "git",
148
- ["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
+ ],
149
252
  { cwd: root, stdio: "pipe" },
150
253
  ),
151
254
  fetchImpl: (root: string, branch: string) => void = (root, branch) =>
152
255
  execFileSync(
153
256
  "git",
154
257
  [
258
+ ...GIT_NO_HOOKS,
155
259
  "fetch",
156
260
  "origin",
157
261
  `+refs/heads/${branch}:refs/remotes/origin/${branch}`,
@@ -209,6 +313,17 @@ export interface CreateWorktreeOptions {
209
313
  * when the branch isn't on origin yet.
210
314
  */
211
315
  continueExisting?: boolean;
316
+ /**
317
+ * Pre-answered origin probe for `branchName`, set when the caller already
318
+ * ran the probe (`resolveContinuationTarget` → `fetchExistingBranch`, #930):
319
+ * `createWorktree` then skips its own ls-remote + fetch of the same ref.
320
+ * `true` carries a promise, not just an answer — the ref must have been
321
+ * FETCHED so `refs/remotes/origin/<branchName>` resolves locally.
322
+ * `fetchExistingBranch` does both; a bare ls-remote does not, so never pass
323
+ * its answer here. Leave undefined and `createWorktree` probes itself
324
+ * (unchanged behaviour).
325
+ */
326
+ branchExistsOnOrigin?: boolean;
212
327
  }
213
328
 
214
329
  /**
@@ -229,7 +344,7 @@ export interface CreateWorktreeOptions {
229
344
  */
230
345
  export function readWorktreeHead(worktreePath: string): string | null {
231
346
  try {
232
- return execFileSync("git", ["rev-parse", "HEAD"], {
347
+ return execFileSync("git", [...GIT_NO_HOOKS, "rev-parse", "HEAD"], {
233
348
  cwd: worktreePath,
234
349
  encoding: "utf-8",
235
350
  }).trim();
@@ -244,9 +359,13 @@ export function createWorktree(
244
359
  branchName: string,
245
360
  opts: CreateWorktreeOptions = {},
246
361
  ): string {
247
- const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
248
- encoding: "utf-8",
249
- }).trim();
362
+ const repoRoot = execFileSync(
363
+ "git",
364
+ [...GIT_NO_HOOKS, "rev-parse", "--show-toplevel"],
365
+ {
366
+ encoding: "utf-8",
367
+ },
368
+ ).trim();
250
369
 
251
370
  const worktreeDir = resolve(repoRoot, basePath, branchName);
252
371
 
@@ -261,10 +380,14 @@ export function createWorktree(
261
380
  // `--expire=now` overrides `gc.worktreePruneExpire` (default 3 months) so
262
381
  // freshly-orphaned entries are removed immediately.
263
382
  try {
264
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
265
- cwd: repoRoot,
266
- stdio: "pipe",
267
- });
383
+ execFileSync(
384
+ "git",
385
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
386
+ {
387
+ cwd: repoRoot,
388
+ stdio: "pipe",
389
+ },
390
+ );
268
391
  } catch {
269
392
  // non-fatal
270
393
  }
@@ -278,12 +401,17 @@ export function createWorktree(
278
401
  // Pick the base ref. Normally a fresh per-attempt branch from
279
402
  // origin/<baseBranch>; for a stage continuation, the branch's own pushed tip
280
403
  // so a prior stage's commits survive into this run (#561). `-B` resets the
281
- // local branch to whichever ref we chose.
404
+ // local branch to whichever ref we chose. A caller that already probed
405
+ // origin (resolveContinuationTarget, #930) passes the answer in
406
+ // `opts.branchExistsOnOrigin` — its probe also fetched the ref, so
407
+ // `origin/<branchName>` resolves locally and asking origin again would be a
408
+ // redundant second ls-remote + fetch of the same ref.
282
409
  const startRef = resolveWorktreeStartRef(
283
410
  baseBranch,
284
411
  branchName,
285
412
  opts.continueExisting ?? false,
286
- () => fetchExistingBranch(repoRoot, branchName),
413
+ () =>
414
+ opts.branchExistsOnOrigin ?? fetchExistingBranch(repoRoot, branchName),
287
415
  );
288
416
 
289
417
  log.info(
@@ -293,7 +421,15 @@ export function createWorktree(
293
421
  try {
294
422
  execFileSync(
295
423
  "git",
296
- ["worktree", "add", "-B", branchName, worktreeDir, startRef],
424
+ [
425
+ ...GIT_NO_HOOKS,
426
+ "worktree",
427
+ "add",
428
+ "-B",
429
+ branchName,
430
+ worktreeDir,
431
+ startRef,
432
+ ],
297
433
  { cwd: repoRoot, stdio: "pipe" },
298
434
  );
299
435
  } catch (err) {
@@ -310,24 +446,32 @@ export function createWorktree(
310
446
  removeWorktreeHoldingBranch(repoRoot, branchName, worktreeDir);
311
447
  // Remove any registered worktree at this path (phantom or otherwise).
312
448
  try {
313
- execFileSync("git", ["worktree", "remove", worktreeDir, "--force"], {
314
- cwd: repoRoot,
315
- stdio: "pipe",
316
- });
449
+ execFileSync(
450
+ "git",
451
+ [...GIT_NO_HOOKS, "worktree", "remove", worktreeDir, "--force"],
452
+ {
453
+ cwd: repoRoot,
454
+ stdio: "pipe",
455
+ },
456
+ );
317
457
  } catch {
318
458
  // best-effort
319
459
  }
320
460
  // Force-prune any stale worktree admin entries referencing this branch.
321
461
  try {
322
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
323
- cwd: repoRoot,
324
- stdio: "pipe",
325
- });
462
+ execFileSync(
463
+ "git",
464
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
465
+ {
466
+ cwd: repoRoot,
467
+ stdio: "pipe",
468
+ },
469
+ );
326
470
  } catch {
327
471
  // best-effort
328
472
  }
329
473
  try {
330
- execFileSync("git", ["branch", "-D", branchName], {
474
+ execFileSync("git", [...GIT_NO_HOOKS, "branch", "-D", branchName], {
331
475
  cwd: repoRoot,
332
476
  stdio: "pipe",
333
477
  });
@@ -336,18 +480,40 @@ export function createWorktree(
336
480
  }
337
481
  execFileSync(
338
482
  "git",
339
- ["worktree", "add", "-B", branchName, worktreeDir, startRef],
483
+ [
484
+ ...GIT_NO_HOOKS,
485
+ "worktree",
486
+ "add",
487
+ "-B",
488
+ branchName,
489
+ worktreeDir,
490
+ startRef,
491
+ ],
340
492
  { cwd: repoRoot, stdio: "pipe" },
341
493
  );
342
494
  }
343
495
 
344
- // 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.
345
510
  log.info(TAG, "Installing dependencies in worktree...");
346
511
  try {
347
- execSync(installCommand(), {
512
+ execSync(installCommand(true), {
348
513
  cwd: worktreeDir,
349
514
  stdio: "pipe",
350
515
  timeout: 60_000,
516
+ env: containedEnv(),
351
517
  });
352
518
  } catch {
353
519
  log.warn(TAG, "Install failed (may be fine if deps are hoisted)");
@@ -382,9 +548,13 @@ export function cleanupWorktree(
382
548
  worktreePath: string,
383
549
  branchName?: string,
384
550
  ): void {
385
- const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
386
- encoding: "utf-8",
387
- }).trim();
551
+ const repoRoot = execFileSync(
552
+ "git",
553
+ [...GIT_NO_HOOKS, "rev-parse", "--show-toplevel"],
554
+ {
555
+ encoding: "utf-8",
556
+ },
557
+ ).trim();
388
558
 
389
559
  if (existsSync(worktreePath)) {
390
560
  // Never escalate on a container: `git worktree remove` would fail on it
@@ -398,10 +568,14 @@ export function cleanupWorktree(
398
568
  );
399
569
  }
400
570
  try {
401
- execFileSync("git", ["worktree", "remove", worktreePath, "--force"], {
402
- cwd: repoRoot,
403
- stdio: "pipe",
404
- });
571
+ execFileSync(
572
+ "git",
573
+ [...GIT_NO_HOOKS, "worktree", "remove", worktreePath, "--force"],
574
+ {
575
+ cwd: repoRoot,
576
+ stdio: "pipe",
577
+ },
578
+ );
405
579
  log.info(TAG, `Removed worktree: ${worktreePath}`);
406
580
  } catch (err) {
407
581
  log.warn(
@@ -414,10 +588,14 @@ export function cleanupWorktree(
414
588
  }
415
589
  // Prune stale worktree entries
416
590
  try {
417
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
418
- cwd: repoRoot,
419
- stdio: "pipe",
420
- });
591
+ execFileSync(
592
+ "git",
593
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
594
+ {
595
+ cwd: repoRoot,
596
+ stdio: "pipe",
597
+ },
598
+ );
421
599
  } catch {
422
600
  // best-effort
423
601
  }
@@ -429,10 +607,14 @@ export function cleanupWorktree(
429
607
  // "Failed to remove worktree cleanly" warning. Quietly prune any stale
430
608
  // registration instead; the branch delete below still runs.
431
609
  try {
432
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
433
- cwd: repoRoot,
434
- stdio: "pipe",
435
- });
610
+ execFileSync(
611
+ "git",
612
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
613
+ {
614
+ cwd: repoRoot,
615
+ stdio: "pipe",
616
+ },
617
+ );
436
618
  } catch {
437
619
  // best-effort
438
620
  }
@@ -441,7 +623,7 @@ export function cleanupWorktree(
441
623
  // Delete the branch so it doesn't block future runs
442
624
  if (branchName) {
443
625
  try {
444
- execFileSync("git", ["branch", "-D", branchName], {
626
+ execFileSync("git", [...GIT_NO_HOOKS, "branch", "-D", branchName], {
445
627
  cwd: repoRoot,
446
628
  stdio: "pipe",
447
629
  });
@@ -478,11 +660,15 @@ export function removeWorktreeHoldingBranch(
478
660
  ): string | null {
479
661
  let listing: string;
480
662
  try {
481
- listing = execFileSync("git", ["worktree", "list", "--porcelain"], {
482
- cwd: repoRoot,
483
- encoding: "utf-8",
484
- stdio: ["ignore", "pipe", "pipe"],
485
- });
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
+ );
486
672
  } catch {
487
673
  return null;
488
674
  }
@@ -510,10 +696,14 @@ export function removeWorktreeHoldingBranch(
510
696
  if (exceptDir && resolve(holderPath) === resolve(exceptDir)) return null;
511
697
 
512
698
  try {
513
- execFileSync("git", ["worktree", "remove", holderPath, "--force"], {
514
- cwd: repoRoot,
515
- stdio: "pipe",
516
- });
699
+ execFileSync(
700
+ "git",
701
+ [...GIT_NO_HOOKS, "worktree", "remove", holderPath, "--force"],
702
+ {
703
+ cwd: repoRoot,
704
+ stdio: "pipe",
705
+ },
706
+ );
517
707
  log.warn(
518
708
  TAG,
519
709
  `Evicted worktree ${holderPath} holding branch ${branchName} so it can be reused (#732)`,
@@ -530,10 +720,14 @@ export function removeWorktreeHoldingBranch(
530
720
  // Clear the now-orphaned admin entry so `git branch -D` / `worktree add` see a
531
721
  // free branch.
532
722
  try {
533
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
534
- cwd: repoRoot,
535
- stdio: "pipe",
536
- });
723
+ execFileSync(
724
+ "git",
725
+ [...GIT_NO_HOOKS, "worktree", "prune", "--expire=now"],
726
+ {
727
+ cwd: repoRoot,
728
+ stdio: "pipe",
729
+ },
730
+ );
537
731
  } catch {
538
732
  // best-effort
539
733
  }
@@ -542,9 +736,13 @@ export function removeWorktreeHoldingBranch(
542
736
 
543
737
  /** Resolve the main repo root, independent of any worktree cwd. */
544
738
  function resolveRepoRoot(): string {
545
- return execFileSync("git", ["rev-parse", "--show-toplevel"], {
546
- encoding: "utf-8",
547
- }).trim();
739
+ return execFileSync(
740
+ "git",
741
+ [...GIT_NO_HOOKS, "rev-parse", "--show-toplevel"],
742
+ {
743
+ encoding: "utf-8",
744
+ },
745
+ ).trim();
548
746
  }
549
747
 
550
748
  /**
@@ -557,7 +755,13 @@ function localBranchExists(branchName: string, repoRoot: string): boolean {
557
755
  try {
558
756
  execFileSync(
559
757
  "git",
560
- ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
758
+ [
759
+ ...GIT_NO_HOOKS,
760
+ "show-ref",
761
+ "--verify",
762
+ "--quiet",
763
+ `refs/heads/${branchName}`,
764
+ ],
561
765
  { cwd: repoRoot, stdio: "ignore" },
562
766
  );
563
767
  return true;
@@ -596,7 +800,7 @@ export function branchAheadOfItsRemote(
596
800
  try {
597
801
  const out = execFileSync(
598
802
  "git",
599
- ["rev-list", branchName, "--not", "--remotes=origin"],
803
+ [...GIT_NO_HOOKS, "rev-list", branchName, "--not", "--remotes=origin"],
600
804
  // stdio pipes stderr (rather than inheriting it) so any git diagnostic is
601
805
  // captured with the throw, never leaked to the daemon's own stderr.
602
806
  { cwd: repoRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },