@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/dist/cli.js +535 -69
- package/dist/index.js +697 -229
- package/package.json +2 -2
- package/src/ci-failure.ts +10 -5
- package/src/exec-types.ts +5 -7
- package/src/git-diff-stat.ts +2 -1
- package/src/git-pr.ts +59 -25
- package/src/index.ts +1 -0
- package/src/pm.ts +8 -3
- package/src/revert-guard.ts +9 -2
- package/src/run-containment.ts +1292 -0
- package/src/sdk-agent-runner.ts +19 -0
- package/src/verification.ts +84 -7
- package/src/worktree.ts +263 -59
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
|
-
[
|
|
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(
|
|
248
|
-
|
|
249
|
-
|
|
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(
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
() =>
|
|
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
|
-
[
|
|
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(
|
|
314
|
-
|
|
315
|
-
|
|
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(
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
[
|
|
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(
|
|
386
|
-
|
|
387
|
-
|
|
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(
|
|
402
|
-
|
|
403
|
-
|
|
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(
|
|
418
|
-
|
|
419
|
-
|
|
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(
|
|
433
|
-
|
|
434
|
-
|
|
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(
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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(
|
|
514
|
-
|
|
515
|
-
|
|
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(
|
|
534
|
-
|
|
535
|
-
|
|
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(
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
[
|
|
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"] },
|