← reports

#2724 · Managed worktree removal retains a local branch

Bug Medium Effort: Low workspaces open on GitHub 2026-08-30 · base f4bbc2fe81a9

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

ClaimStatusEvidence
The managed checkout disappears.VerifiedThe focused test completed removeWorktree before it inspected the source repository.
The generated local branch remains.VerifiedBoth runs returned managed-cleanup from git branch --list.
The direct host-workspace path is sufficient.VerifiedThe test used only createWorktree and removeWorktree. It started no server or provider.
The documented cleanup includes the branch.VerifiedThe 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

4. Minimal reproduction

  1. Check out the trusted base commit.
  2. Add the test below as packages/host-workspace/test/managed-worktree-removal.test.ts.
  3. 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.