// 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()
Description
On Windows,
DELETE /api/worktreefails 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 withWorktree directory unavailable, andPOST /api/worktree/refreshdoes not prune the row, because the empty directory still exists.Output from the script below (2.0.15):
What I found:
cwdset 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 ordebug.location.evictreleases them.Worktree.remove(worktree.ts#L257-L271) callsstrategy.removewithout releasing that location.LocationServiceMap.Serviceis already injected (L113), but onlyloaduses it.git worktree removeis 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.git.repo.discoverno longer finds.git. That response carriesforceRequired: null, so a client cannot tell that git already ran.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
devand was auto-closed. #50361 is the related case of subprocesses that outlive a deleted worktree root.Suggested fix:
Worktree.remove, invalidate the worktree's location beforestrategy.remove, for examplelocations.invalidate(Location.Ref.make({ directory: worktreeDirectory })). Nested locations under the worktree need the same treatment.refreshprune 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
localMCP server; any local MCP server without an absolutecwdworks.OpenCode version
2.0.15 (
opencode serve). The relevant code is unchanged in v2.0.16 and onv2at19a9e41c28.Steps to reproduce
opencode servewith a config that has one enabledlocalMCP server.POST /api/worktreewith{ projectID, directory: <parent>, name: "wt" }.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.DELETE /api/worktreewith{ projectID, directory: <worktree>, force: false }. The response is 400; the directory is empty, the git worktree is gone, and the row remains.force: true. The response is 400Worktree directory unavailable.Script (bun; Windows; starts an isolated
opencode serve)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).