wave-code 1.6.3 → 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.
- package/dist/bundle/wave.mjs +586 -583
- package/package.json +3 -3
- package/src/daemon/commands.ts +9 -2
- package/src/remote/notificationCoalescer.ts +3 -1
- package/src/stdio/agentBridge.ts +72 -6
- package/src/utils/workspaceDiff.ts +2 -4
- package/src/utils/worktree.ts +244 -175
package/src/utils/worktree.ts
CHANGED
|
@@ -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
|
|
110
|
+
const { stdout } = await execFileAsyncHidden(
|
|
95
111
|
"git",
|
|
96
112
|
["worktree", "list", "--porcelain"],
|
|
97
|
-
|
|
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
|
|
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
|
|
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
|
|
280
|
+
await execFileAsyncHidden("git", ["fetch", "origin", branchNameOnly], {
|
|
261
281
|
cwd: repoRoot,
|
|
262
282
|
});
|
|
263
|
-
await
|
|
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
|
|
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
|
|
347
|
-
*
|
|
348
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
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
|
|
406
|
+
async function rmWorktreeContentsWithRetries(
|
|
425
407
|
worktreePath: string,
|
|
426
408
|
): Promise<unknown | null> {
|
|
427
|
-
let error =
|
|
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 =
|
|
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
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
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
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
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
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
if (
|
|
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
|
|
735
|
-
`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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
772
|
-
|
|
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(
|
|
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=${
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
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
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
|
931
|
+
const { stdout } = await execFileAsyncHidden(
|
|
863
932
|
"git",
|
|
864
933
|
["branch", "--list", "--format=%(refname:short)", branch],
|
|
865
934
|
{ cwd: repoRoot, encoding: "utf8" },
|