#2724 · Managed worktree removal retains a local branch
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
A managed worktree gets a new local branch during creation. Removal deletes the checkout and its worktree metadata. Removal does not delete the branch ref. Each removed managed worktree can therefore leave one local branch. Two clean runs at the trusted base commit produced the same result.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| The managed checkout disappears. | Verified | The focused test completed removeWorktree before it inspected the source repository. |
| The generated local branch remains. | Verified | Both runs returned managed-cleanup from git branch --list. |
| The direct host-workspace path is sufficient. | Verified | The test used only createWorktree and removeWorktree. It started no server or provider. |
| The documented cleanup includes the branch. | Verified | The trusted worktree guide states that cleanup removes the worktree and its branch. |
The issue data was untrusted. The investigation did not run its commands or open its links.
3. Environment
- Repository:
get-bb/bbatf4bbc2fe81a9b7639ff9a7396e172bddd89109e4. - Host: macOS 26.6.1 on arm64.
- Node.js: 22.22.3. Git: 2.50.1.
- No server, provider, port, or bb data directory was used.
- The second run used a separate clean clone and dependency tree.
4. Minimal reproduction
- Check out the trusted base commit.
- Add the test below as
packages/host-workspace/test/managed-worktree-removal.test.ts. - Run this command from the repository root.
pnpm exec turbo run test --filter=@bb/host-workspace --force -- managed-worktree-removal.test.ts
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { runGit } from "../src/git.js";
import { createWorktree, removeWorktree } from "../src/provisioning.js";
const tempDirs: string[] = [];
async function makeTempDir(prefix: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(
tempDirs
.splice(0)
.map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe("managed worktree removal", () => {
it("removes the local branch", async () => {
const sourceRepo = await makeTempDir("bb-remove-branch-repo-");
const parentDir = await makeTempDir("bb-remove-branch-parent-");
const targetPath = path.join(parentDir, "feature");
const branchName = "managed-cleanup";
await runGit(["init", "-b", "main"], { cwd: sourceRepo });
await runGit(["config", "user.name", "BB Tests"], { cwd: sourceRepo });
await runGit(["config", "user.email", "user@example.com"], {
cwd: sourceRepo,
});
await fs.writeFile(path.join(sourceRepo, "README.md"), "hello\n", "utf8");
await runGit(["add", "."], { cwd: sourceRepo });
await runGit(["commit", "-m", "Initial commit"], { cwd: sourceRepo });
await createWorktree({
sourcePath: sourceRepo,
targetPath,
branchName,
baseBranch: "main",
timeoutMs: 900000,
});
await removeWorktree({ path: targetPath, timeoutMs: 900000, force: true });
const branches = await runGit(["branch", "--list", branchName], {
cwd: sourceRepo,
});
expect(branches.stdout.trim()).toBe("");
});
});
Expected result
Test Files 1 passed (1) Tests 1 passed (1)
Actual result in both clean runs
FAIL test/managed-worktree-removal.test.ts AssertionError: expected 'managed-cleanup' to be '' Expected: "" Received: "managed-cleanup" Test Files 1 failed (1) Tests 1 failed (1)
5. Root cause
createWorktree passes -B and the new branch name to git worktree add. This creates or resets a local branch ref.
removeWorktree resolves the shared Git directory and runs only git worktree remove. It then deletes the remaining directory. No path reads or deletes the symbolic branch ref.
The metadata operation cannot delete the branch because Git treats a worktree and a branch ref as separate objects. The observed checkout removal and retained ref follow directly from this missing operation.
6. Proposed fix (first principles)
Read the symbolic branch before removal. Remove the worktree under the existing metadata lock. Delete that exact local branch only after Git reports successful worktree removal. Use the existing ref mutation lock. Keep a detached checkout as a no-op, and keep branch deletion failure non-fatal.
7. Related issues
The issue has no linked pull request. A review of recent workspaces issues found no direct duplicate for this cleanup path.
8. Verification
The first run used the primary clean worktree at the trusted base commit. The second run used a separate clone at the same full commit. Each checkout completed a frozen install and a full Turbo build. Each checkout then ran the same focused Turbo test. Both tests failed with Received: "managed-cleanup". No report correction was necessary.
9. Appendix
Commands
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/host-workspace --force -- managed-worktree-removal.test.ts
Build results
Primary checkout: 18 successful tasks. Second checkout: 18 successful tasks.