Skip to content

Windows: worktree remove fails half-way and leaves a stale row when the worktree's location has a local MCP server running #51172

Description

@Blind-Striker

Description

On Windows, DELETE /api/worktree fails for a worktree that the server has already served as a location, when the config has a local MCP server. The failure is partial. Git deletes the worktree's files and its admin directory and then fails on the root directory. opencode keeps the inventory row. Every retry then fails with Worktree directory unavailable, and POST /api/worktree/refresh does not prune the row, because the empty directory still exists.

Output from the script below (2.0.15):

remove:              400 {"name":"WorktreeError","data":{"message":"error: failed to delete 'C:/…/wts/wt': Permission denied","forceRequired":false}}
dir exists:          true entries: []
git worktree list:   1 entry (main only)
server inventory:    [{"directory":"C:\\…\\wts\\wt","strategy":"git"},{"directory":"C:\\…\\repo"}]
retry remove force:  400 {"name":"WorktreeError","data":{"message":"Worktree directory unavailable: C:\\…\\wts\\wt","forceRequired":null}}
evict location:      204
remove after evict:  204  dir exists: false

What I found:

  • A request for the worktree boots its location. The location's MCP layer starts local servers asynchronously, with cwd set to the location directory (mcp/index.ts#L503-L510, mcp/client.ts#L202). Locations have no idle TTL (location-services.ts#L57). Only the 60-minute activity sweep or debug.location.evict releases them.
  • Worktree.remove (worktree.ts#L257-L271) calls strategy.remove without releasing that location. LocationServiceMap.Service is already injected (L113), but only load uses it.
  • On Windows, a process whose working directory is inside the worktree blocks the rmdir. git worktree remove is not transactional: it deletes the files and .git/worktrees/<id>, then reports the failure on the root. The result is an empty directory, no git worktree, and a stored row, because L270 is never reached.
  • The retry fails in worktree/git.ts#L22-L26, because git.repo.discover no longer finds .git. That response carries forceRequired: null, so a client cannot tell that git already ran.
  • Evicting the location first (DELETE /api/debug/location) stops the MCP child, and the remove then succeeds (last two lines above).

Without an MCP server in the config, the same sequence removes the worktree cleanly, even with a session and a shell used inside the worktree. The MCP child is therefore the holder that I could reproduce. Other location-owned children with a cwd inside the worktree (an open PTY, for example) should hit the same path, but I did not test them. Linux and macOS allow removing another process's cwd, so I expect this to be Windows-only (not tested).

The same symptom was reported for v1/Desktop in #12690 and #19564; both were closed without a fix. #30585 proposed disposing the instance before removal on dev and was auto-closed. #50361 is the related case of subprocesses that outlive a deleted worktree root.

Suggested fix:

  1. In Worktree.remove, invalidate the worktree's location before strategy.remove, for example locations.invalidate(Location.Ref.make({ directory: worktreeDirectory })). Nested locations under the worktree need the same treatment.
  2. When git fails after it has already deleted the worktree's admin directory, drop or repair the row, or let refresh prune rows that are no longer git worktrees. Then the API can recover the state without a manual delete.

Plugins

None. The isolated config has a single stub local MCP server; any local MCP server without an absolute cwd works.

OpenCode version

2.0.15 (opencode serve). The relevant code is unchanged in v2.0.16 and on v2 at 19a9e41c28.

Steps to reproduce

  1. Start opencode serve with a config that has one enabled local MCP server.
  2. POST /api/worktree with { projectID, directory: <parent>, name: "wt" }.
  3. GET /api/location?location[directory]=<worktree>. This boots the location, which starts the MCP server with the worktree as its cwd. Wait about 2 s.
  4. DELETE /api/worktree with { projectID, directory: <worktree>, force: false }. The response is 400; the directory is empty, the git worktree is gone, and the row remains.
  5. Repeat step 4, even with force: true. The response is 400 Worktree directory unavailable.
Script (bun; Windows; starts an isolated opencode serve)
// bun worktree-remove-windows.ts   (Windows; starts an isolated `opencode serve` with one local MCP server)
import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"

const root = mkdtempSync(join(tmpdir(), "oc-wt-"))
const repo = join(root, "repo")
const git = (...args: string[]) => Bun.spawnSync(["git", ...args], { cwd: repo }).stdout.toString().trim()
Bun.spawnSync(["git", "init", "-q", "-b", "main", repo])
git("-c", "user.name=r", "-c", "user.email=r@r", "commit", "-q", "--allow-empty", "-m", "init")

// Any long-lived local MCP server works; this stub answers the handshake and idles.
const stub = join(root, "mcp-stub.js")
writeFileSync(stub, `require("readline").createInterface({ input: process.stdin }).on("line", (l) => {
  const m = JSON.parse(l); if (m.id === undefined) return
  const result = m.method === "initialize"
    ? { protocolVersion: m.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "stub", version: "1" } }
    : { tools: [], prompts: [], resources: [], resourceTemplates: [] }
  process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: m.id, result }) + "\\n")
})`)

const port = 20000 + Math.floor(Math.random() * 20000)
const base = `http://127.0.0.1:${port}`
const headers = { authorization: "Basic " + btoa("opencode:repro"), "content-type": "application/json" }
const server = Bun.spawn([process.env.OPENCODE_BIN ?? "opencode", "serve", "--hostname", "127.0.0.1", "--port", `${port}`], {
  cwd: repo,
  stdout: "ignore",
  stderr: "ignore",
  env: {
    ...process.env,
    OPENCODE_SERVER_PASSWORD: "repro",
    OPENCODE_DB: join(root, "opencode.db"),
    XDG_DATA_HOME: join(root, "data"),
    XDG_CONFIG_HOME: join(root, "config"),
    XDG_STATE_HOME: join(root, "state"),
    XDG_CACHE_HOME: join(root, "cache"),
    OPENCODE_CONFIG_CONTENT: JSON.stringify({ mcp: { stub: { type: "local", command: ["node", stub] } } }),
    OPENCODE_DISABLE_MODELS_FETCH: "1",
  },
})
const api = (path: string, init: RequestInit = {}) => fetch(base + path, { ...init, headers })
const at = (dir: string) => `location[directory]=${encodeURIComponent(dir)}`
while (!(await api("/api/info").then((r) => r.ok, () => false))) await Bun.sleep(100)

const { project } = await api(`/api/location?${at(repo)}`).then((r) => r.json())
const create = (name: string) =>
  api("/api/worktree", { method: "POST", body: JSON.stringify({ projectID: project.id, directory: join(root, "wts"), name }) })
    .then((r) => r.json())
    .then((w) => w.directory as string)
const remove = async (directory: string, force: boolean) => {
  const res = await api("/api/worktree", { method: "DELETE", body: JSON.stringify({ projectID: project.id, directory, force }) })
  return `${res.status} ${await res.text()}`
}
const inventory = () => api(`/api/worktree?projectID=${project.id}`).then((r) => r.text())

// 1. Use the worktree as a location (this starts its MCP server with cwd = worktree), then remove it.
const wt = await create("wt")
await api(`/api/location?${at(wt)}`)
await Bun.sleep(2000) // let the MCP server connect
console.log("remove:             ", await remove(wt, false))
console.log("dir exists:         ", existsSync(wt), "entries:", existsSync(wt) ? readdirSync(wt) : [])
console.log("git worktree list:  ", git("worktree", "list").split("\n").length, "entry (main only)")
console.log("server inventory:   ", await inventory())
console.log("retry remove force: ", await remove(wt, true))

// 2. Same, but evict the location first.
const wt2 = await create("wt2")
await api(`/api/location?${at(wt2)}`)
await Bun.sleep(2000)
console.log("evict location:     ", (await api(`/api/debug/location?${at(wt2)}`, { method: "DELETE" })).status)
console.log("remove after evict: ", await remove(wt2, false), "dir exists:", existsSync(wt2))

server.kill()

Screenshot and/or share link

Not applicable (HTTP API).

Operating System

Windows 11 Pro 10.0.26200, git 2.55.0.windows.2

Terminal

Not applicable (HTTP API; the script runs under bun 1.3.8).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions