From 8da5fd0a66b2b31f4d77eb8c0949c148b9a7d760 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:35:01 -0600 Subject: [PATCH] fix(app): worktree delete --- packages/opencode/src/worktree/index.ts | 81 +++++++++++++------ .../test/project/worktree-remove.test.ts | 64 +++++++++++++++ 2 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/test/project/worktree-remove.test.ts 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) + }) +})