@gethmy/harness 1.4.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 +527 -69
- package/dist/index.js +660 -228
- 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 +149 -57
package/src/sdk-agent-runner.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
type McpServerConfig,
|
|
37
37
|
type Options,
|
|
38
38
|
query,
|
|
39
|
+
type SandboxSettings,
|
|
39
40
|
type SDKMessage,
|
|
40
41
|
type SettingSource,
|
|
41
42
|
type SpawnedProcess,
|
|
@@ -143,6 +144,18 @@ export interface SdkRunnerConfig {
|
|
|
143
144
|
* Only the sizing preflight sets this. Every other spawn keeps CLI parity.
|
|
144
145
|
*/
|
|
145
146
|
gateEveryToolCall?: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* OS-level isolation for COMMAND EXECUTION — Seatbelt on macOS, bubblewrap on
|
|
149
|
+
* Linux (#988). Built by `implementRunContainment` (`run-containment.ts`),
|
|
150
|
+
* which is where the reasoning for each field lives.
|
|
151
|
+
*
|
|
152
|
+
* This is the only containment knob that leaves `Bash` usable. `canUseTool` +
|
|
153
|
+
* `confineToRepo` gate the agent's own TOOL calls, so they can confine a
|
|
154
|
+
* spawn that only reads and edits; they say nothing about what a command that
|
|
155
|
+
* spawn runs may then reach. An implement run has to run commands, so it
|
|
156
|
+
* needs the bound one layer down.
|
|
157
|
+
*/
|
|
158
|
+
sandbox?: SandboxSettings;
|
|
146
159
|
/**
|
|
147
160
|
* Hands the spawned process-group leader to the worker so its existing
|
|
148
161
|
* pause/resume/cancel paths can keep operating on `this.process`.
|
|
@@ -322,6 +335,12 @@ export class SdkAgentRunner implements AgentRunner {
|
|
|
322
335
|
: {}),
|
|
323
336
|
...(this.cfg.mcpServers ? { mcpServers: this.cfg.mcpServers } : {}),
|
|
324
337
|
...(this.cfg.strictMcpConfig ? { strictMcpConfig: true } : {}),
|
|
338
|
+
// Execution sandbox (#988). Omitted entirely when unset, so every spawn
|
|
339
|
+
// that has not opted in keeps its current behaviour — the same shape as
|
|
340
|
+
// `canUseTool` above. When it IS set, `failIfUnavailable` inside it means
|
|
341
|
+
// an unsupported host fails the run rather than quietly running it
|
|
342
|
+
// uncontained, so there is no silent third state.
|
|
343
|
+
...(this.cfg.sandbox ? { sandbox: this.cfg.sandbox } : {}),
|
|
325
344
|
stderr: (data) => {
|
|
326
345
|
this.capturedStderr += data;
|
|
327
346
|
},
|
package/src/verification.ts
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
testCommand,
|
|
12
12
|
} from "./project-type.js";
|
|
13
13
|
import { findDeletedTestFiles } from "./revert-guard.js";
|
|
14
|
+
import {
|
|
15
|
+
containedEnv,
|
|
16
|
+
GIT_NO_HOOKS,
|
|
17
|
+
implementRunContainmentCliArgs,
|
|
18
|
+
} from "./run-containment.js";
|
|
14
19
|
|
|
15
20
|
const TAG = "verification";
|
|
16
21
|
|
|
@@ -148,6 +153,38 @@ export async function runVerification(
|
|
|
148
153
|
return result;
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
/**
|
|
157
|
+
* ## The verification steps run the RUN'S OWN scripts, and that is a known hole
|
|
158
|
+
*
|
|
159
|
+
* `runBuild`, `runTests`, `runFormatFix`, `runLint` and `runDeepReview`'s dev
|
|
160
|
+
* server all resolve their command through `spawnRunArgs` — `bun run <script>`
|
|
161
|
+
* out of the worktree's own `package.json` — and execute it **in the daemon
|
|
162
|
+
* process**. (`review-worker.ts` spawns the sixth, its own `dev` server, for the
|
|
163
|
+
* same reason and with the same exposure.)
|
|
164
|
+
* The worktree is in `sandbox.filesystem.allowWrite` by design, so a contained
|
|
165
|
+
* implement run can rewrite `"build"` to anything it likes and the daemon will
|
|
166
|
+
* then run it, outside the sandbox, with full network. No failure is needed to
|
|
167
|
+
* trigger this; verification runs on every successful run.
|
|
168
|
+
*
|
|
169
|
+
* `env: containedEnv()` (#988) is a real reduction and NOT a fix: it removes
|
|
170
|
+
* the secret-shaped variables, so `HARMONY_API_KEY`, `GITHUB_PERSONAL_ACCESS_TOKEN`
|
|
171
|
+
* and their neighbours are gone from the child. What remains is a command of
|
|
172
|
+
* the run's choosing, executing as the operator, able to read `~/.ssh` and
|
|
173
|
+
* reach the network.
|
|
174
|
+
*
|
|
175
|
+
* Closing it properly means running the repo's own build and test somewhere the
|
|
176
|
+
* operator's credentials are not — which is the problem `repair-sandbox.ts`
|
|
177
|
+
* solved for the CI repair with a container, and it is a piece of work of its
|
|
178
|
+
* own rather than a line in this one. Until then this is listed in
|
|
179
|
+
* `run-containment.ts`'s "what this does not cover" and in `docs/agent-daemon.md`,
|
|
180
|
+
* because a residual nobody wrote down is indistinguishable from one nobody saw.
|
|
181
|
+
*
|
|
182
|
+
* That is not a figure of speech here. The first version of both lists named
|
|
183
|
+
* the four verification steps only, and the two `dev` servers — which are the
|
|
184
|
+
* same `spawnRunArgs` shape — then shipped with the daemon's full environment,
|
|
185
|
+
* unlisted and unnoticed through every gate. `spawn-run-containment.test.ts`
|
|
186
|
+
* now asserts the property over call sites rather than over a written list.
|
|
187
|
+
*/
|
|
151
188
|
export function runBuild(worktreePath: string, timeout: number): string[] {
|
|
152
189
|
const command = buildCommand(worktreePath);
|
|
153
190
|
if (!command) {
|
|
@@ -163,6 +200,7 @@ export function runBuild(worktreePath: string, timeout: number): string[] {
|
|
|
163
200
|
timeout,
|
|
164
201
|
stdio: "pipe",
|
|
165
202
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
203
|
+
env: containedEnv(),
|
|
166
204
|
});
|
|
167
205
|
return [];
|
|
168
206
|
} catch (err: unknown) {
|
|
@@ -190,6 +228,7 @@ export function runTests(worktreePath: string, timeout: number): string[] {
|
|
|
190
228
|
timeout,
|
|
191
229
|
stdio: "pipe",
|
|
192
230
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
231
|
+
env: containedEnv(),
|
|
193
232
|
});
|
|
194
233
|
return [];
|
|
195
234
|
} catch (err: unknown) {
|
|
@@ -229,6 +268,7 @@ export function runFormatFix(
|
|
|
229
268
|
timeout,
|
|
230
269
|
stdio: "pipe",
|
|
231
270
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
271
|
+
env: containedEnv(),
|
|
232
272
|
});
|
|
233
273
|
log.info(
|
|
234
274
|
TAG,
|
|
@@ -259,6 +299,7 @@ export function runLint(worktreePath: string, timeout: number): string[] {
|
|
|
259
299
|
timeout,
|
|
260
300
|
stdio: "pipe",
|
|
261
301
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
302
|
+
env: containedEnv(),
|
|
262
303
|
});
|
|
263
304
|
return [];
|
|
264
305
|
} catch (err: unknown) {
|
|
@@ -290,6 +331,19 @@ export async function runDeepReview(
|
|
|
290
331
|
devServer = spawn(cmd, args, {
|
|
291
332
|
cwd: worktreePath,
|
|
292
333
|
stdio: ["ignore", "pipe", "pipe"],
|
|
334
|
+
// Contained (#988), and it was the one spawn in this file that was not.
|
|
335
|
+
// `spawnRunArgs("dev", …)` resolves the worktree's OWN `dev` script,
|
|
336
|
+
// which the contained implement run may rewrite, so this is the same
|
|
337
|
+
// attacker-choosable command as `runBuild`/`runTests` — except it ran
|
|
338
|
+
// with the daemon's full environment, `HARMONY_API_KEY` and
|
|
339
|
+
// `GITHUB_PERSONAL_ACCESS_TOKEN` included, while `changelog.ts` told
|
|
340
|
+
// users the verification commands run "without your credentials".
|
|
341
|
+
//
|
|
342
|
+
// A reduction, not a fix: the command still executes outside the sandbox
|
|
343
|
+
// with network. That is why the dev server is now NAMED in the residual
|
|
344
|
+
// list in `run-containment.ts` and in `docs/agent-daemon.md`, instead of
|
|
345
|
+
// being left implied by a list of four other scripts.
|
|
346
|
+
env: containedEnv(),
|
|
293
347
|
});
|
|
294
348
|
|
|
295
349
|
// Wait for dev server to be ready, then confirm it answers HTTP.
|
|
@@ -311,7 +365,7 @@ export async function runDeepReview(
|
|
|
311
365
|
// "(unable to retrieve diff)" — reviewing the change with no change (#701).
|
|
312
366
|
diff = execFileSync(
|
|
313
367
|
"git",
|
|
314
|
-
["diff", `origin/${config.worktree.baseBranch}..HEAD`],
|
|
368
|
+
[...GIT_NO_HOOKS, "diff", `origin/${config.worktree.baseBranch}..HEAD`],
|
|
315
369
|
{
|
|
316
370
|
cwd: worktreePath,
|
|
317
371
|
encoding: "utf-8",
|
|
@@ -336,7 +390,6 @@ export async function runDeepReview(
|
|
|
336
390
|
"```",
|
|
337
391
|
].join("\n");
|
|
338
392
|
|
|
339
|
-
const leanSources = config.claude.leanSettingSources;
|
|
340
393
|
const output = execFileSync(
|
|
341
394
|
"claude",
|
|
342
395
|
[
|
|
@@ -345,8 +398,16 @@ export async function runDeepReview(
|
|
|
345
398
|
"sonnet",
|
|
346
399
|
"--max-turns",
|
|
347
400
|
"10",
|
|
348
|
-
//
|
|
349
|
-
|
|
401
|
+
// Contained (#988). This spawn reads a diff the implement run wrote, in
|
|
402
|
+
// that run's own worktree, so it is downstream of untrusted text by
|
|
403
|
+
// construction — and it used to run with no sandbox and no credential
|
|
404
|
+
// deny at all. It has no `--allowedTools` of its own, which bounds it
|
|
405
|
+
// less than it looks: the default set still reads files.
|
|
406
|
+
...implementRunContainmentCliArgs({
|
|
407
|
+
worktree: worktreePath,
|
|
408
|
+
// Reads a diff and reports findings; it writes nothing.
|
|
409
|
+
readOnly: true,
|
|
410
|
+
}),
|
|
350
411
|
"--",
|
|
351
412
|
reviewPrompt,
|
|
352
413
|
],
|
|
@@ -356,6 +417,7 @@ export async function runDeepReview(
|
|
|
356
417
|
timeout: config.verification.timeout,
|
|
357
418
|
stdio: "pipe",
|
|
358
419
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
420
|
+
env: containedEnv(),
|
|
359
421
|
},
|
|
360
422
|
);
|
|
361
423
|
|
|
@@ -396,7 +458,6 @@ export function attemptAutoFix(
|
|
|
396
458
|
"```",
|
|
397
459
|
].join("\n");
|
|
398
460
|
|
|
399
|
-
const leanSources = config.claude.leanSettingSources;
|
|
400
461
|
const args = [
|
|
401
462
|
"--print",
|
|
402
463
|
"--model",
|
|
@@ -405,8 +466,23 @@ export function attemptAutoFix(
|
|
|
405
466
|
"50",
|
|
406
467
|
"--allowedTools",
|
|
407
468
|
"Bash,Read,Write,Edit,Glob,Grep",
|
|
408
|
-
//
|
|
409
|
-
|
|
469
|
+
// Contained exactly like the implement run (#988). This spawn used to pass
|
|
470
|
+
// `--setting-sources local,user` and nothing else: no sandbox, no
|
|
471
|
+
// credential denies, no env strip — measured reading
|
|
472
|
+
// `~/.harmony-mcp/config.json` (the Harmony API key), reaching the open
|
|
473
|
+
// internet and writing to `$HOME`.
|
|
474
|
+
//
|
|
475
|
+
// It is not an edge path. `maxFixAttempts` defaults to 1, so a failing
|
|
476
|
+
// build reaches here on an ordinary run — and whether the build fails is
|
|
477
|
+
// under the CONTAINED run's control, which made this the cheap way out of
|
|
478
|
+
// the containment: break the build, get an uncontained spawn in the same
|
|
479
|
+
// worktree, on the same branch.
|
|
480
|
+
//
|
|
481
|
+
// `leanSettingSources` is deliberately no longer read here. It exists to
|
|
482
|
+
// drop the project docs an auto-fix does not need (#348), and the
|
|
483
|
+
// containment's `settingSources` already excludes `user`; honouring both
|
|
484
|
+
// would let the operator's config widen a boundary.
|
|
485
|
+
...implementRunContainmentCliArgs({ worktree: worktreePath }),
|
|
410
486
|
"--",
|
|
411
487
|
fixPrompt,
|
|
412
488
|
];
|
|
@@ -419,6 +495,7 @@ export function attemptAutoFix(
|
|
|
419
495
|
timeout: config.verification.timeout,
|
|
420
496
|
stdio: "pipe",
|
|
421
497
|
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
498
|
+
env: containedEnv(),
|
|
422
499
|
});
|
|
423
500
|
}
|
|
424
501
|
|
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
|
-
[
|
|
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(
|
|
355
|
-
|
|
356
|
-
|
|
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(
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
[
|
|
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(
|
|
426
|
-
|
|
427
|
-
|
|
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(
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
[
|
|
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(
|
|
498
|
-
|
|
499
|
-
|
|
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(
|
|
514
|
-
|
|
515
|
-
|
|
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(
|
|
530
|
-
|
|
531
|
-
|
|
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(
|
|
545
|
-
|
|
546
|
-
|
|
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(
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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(
|
|
626
|
-
|
|
627
|
-
|
|
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(
|
|
646
|
-
|
|
647
|
-
|
|
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(
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
[
|
|
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"] },
|