wave-code 1.6.2 → 1.6.4

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.
@@ -1,8 +1,7 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
1
  import * as path from "node:path";
4
2
  import * as fs from "node:fs";
5
3
  import {
4
+ execFileAsyncHidden,
6
5
  getDefaultRemoteBranch,
7
6
  getGitMainRepoRoot,
8
7
  performPostCreationSetup,
@@ -10,13 +9,22 @@ import {
10
9
  hasWorktreeCreateHook,
11
10
  executeWorktreeCreateHook,
12
11
  executeWorktreeRemoveHook,
12
+ EPHEMERAL_WORKTREE_PREFIX,
13
+ // The worktree delete core, shared with the SDK's own removal (ExitWorktree,
14
+ // subagent isolation, the startup sweep) so the two entry points cannot drift
15
+ // apart again — they used to be independent implementations, one git-first.
16
+ describeRemovalFailure,
17
+ describeWorktreeRemovalOutcome,
18
+ describeWorktreeResidue,
19
+ finishWorktreeRemoval,
20
+ prepareWorktreeRemoval,
21
+ removeWorktreeContents,
13
22
  } from "wave-agent-sdk";
14
23
  import { logger } from "./logger.js";
15
24
 
16
25
  // Never use execFileSync here: the shared `wave --stdio` process handles all
17
26
  // desktop sessions, so a synchronous git call (especially a multi-second
18
27
  // recursive worktree delete or a network fetch) freezes every session.
19
- const execFileAsync = promisify(execFile);
20
28
 
21
29
  export interface WorktreeSession {
22
30
  name: string;
@@ -64,6 +72,14 @@ export function validateWorktreeSlug(name: string): void {
64
72
  `Invalid worktree name: "${name}" must be ${MAX_WORKTREE_SLUG_LENGTH} characters or fewer (got ${name.length})`,
65
73
  );
66
74
  }
75
+ // `sa-` belongs to wave's own ephemeral worktrees (subagent isolation), which
76
+ // the startup sweep deletes once they go stale — a user's worktree must never
77
+ // live in that namespace.
78
+ if (name.split("/")[0].startsWith(EPHEMERAL_WORKTREE_PREFIX)) {
79
+ throw new Error(
80
+ `Invalid worktree name: "${name}" must not start with "${EPHEMERAL_WORKTREE_PREFIX}" (reserved for wave-managed temporary worktrees)`,
81
+ );
82
+ }
67
83
  for (const segment of name.split("/")) {
68
84
  if (segment === "." || segment === "..") {
69
85
  throw new Error(
@@ -91,10 +107,14 @@ export function validateWorktreeSlug(name: string): void {
91
107
  */
92
108
  export async function listWorktrees(cwd: string): Promise<string[]> {
93
109
  try {
94
- const { stdout } = await execFileAsync(
110
+ const { stdout } = await execFileAsyncHidden(
95
111
  "git",
96
112
  ["worktree", "list", "--porcelain"],
97
- { cwd },
113
+ // Explicit for type determinism, not a fix: `execFile` already defaults
114
+ // `encoding` to 'utf8', so omitting it would also yield a string. Naming
115
+ // it picks the `{ stdout: string }` overload of `execFileAsyncHidden`
116
+ // instead of leaving the reader to know that default.
117
+ { cwd, encoding: "utf8" },
98
118
  );
99
119
 
100
120
  const worktreePaths = stdout
@@ -204,7 +224,7 @@ async function createWorktreeInternal(
204
224
 
205
225
  try {
206
226
  // Create worktree and branch
207
- await execFileAsync(
227
+ await execFileAsyncHidden(
208
228
  "git",
209
229
  ["worktree", "add", "-b", branchName, worktreePath, resolvedBaseBranch],
210
230
  {
@@ -227,7 +247,7 @@ async function createWorktreeInternal(
227
247
  if (stderr.includes("already exists")) {
228
248
  // If branch already exists, try to add worktree without -b
229
249
  try {
230
- await execFileAsync(
250
+ await execFileAsyncHidden(
231
251
  "git",
232
252
  ["worktree", "add", worktreePath, branchName],
233
253
  {
@@ -257,10 +277,10 @@ async function createWorktreeInternal(
257
277
  // Base branch not fetched yet — try fetching then retrying
258
278
  const branchNameOnly = resolvedBaseBranch.split("/").pop()!;
259
279
  try {
260
- await execFileAsync("git", ["fetch", "origin", branchNameOnly], {
280
+ await execFileAsyncHidden("git", ["fetch", "origin", branchNameOnly], {
261
281
  cwd: repoRoot,
262
282
  });
263
- await execFileAsync(
283
+ await execFileAsyncHidden(
264
284
  "git",
265
285
  [
266
286
  "worktree",
@@ -286,7 +306,7 @@ async function createWorktreeInternal(
286
306
  } catch {
287
307
  // Fetch or retry failed — fall back to HEAD
288
308
  try {
289
- await execFileAsync(
309
+ await execFileAsyncHidden(
290
310
  "git",
291
311
  ["worktree", "add", "-b", branchName, worktreePath, "HEAD"],
292
312
  {
@@ -315,26 +335,6 @@ async function createWorktreeInternal(
315
335
  }
316
336
  }
317
337
 
318
- /**
319
- * On Windows, prefix an absolute path with the extended-length marker (`\\?\`)
320
- * so recursive removal bypasses the 260-char MAX_PATH limit. POSIX paths are
321
- * returned unchanged.
322
- */
323
- function toExtendedLengthPath(worktreePath: string): string {
324
- if (process.platform !== "win32") {
325
- return worktreePath;
326
- }
327
- const absolute = path.win32.resolve(worktreePath);
328
- if (absolute.startsWith("\\\\?\\")) {
329
- return absolute;
330
- }
331
- if (absolute.startsWith("\\\\")) {
332
- // UNC path: \\server\share -> \\?\UNC\server\share
333
- return `\\\\?\\UNC\\${absolute.slice(2)}`;
334
- }
335
- return `\\\\?\\${absolute}`;
336
- }
337
-
338
338
  /** First line of a captured stderr/message, trimmed and length-capped. */
339
339
  function firstLine(text: string | undefined, max = 200): string {
340
340
  if (!text) return "";
@@ -343,9 +343,13 @@ function firstLine(text: string | undefined, max = 200): string {
343
343
  }
344
344
 
345
345
  /**
346
- * Summarise why `git worktree remove` failed. execFile rejections carry the
347
- * exit code and the child's stderr; without them a removal failure is
348
- * indistinguishable from a MAX_PATH refusal, a locking process, or a git error.
346
+ * Summarise why a git command failed. execFile rejections carry the exit code
347
+ * and the child's stderr; without them a failure is indistinguishable from a
348
+ * missing git, a locked index, or a plain git error.
349
+ *
350
+ * Only `git worktree prune` can fail here now — git no longer performs the
351
+ * delete itself (see `removeWorktree`), so there is no `worktree remove`
352
+ * stderr left to explain.
349
353
  */
350
354
  function describeGitFailure(error: unknown): string {
351
355
  const e = error as {
@@ -363,41 +367,8 @@ function describeGitFailure(error: unknown): string {
363
367
  return parts.join(" ");
364
368
  }
365
369
 
366
- /** Summarise why the `fs.rmSync` fallback failed (code + offending path). */
367
- function describeFsFailure(error: unknown): string {
368
- const e = error as {
369
- code?: string;
370
- syscall?: string;
371
- path?: string;
372
- message?: string;
373
- };
374
- const parts: string[] = [];
375
- if (e.code) parts.push(`code=${e.code}`);
376
- if (e.syscall) parts.push(`syscall=${e.syscall}`);
377
- if (e.path) parts.push(`at=${e.path}`);
378
- if (parts.length === 0) parts.push(`message=${firstLine(e.message)}`);
379
- return parts.join(" ");
380
- }
381
-
382
370
  /**
383
- * Delete the worktree directory with `fs.rmSync`, returning the error when it
384
- * fails (null on success). Node removes directory symlinks/junctions properly,
385
- * which is exactly where git gives up on Windows.
386
- */
387
- function rmWorktreeDirWithFs(worktreePath: string): unknown | null {
388
- try {
389
- fs.rmSync(toExtendedLengthPath(worktreePath), {
390
- recursive: true,
391
- force: true,
392
- });
393
- return null;
394
- } catch (error) {
395
- return error;
396
- }
397
- }
398
-
399
- /**
400
- * Waits between `fs.rmSync` attempts on the worktree directory. Mutable so
371
+ * Waits between content-delete attempts on the worktree directory. Mutable so
401
372
  * tests can zero them out.
402
373
  *
403
374
  * The first attempt still runs immediately; these only spread the retries.
@@ -407,6 +378,11 @@ function rmWorktreeDirWithFs(worktreePath: string): unknown | null {
407
378
  * the OS let go (issue #2763: the same `rm -rf` succeeds a few seconds later by
408
379
  * hand). Three attempts over ~0.9s covers the release lag without turning a
409
380
  * genuinely locked directory into a multi-second stall.
381
+ *
382
+ * These cover the content delete only. Once the `.git` pointer is gone there is
383
+ * nothing left worth retrying — an empty directory's removal either works at
384
+ * once or is blocked by something a wait will not fix — and a retry there would
385
+ * reopen exactly the window this ordering exists to close.
410
386
  */
411
387
  export const worktreeRemoveRetry = { delaysMs: [300, 600] };
412
388
 
@@ -415,40 +391,30 @@ function sleep(ms: number): Promise<void> {
415
391
  }
416
392
 
417
393
  /**
418
- * `rmWorktreeDirWithFs` with the short retry above. Returns null once the
419
- * directory is gone (or the deletion reported success), the last error
420
- * otherwise. Existence is deliberately not probed here: the caller already
421
- * decides "did it actually go" from the filesystem, and an extra stat per
422
- * attempt would only make that harder to reason about.
394
+ * The shared `removeWorktreeContents` with the short retry above. Returns null
395
+ * once every entry except `.git` is gone, the last error otherwise. Existence is
396
+ * deliberately not probed here: the caller already decides "did it actually go"
397
+ * from the filesystem, and an extra stat per attempt would only make that harder
398
+ * to reason about.
399
+ *
400
+ * The retry lives here rather than in the shared core because how long to wait
401
+ * for a just-exited process is this entry point's call — the SDK's synchronous
402
+ * caller deletes once. It wraps this one call and no other: the `.git` unlink
403
+ * and the shell delete happen after it, and retrying them makes no sense once
404
+ * the pointer is gone.
423
405
  */
424
- async function rmWorktreeDirWithRetries(
406
+ async function rmWorktreeContentsWithRetries(
425
407
  worktreePath: string,
426
408
  ): Promise<unknown | null> {
427
- let error = rmWorktreeDirWithFs(worktreePath);
409
+ let error = removeWorktreeContents(worktreePath);
428
410
  for (const delayMs of worktreeRemoveRetry.delaysMs) {
429
411
  if (!error) return null;
430
412
  await sleep(delayMs);
431
- error = rmWorktreeDirWithFs(worktreePath);
413
+ error = removeWorktreeContents(worktreePath);
432
414
  }
433
415
  return error;
434
416
  }
435
417
 
436
- /**
437
- * Describe what is still on disk after a failed removal. Without this a
438
- * failure log cannot distinguish "directory already gone" from "the whole
439
- * checkout is still sitting there".
440
- */
441
- function probeResidue(worktreePath: string): string {
442
- try {
443
- if (!fs.existsSync(worktreePath)) return "none";
444
- const entries = fs.readdirSync(worktreePath) as string[];
445
- const head = entries.slice(0, 5).join(",");
446
- return `${entries.length}[${head}${entries.length > 5 ? ",…" : ""}]`;
447
- } catch (error) {
448
- return `unknown(${(error as { code?: string }).code ?? "error"})`;
449
- }
450
- }
451
-
452
418
  export interface WorktreeChanges {
453
419
  /** Uncommitted (tracked-modified or untracked) files in the worktree. */
454
420
  files: number;
@@ -634,10 +600,14 @@ export async function getWorktreeChanges(
634
600
  ): Promise<WorktreeChanges | null> {
635
601
  let files: number;
636
602
  try {
637
- const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
638
- cwd: worktreePath,
639
- encoding: "utf8",
640
- });
603
+ const { stdout } = await execFileAsyncHidden(
604
+ "git",
605
+ ["status", "--porcelain"],
606
+ {
607
+ cwd: worktreePath,
608
+ encoding: "utf8",
609
+ },
610
+ );
641
611
  files = stdout.split("\n").filter((line) => line.trim().length > 0).length;
642
612
  } catch {
643
613
  return null;
@@ -646,7 +616,7 @@ export async function getWorktreeChanges(
646
616
  let commits = 0;
647
617
  try {
648
618
  const base = baseBranch ?? getDefaultRemoteBranch(worktreePath);
649
- const { stdout } = await execFileAsync(
619
+ const { stdout } = await execFileAsyncHidden(
650
620
  "git",
651
621
  ["rev-list", "--count", `${base}..HEAD`],
652
622
  { cwd: worktreePath, encoding: "utf8" },
@@ -667,20 +637,55 @@ export async function getWorktreeChanges(
667
637
  * or `worktreeCreationBranch`) — never the branch that directory happens to
668
638
  * have checked out, which an in-session `git checkout` can change.
669
639
  *
670
- * Removal is best-effort: `git worktree remove --force` deletes the worktree
671
- * metadata before the working directory, and on Windows its recursive deletion
672
- * is MAX_PATH-limited — deep paths (e.g. node_modules) can fail with "Filename
673
- * too long", leaving an orphan directory. When git fails we fall back to
674
- * fs.rmSync with an extended-length path (bypasses MAX_PATH) and prune stale
675
- * metadata. The fallback retries a couple of times (`worktreeRemoveRetry`): a
676
- * directory locked by a process that is only now exiting — the usual Windows
677
- * case — becomes deletable a moment later. Failures are logged; when the
678
- * directory still survives, its branch is deliberately kept so the leftover
679
- * checkout stays reachable through git.
640
+ * The order is inverted relative to "let git delete it" — git performs no part
641
+ * of the delete at all — but the `.git` pointer is deliberately *not* first:
642
+ *
643
+ * 1. the checks git used to make (main working tree / lock / points back);
644
+ * 2. read the pointer's bytes;
645
+ * 3. Windows only: clear the reparse points;
646
+ * 4. delete every entry under the worktree except the root `.git` ← retried;
647
+ * 5. unlink the root `.git`;
648
+ * 6. remove the now-empty directory;
649
+ * 7. only if 6 failed: write the pointer back;
650
+ * 8. `git worktree prune`, then `git branch -D`.
651
+ *
652
+ * git's exit code is not trustworthy for this job. `git worktree remove`
653
+ * deletes the metadata before the working directory, and on Windows it cannot
654
+ * recurse over the junctions pnpm's node_modules is made of — it deletes the
655
+ * files, leaves the skeleton behind and still exits 0; the opposite also
656
+ * happens (it deregisters, then fails to delete). Either way the directory and
657
+ * git's records end up disagreeing, which is the "residue + branch still there
658
+ * + worktree no longer registered" report. So git no longer deletes: the
659
+ * contents go first with the pointer still in place (the long, failure-prone
660
+ * step), then the pointer is unlinked, then the empty shell is removed with
661
+ * `fs.rmSync` on an extended-length path (bypasses MAX_PATH), and `prune` ties
662
+ * up the registration afterwards.
663
+ *
664
+ * Steps 4 and 5 are in that order because step 4 is the one that takes seconds
665
+ * and the one that fails on a held file. While it runs git still registers the
666
+ * worktree, so a process killed there leaves something `git worktree prune` and
667
+ * the next removal both find. A failed step 4 or 5 leaves the pointer untouched
668
+ * and nothing is written back; only a failed step 6 needs the bytes restored.
669
+ *
670
+ * The checks git used to make — is this the main working tree, is it
671
+ * `git worktree lock`ed, does its `.git` really point into this repo — plus the
672
+ * pointer handling and the delete come from the shared core
673
+ * (`worktreeRemoval.ts`, exported by `wave-agent-sdk`), which is also what the
674
+ * SDK's own removal uses (ExitWorktree, subagent isolation, the startup sweep).
675
+ * One delete core, two calling conventions; they used to be independent
676
+ * implementations and drifted. What stays here is this package's half: git
677
+ * calls are async (a synchronous spawn would freeze every session the shared
678
+ * `wave --stdio` process serves), the *content* delete retries
679
+ * (`worktreeRemoveRetry`) because Windows releases a just-exited process's
680
+ * directory handle asynchronously, and the answer is "is the directory gone"
681
+ * rather than nothing.
680
682
  *
681
683
  * Returns whether the directory is actually gone (not whether the removal was
682
684
  * attempted) — a caller that reports the outcome to a user must print it rather
683
685
  * than assume success, because every failure mode above is silent by design.
686
+ * A refusal (locked, `.git` not pointing into this repo, main working tree) is
687
+ * the same answer as a failed delete: `false`, with the directory and its
688
+ * branch untouched.
684
689
  *
685
690
  * The branch is deleted only when `repoRoot` actually has it (`branchExists`),
686
691
  * so a name inferred from the path — which no longer depends on how the two
@@ -715,76 +720,117 @@ export async function removeWorktree(
715
720
  }
716
721
 
717
722
  const repoRoot = session.repoRoot;
723
+ const worktreePath = session.path;
718
724
 
719
- // Remove worktree
720
- try {
721
- await execFileAsync(
722
- "git",
723
- ["worktree", "remove", "--force", session.path],
724
- {
725
- cwd: repoRoot,
726
- },
725
+ // Steps 1–3 — the checks git used to make, the Windows reparse-point pass and
726
+ // the pointer read — all in the shared core, so this path and the SDK's cannot
727
+ // diverge.
728
+ const prepared = prepareWorktreeRemoval({ worktreePath, repoRoot });
729
+
730
+ if (prepared.status === "refused") {
731
+ // Nothing was deleted and the directory is still there, so this is a
732
+ // `false` answer to "is the directory gone" — not a throw.
733
+ logger.warn(
734
+ `Removal refused: path=${worktreePath} refusal=${prepared.reason} — ${prepared.message}`,
727
735
  );
728
- // git exits 0 even when it did not delete the directory: on Windows it
729
- // cannot remove directory symlinks/junctions, which is exactly what pnpm's
730
- // node_modules is made of — so it deletes the files, leaves the skeleton
731
- // behind and still reports success. Trust the filesystem, not the exit code.
732
- if (fs.existsSync(session.path)) {
736
+ return false;
737
+ }
738
+
739
+ if (prepared.status === "prepared") {
740
+ if (prepared.leftover) {
741
+ // No pointer: a previous cleanup already took it, so git does not know
742
+ // this directory any more — it is just files.
733
743
  logger.warn(
734
- `git worktree remove reported success but the directory survived: ` +
735
- `path=${session.path} residue=${probeResidue(session.path)} — ` +
736
- `deleting with fs.rmSync instead`,
744
+ `Worktree .git pointer already gone — deleting the directory as a ` +
745
+ `plain leftover: path=${worktreePath}`,
737
746
  );
738
- const rmError = await rmWorktreeDirWithRetries(session.path);
739
- if (rmError) {
740
- logger.error(
741
- `Failed to remove worktree or branch: path=${session.path} ` +
742
- `stage=fs(after git success) ${describeFsFailure(rmError)} ` +
743
- `residue=${probeResidue(session.path)}`,
744
- rmError,
745
- );
746
- }
747
747
  }
748
- } catch (error: unknown) {
749
- logger.warn(
750
- `git worktree remove failed, falling back to fs.rmSync: ${describeGitFailure(error)}`,
751
- error,
752
- );
753
- const rmError = await rmWorktreeDirWithRetries(session.path);
754
- if (rmError) {
755
- // Removal failures are best-effort, so this line is often the only trace
756
- // of an orphaned worktree directory: record which stage failed, why, and
757
- // whether anything was left behind.
758
- logger.error(
759
- `Failed to remove worktree or branch: path=${session.path} stage=fs ` +
760
- `${describeFsFailure(rmError)} residue=${probeResidue(session.path)} ` +
761
- `git(${describeGitFailure(error)})`,
762
- rmError,
748
+ if (prepared.reparsePointsUnlinked > 0) {
749
+ logger.info(
750
+ `Unlinked ${prepared.reparsePointsUnlinked} reparse point(s) before ` +
751
+ `removing the worktree: path=${worktreePath}`,
763
752
  );
764
753
  }
765
- // git removes worktree metadata before the working directory; prune any
766
- // leftovers in case git failed before deleting them.
767
- try {
768
- await execFileAsync("git", ["worktree", "prune"], {
769
- cwd: repoRoot,
754
+
755
+ // Step 4, retried — the only retried step, and the only one that runs while
756
+ // git still registers the worktree. Failing here or at the unlink below
757
+ // leaves the pointer untouched, so nothing is written back: the directory
758
+ // and git's records still agree, which is the state this ordering buys.
759
+ const contentError = await rmWorktreeContentsWithRetries(worktreePath);
760
+ if (contentError) {
761
+ const errno = (contentError as NodeJS.ErrnoException | undefined)?.code;
762
+ logger.error(
763
+ `Failed to remove the worktree's contents — its .git is still in ` +
764
+ `place, so git keeps it registered: path=${worktreePath} ` +
765
+ `stage=contents` +
766
+ (typeof errno === "string" ? ` code=${errno}` : "") +
767
+ ` ${describeRemovalFailure(contentError, prepared.reparsePointUnremovable)} ` +
768
+ `gitPointerIntact=true residue=${describeWorktreeResidue(worktreePath)}`,
769
+ contentError,
770
+ );
771
+ } else {
772
+ // Steps 5–7: unlink the pointer, remove the shell, and write the pointer
773
+ // back only if that last step failed. The restore has to happen after the
774
+ // retries above, never between them; and the pointer must not go back
775
+ // when the unlink itself failed, since it was never removed.
776
+ const finish = finishWorktreeRemoval({
777
+ worktreePath,
778
+ pointer: prepared.pointer,
779
+ reparsePointUnremovable: prepared.reparsePointUnremovable,
770
780
  });
771
- } catch {
772
- // Ignore errors pruning stale metadata
781
+
782
+ if (finish.status === "pointer-stuck") {
783
+ // Deleting the directory while git still registers it would leave the
784
+ // dangling entry this design exists to prevent. The shared tail below
785
+ // reports the surviving directory and returns false.
786
+ logger.error(
787
+ `Failed to remove worktree or branch: path=${worktreePath} ` +
788
+ `stage=unlink(.git) ${finish.reason} gitPointerIntact=true ` +
789
+ `residue=${describeWorktreeResidue(worktreePath)}`,
790
+ );
791
+ } else if (finish.status === "shell-stuck") {
792
+ // The raw errno next to the translated sentence: the sentence says what
793
+ // to do about it, the code is what a bug report or a search matches.
794
+ const errno = (finish.error as NodeJS.ErrnoException | undefined)?.code;
795
+ // Both failures are reported, and neither masks the other: the pointer
796
+ // went back byte for byte (so git still knows about the directory that
797
+ // survived) and the reason says what is holding it.
798
+ logger.error(
799
+ `Failed to remove worktree directory: path=${worktreePath} stage=fs` +
800
+ (typeof errno === "string" ? ` code=${errno}` : "") +
801
+ ` ${finish.reason} ` +
802
+ `gitPointerRestored=${finish.pointerRestored}` +
803
+ (finish.restoreError === null
804
+ ? ""
805
+ : ` restoreError=${firstLine((finish.restoreError as Error)?.message)}`) +
806
+ ` residue=${describeWorktreeResidue(worktreePath)}`,
807
+ finish.error,
808
+ );
809
+ }
773
810
  }
774
811
  }
775
812
 
776
813
  // The directory, not any exit code, decides whether the removal happened. A
777
814
  // surviving directory means its checkout (and any uncommitted work in it) is
778
815
  // still on disk, and the branch is the only ref still leading back to it.
779
- if (fs.existsSync(session.path)) {
816
+ if (fs.existsSync(worktreePath)) {
780
817
  logger.warn(
781
818
  `Worktree directory survived removal — keeping ` +
782
819
  `${session.branch || "its branches"} so the leftover checkout stays ` +
783
- `reachable: path=${session.path} residue=${probeResidue(session.path)}`,
820
+ `reachable: path=${worktreePath} residue=${describeWorktreeResidue(worktreePath)}`,
784
821
  );
785
822
  return false;
786
823
  }
787
824
 
825
+ // git's registration deliberately outlives the directory — that is what let
826
+ // the content delete be recoverable — so this is the step that drops it, and
827
+ // it has to run before `branch -D`, which git refuses for a branch a
828
+ // registered worktree still has checked out. It runs only once the directory
829
+ // is really gone: pruning while the worktree is still on disk would be a
830
+ // no-op at best and would deregister a directory a refusal (or a stuck
831
+ // pointer) deliberately left registered.
832
+ await pruneWorktreeRegistration(repoRoot, worktreePath);
833
+
788
834
  // Delete the worktree's own branch. Branches the user checked out inside the
789
835
  // worktree are never touched — they may hold commits that are not reachable
790
836
  // from anywhere else (aligned with Claude Code). That is why `session.branch`
@@ -793,7 +839,7 @@ export async function removeWorktree(
793
839
  // that must survive. An unknown branch deletes nothing at all.
794
840
  if (!session.branch) {
795
841
  logger.warn(
796
- `No branch recorded for the worktree at ${session.path} — keeping every ` +
842
+ `No branch recorded for the worktree at ${worktreePath} — keeping every ` +
797
843
  `branch rather than deleting the one it had checked out`,
798
844
  );
799
845
  return true;
@@ -806,13 +852,13 @@ export async function removeWorktree(
806
852
  if (!(await branchExists(repoRoot, session.branch))) {
807
853
  logger.warn(
808
854
  `No branch ${session.branch} in ${repoRoot} — keeping every branch ` +
809
- `(the worktree at ${session.path} was not created on it, or it is ` +
855
+ `(the worktree at ${worktreePath} was not created on it, or it is ` +
810
856
  `already gone)`,
811
857
  );
812
858
  return true;
813
859
  }
814
860
  try {
815
- await execFileAsync("git", ["branch", "-D", session.branch], {
861
+ await execFileAsyncHidden("git", ["branch", "-D", session.branch], {
816
862
  cwd: repoRoot,
817
863
  });
818
864
  } catch {
@@ -822,29 +868,52 @@ export async function removeWorktree(
822
868
  }
823
869
 
824
870
  /**
825
- * What to tell the user when `removeWorktree` returned `false`: where the
826
- * directory still is, which branch was kept for it (removal keeps that ref so
827
- * the leftover checkout stays reachable through git), and the two commands that
828
- * finish the job by hand.
871
+ * Drop git's record of a worktree that is no longer on disk.
872
+ *
873
+ * `git worktree prune` removes administrative dirs whose worktree has
874
+ * disappeared — exactly the state this removal leaves behind on purpose (see
875
+ * `removeWorktree`). A failure only warns: the directory is already gone by
876
+ * then, so all that is left is a stale entry a later `prune` will drop.
877
+ */
878
+ async function pruneWorktreeRegistration(
879
+ repoRoot: string,
880
+ worktreePath: string,
881
+ ): Promise<void> {
882
+ try {
883
+ await execFileAsyncHidden("git", ["worktree", "prune"], {
884
+ cwd: repoRoot,
885
+ });
886
+ } catch (error) {
887
+ logger.warn(
888
+ `git worktree prune failed after removing a worktree: ` +
889
+ `path=${worktreePath} ${describeGitFailure(error)}`,
890
+ error,
891
+ );
892
+ }
893
+ }
894
+
895
+ /**
896
+ * What to tell the user when `removeWorktree` returned `false`.
829
897
  *
830
- * Single-sourced because every CLI entry point that removes a worktree used to
831
- * print a flat "Done." whether or not anything was deleted — TUI exit, `-p`, and
832
- * `wave daemon destroy --remove-worktree` (issue #2763). One wording, one place.
898
+ * The text itself lives in the shared core
899
+ * (`describeWorktreeRemovalOutcome`), because `false` does not only mean "the
900
+ * delete failed": the removal is also refused outright for a `git worktree
901
+ * lock`ed worktree, a directory that is its own repository, and the main
902
+ * working tree. Only the first case should be answered with `rm -rf`, and this
903
+ * signature has no room for the reason — the core therefore probes the
904
+ * directory instead. Keeping the wrapper means the four callers (TUI exit,
905
+ * `-p` twice, `wave daemon destroy --remove-worktree`) do not change.
833
906
  */
834
907
  export function describeWorktreeSurvival(worktree: {
835
908
  path: string;
836
909
  branch: string;
837
910
  repoRoot: string;
838
911
  }): string {
839
- const branch = worktree.branch ? `branch ${worktree.branch}` : "every branch";
840
- return (
841
- `Worktree directory survived at ${worktree.path}; kept ${branch} so the ` +
842
- `leftover checkout stays reachable\n` +
843
- `Clean up manually: rm -rf ${worktree.path}` +
844
- (worktree.branch
845
- ? ` && git -C ${worktree.repoRoot} branch -D ${worktree.branch}`
846
- : "")
847
- );
912
+ return describeWorktreeRemovalOutcome({
913
+ worktreePath: worktree.path,
914
+ repoRoot: worktree.repoRoot,
915
+ branch: worktree.branch,
916
+ });
848
917
  }
849
918
 
850
919
  /**
@@ -859,7 +928,7 @@ async function branchExists(
859
928
  branch: string,
860
929
  ): Promise<boolean> {
861
930
  try {
862
- const { stdout } = await execFileAsync(
931
+ const { stdout } = await execFileAsyncHidden(
863
932
  "git",
864
933
  ["branch", "--list", "--format=%(refname:short)", branch],
865
934
  { cwd: repoRoot, encoding: "utf8" },