Explorar el Código

fix(app): worktree delete

Adam hace 2 meses
padre
commit
8da5fd0a66

+ 55 - 26
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)

+ 64 - 0
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)
+  })
+})