summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-12 19:35:01 -0600
committerAdam <[email protected]>2026-02-12 19:38:13 -0600
commit8da5fd0a66b2b31f4d77eb8c0949c148b9a7d760 (patch)
treece577077eac144aa82907ffca71ccc97d8931463
parent0303c29e3ff4f45aff4176e496ecb3f5fa5b611a (diff)
downloadopencode-8da5fd0a66b2b31f4d77eb8c0949c148b9a7d760.tar.gz
opencode-8da5fd0a66b2b31f4d77eb8c0949c148b9a7d760.zip
fix(app): worktree delete
-rw-r--r--packages/opencode/src/worktree/index.ts81
-rw-r--r--packages/opencode/test/project/worktree-remove.test.ts64
2 files changed, 119 insertions, 26 deletions
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 88c778cbb..85d7f6d0e 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -420,49 +420,78 @@ export namespace Worktree {
}
const directory = await canonical(input.directory)
+ const locate = async (stdout: Uint8Array | undefined) => {
+ const lines = outputText(stdout)
+ .split("\n")
+ .map((line) => line.trim())
+ const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
+ if (!line) return acc
+ if (line.startsWith("worktree ")) {
+ acc.push({ path: line.slice("worktree ".length).trim() })
+ return acc
+ }
+ const current = acc[acc.length - 1]
+ if (!current) return acc
+ if (line.startsWith("branch ")) {
+ current.branch = line.slice("branch ".length).trim()
+ }
+ return acc
+ }, [])
+
+ return (async () => {
+ for (const item of entries) {
+ if (!item.path) continue
+ const key = await canonical(item.path)
+ if (key === directory) return item
+ }
+ })()
+ }
+
+ const clean = (target: string) =>
+ fs
+ .rm(target, {
+ recursive: true,
+ force: true,
+ maxRetries: 5,
+ retryDelay: 100,
+ })
+ .catch((error) => {
+ const message = error instanceof Error ? error.message : String(error)
+ throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
+ })
+
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
- const lines = outputText(list.stdout)
- .split("\n")
- .map((line) => line.trim())
- const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
- if (!line) return acc
- if (line.startsWith("worktree ")) {
- acc.push({ path: line.slice("worktree ".length).trim() })
- return acc
- }
- const current = acc[acc.length - 1]
- if (!current) return acc
- if (line.startsWith("branch ")) {
- current.branch = line.slice("branch ".length).trim()
- }
- return acc
- }, [])
-
- const entry = await (async () => {
- for (const item of entries) {
- if (!item.path) continue
- const key = await canonical(item.path)
- if (key === directory) return item
- }
- })()
+ const entry = await locate(list.stdout)
if (!entry?.path) {
const directoryExists = await exists(directory)
if (directoryExists) {
- await fs.rm(directory, { recursive: true, force: true })
+ await clean(directory)
}
return true
}
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
if (removed.exitCode !== 0) {
- throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
+ const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
+ if (next.exitCode !== 0) {
+ throw new RemoveFailedError({
+ message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
+ })
+ }
+
+ const stale = await locate(next.stdout)
+ if (stale?.path) {
+ throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
+ }
}
+ await clean(entry.path)
+
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts
new file mode 100644
index 000000000..32d38fe84
--- /dev/null
+++ b/packages/opencode/test/project/worktree-remove.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, test } from "bun:test"
+import { $ } from "bun"
+import fs from "fs/promises"
+import path from "path"
+import { Instance } from "../../src/project/instance"
+import { Worktree } from "../../src/worktree"
+import { tmpdir } from "../fixture/fixture"
+
+describe("Worktree.remove", () => {
+ test("continues when git remove exits non-zero after detaching", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const root = tmp.path
+ const name = `remove-regression-${Date.now().toString(36)}`
+ const branch = `opencode/${name}`
+ const dir = path.join(root, "..", name)
+
+ await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
+ await $`git reset --hard`.cwd(dir).quiet()
+
+ const real = (await $`which git`.quiet().text()).trim()
+ expect(real).toBeTruthy()
+
+ const bin = path.join(root, "bin")
+ const shim = path.join(bin, "git")
+ await fs.mkdir(bin, { recursive: true })
+ await Bun.write(
+ shim,
+ [
+ "#!/bin/bash",
+ `REAL_GIT=${JSON.stringify(real)}`,
+ 'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
+ ' "$REAL_GIT" "$@" >/dev/null 2>&1',
+ ' echo "fatal: failed to remove worktree: Directory not empty" >&2',
+ " exit 1",
+ "fi",
+ 'exec "$REAL_GIT" "$@"',
+ ].join("\n"),
+ )
+ await fs.chmod(shim, 0o755)
+
+ const prev = process.env.PATH ?? ""
+ process.env.PATH = `${bin}${path.delimiter}${prev}`
+
+ const ok = await (async () => {
+ try {
+ return await Instance.provide({
+ directory: root,
+ fn: () => Worktree.remove({ directory: dir }),
+ })
+ } finally {
+ process.env.PATH = prev
+ }
+ })()
+
+ expect(ok).toBe(true)
+ expect(await Bun.file(dir).exists()).toBe(false)
+
+ const list = await $`git worktree list --porcelain`.cwd(root).quiet().text()
+ expect(list).not.toContain(`worktree ${dir}`)
+
+ const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
+ expect(ref.exitCode).not.toBe(0)
+ })
+})