Skip to content

Commit 8da5fd0

Browse files
committed
fix(app): worktree delete
1 parent 0303c29 commit 8da5fd0

2 files changed

Lines changed: 119 additions & 26 deletions

File tree

packages/opencode/src/worktree/index.ts

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -420,49 +420,78 @@ export namespace Worktree {
420420
}
421421

422422
const directory = await canonical(input.directory)
423+
const locate = async (stdout: Uint8Array | undefined) => {
424+
const lines = outputText(stdout)
425+
.split("\n")
426+
.map((line) => line.trim())
427+
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
428+
if (!line) return acc
429+
if (line.startsWith("worktree ")) {
430+
acc.push({ path: line.slice("worktree ".length).trim() })
431+
return acc
432+
}
433+
const current = acc[acc.length - 1]
434+
if (!current) return acc
435+
if (line.startsWith("branch ")) {
436+
current.branch = line.slice("branch ".length).trim()
437+
}
438+
return acc
439+
}, [])
440+
441+
return (async () => {
442+
for (const item of entries) {
443+
if (!item.path) continue
444+
const key = await canonical(item.path)
445+
if (key === directory) return item
446+
}
447+
})()
448+
}
449+
450+
const clean = (target: string) =>
451+
fs
452+
.rm(target, {
453+
recursive: true,
454+
force: true,
455+
maxRetries: 5,
456+
retryDelay: 100,
457+
})
458+
.catch((error) => {
459+
const message = error instanceof Error ? error.message : String(error)
460+
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
461+
})
462+
423463
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
424464
if (list.exitCode !== 0) {
425465
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
426466
}
427467

428-
const lines = outputText(list.stdout)
429-
.split("\n")
430-
.map((line) => line.trim())
431-
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
432-
if (!line) return acc
433-
if (line.startsWith("worktree ")) {
434-
acc.push({ path: line.slice("worktree ".length).trim() })
435-
return acc
436-
}
437-
const current = acc[acc.length - 1]
438-
if (!current) return acc
439-
if (line.startsWith("branch ")) {
440-
current.branch = line.slice("branch ".length).trim()
441-
}
442-
return acc
443-
}, [])
444-
445-
const entry = await (async () => {
446-
for (const item of entries) {
447-
if (!item.path) continue
448-
const key = await canonical(item.path)
449-
if (key === directory) return item
450-
}
451-
})()
468+
const entry = await locate(list.stdout)
452469

453470
if (!entry?.path) {
454471
const directoryExists = await exists(directory)
455472
if (directoryExists) {
456-
await fs.rm(directory, { recursive: true, force: true })
473+
await clean(directory)
457474
}
458475
return true
459476
}
460477

461478
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
462479
if (removed.exitCode !== 0) {
463-
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
480+
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
481+
if (next.exitCode !== 0) {
482+
throw new RemoveFailedError({
483+
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
484+
})
485+
}
486+
487+
const stale = await locate(next.stdout)
488+
if (stale?.path) {
489+
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
490+
}
464491
}
465492

493+
await clean(entry.path)
494+
466495
const branch = entry.branch?.replace(/^refs\/heads\//, "")
467496
if (branch) {
468497
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { $ } from "bun"
3+
import fs from "fs/promises"
4+
import path from "path"
5+
import { Instance } from "../../src/project/instance"
6+
import { Worktree } from "../../src/worktree"
7+
import { tmpdir } from "../fixture/fixture"
8+
9+
describe("Worktree.remove", () => {
10+
test("continues when git remove exits non-zero after detaching", async () => {
11+
await using tmp = await tmpdir({ git: true })
12+
const root = tmp.path
13+
const name = `remove-regression-${Date.now().toString(36)}`
14+
const branch = `opencode/${name}`
15+
const dir = path.join(root, "..", name)
16+
17+
await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
18+
await $`git reset --hard`.cwd(dir).quiet()
19+
20+
const real = (await $`which git`.quiet().text()).trim()
21+
expect(real).toBeTruthy()
22+
23+
const bin = path.join(root, "bin")
24+
const shim = path.join(bin, "git")
25+
await fs.mkdir(bin, { recursive: true })
26+
await Bun.write(
27+
shim,
28+
[
29+
"#!/bin/bash",
30+
`REAL_GIT=${JSON.stringify(real)}`,
31+
'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
32+
' "$REAL_GIT" "$@" >/dev/null 2>&1',
33+
' echo "fatal: failed to remove worktree: Directory not empty" >&2',
34+
" exit 1",
35+
"fi",
36+
'exec "$REAL_GIT" "$@"',
37+
].join("\n"),
38+
)
39+
await fs.chmod(shim, 0o755)
40+
41+
const prev = process.env.PATH ?? ""
42+
process.env.PATH = `${bin}${path.delimiter}${prev}`
43+
44+
const ok = await (async () => {
45+
try {
46+
return await Instance.provide({
47+
directory: root,
48+
fn: () => Worktree.remove({ directory: dir }),
49+
})
50+
} finally {
51+
process.env.PATH = prev
52+
}
53+
})()
54+
55+
expect(ok).toBe(true)
56+
expect(await Bun.file(dir).exists()).toBe(false)
57+
58+
const list = await $`git worktree list --porcelain`.cwd(root).quiet().text()
59+
expect(list).not.toContain(`worktree ${dir}`)
60+
61+
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
62+
expect(ref.exitCode).not.toBe(0)
63+
})
64+
})

0 commit comments

Comments
 (0)