From 069309e15d9b0c1531e18acfc0b72402e36e8f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81s=20Sainz=20de=20Aja?= Date: Thu, 30 Apr 2026 14:55:13 +0200 Subject: [PATCH] feat(rm): add branch deletion option # Current problem Removing a managed worktree always leaves its branch behind, forcing users to clean up workbox branches manually when they want the sandbox branch removed with the worktree. # Proposed solution Add an explicit --delete-branch flag to rm. Keep the default behavior of preserving branches, delete only managed branches after successful worktree removal, and map --force to force branch deletion. Cover the default, managed deletion, and unmanaged refusal paths with tests. --- README.md | 2 +- src/commands/rm.test.ts | 95 +++++++++++++++++++++++++++++++++++++++++ src/commands/rm.ts | 9 +++- src/core/git.test.ts | 64 +++++++++++++++++++++++++++ src/core/git.ts | 9 ++++ 5 files changed, 176 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 24b58f0..6611c2e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ bun add -g github:AlecRust/workbox ```sh wkb new [--from ] # create sandbox worktree -wkb rm [--force] [--unmanaged] # remove worktree (keep branch) +wkb rm [--force] [--unmanaged] [--delete-branch] # remove worktree wkb list # list workbox worktrees wkb prune # prune stale git worktree metadata wkb status [name] # show repo/worktree info and cleanliness diff --git a/src/commands/rm.test.ts b/src/commands/rm.test.ts index 8c70615..6584ec6 100644 --- a/src/commands/rm.test.ts +++ b/src/commands/rm.test.ts @@ -203,6 +203,52 @@ describe("rm command", () => { }); }); + it("deletes managed branches with --delete-branch", async () => { + await withRepo(async (repoRoot) => { + const worktreesDir = join(repoRoot, ".workbox", "worktrees"); + const branchPrefix = "wkb/"; + + const config: ResolvedWorkboxConfig = { + worktrees: { + directory: worktreesDir, + branch_prefix: branchPrefix, + base_ref: "HEAD", + }, + bootstrap: { + enabled: false, + steps: [], + }, + }; + + await createWorktree({ + repoRoot, + worktreesDir, + branchPrefix, + baseRef: "HEAD", + name: "box1", + }); + + const context = { + cwd: repoRoot, + repoRoot, + worktreeRoot: repoRoot, + config, + configPath: join(repoRoot, "workbox.toml"), + flags: { + help: false, + json: false, + nonInteractive: false, + }, + }; + + const result = await rmCommand.run(context, ["box1", "--delete-branch"]); + expect(result.message).toContain("deleted branch wkb/box1"); + expect(await gitSucceeds(["show-ref", "--verify", "refs/heads/wkb/box1"], repoRoot)).toBe( + false + ); + }); + }); + it("refuses to remove unmanaged worktrees unless --unmanaged is provided", async () => { await withRepo(async (repoRoot) => { const worktreesDir = join(repoRoot, ".workbox", "worktrees"); @@ -252,4 +298,53 @@ describe("rm command", () => { ); }); }); + + it("refuses to delete branches for unmanaged worktrees", async () => { + await withRepo(async (repoRoot) => { + const worktreesDir = join(repoRoot, ".workbox", "worktrees"); + const branchPrefix = "wkb/"; + + const config: ResolvedWorkboxConfig = { + worktrees: { + directory: worktreesDir, + branch_prefix: branchPrefix, + base_ref: "HEAD", + }, + bootstrap: { + enabled: false, + steps: [], + }, + }; + + const created = await createWorktree({ + repoRoot, + worktreesDir, + branchPrefix, + baseRef: "HEAD", + name: "box1", + }); + + await runGit(["checkout", "--detach"], created.path); + + const context = { + cwd: repoRoot, + repoRoot, + worktreeRoot: repoRoot, + config, + configPath: join(repoRoot, "workbox.toml"), + flags: { + help: false, + json: false, + nonInteractive: true, + }, + }; + + await expect( + rmCommand.run(context, ["box1", "--unmanaged", "--delete-branch"]) + ).rejects.toThrow(/unmanaged worktree/); + expect(await gitSucceeds(["show-ref", "--verify", "refs/heads/wkb/box1"], repoRoot)).toBe( + true + ); + }); + }); }); diff --git a/src/commands/rm.ts b/src/commands/rm.ts index 47cf4d2..414dc07 100644 --- a/src/commands/rm.ts +++ b/src/commands/rm.ts @@ -7,13 +7,14 @@ export const rmCommand: CommandDefinition = { name: "rm", summary: "Remove a sandbox worktree", description: "Remove a workbox sandbox worktree by name.", - usage: "workbox rm [--force] [--unmanaged]", + usage: "workbox rm [--force] [--unmanaged] [--delete-branch]", run: async (context, args) => { const parsed = parseArgsOrUsage({ args, options: { force: { type: "boolean" }, unmanaged: { type: "boolean" }, + "delete-branch": { type: "boolean" }, }, allowPositionals: true, strict: true, @@ -49,10 +50,14 @@ export const rmCommand: CommandDefinition = { branchPrefix: context.config.worktrees.branch_prefix, name, force: parsed.values.force === true, + deleteBranch: parsed.values["delete-branch"] === true, }); return { - message: `Removed worktree "${worktree.name}" at ${worktree.path}. No branches were deleted.`, + message: + parsed.values["delete-branch"] === true + ? `Removed worktree "${worktree.name}" at ${worktree.path} and deleted branch ${worktree.managedBranch}.` + : `Removed worktree "${worktree.name}" at ${worktree.path}. No branches were deleted.`, data: worktree, }; }, diff --git a/src/core/git.test.ts b/src/core/git.test.ts index 88dc0c0..34ce981 100644 --- a/src/core/git.test.ts +++ b/src/core/git.test.ts @@ -99,6 +99,7 @@ describe("core/git worktrees", () => { branchPrefix, name: "box1", force: false, + deleteBranch: false, }); expect(await gitSucceeds(["show-ref", "--verify", "refs/heads/wkb/box1"], repoRoot)).toBe( @@ -108,6 +109,35 @@ describe("core/git worktrees", () => { }); }); + it("removes a managed worktree and deletes its branch when requested", async () => { + await withRepo(async (repoRoot) => { + const worktreesDir = join(repoRoot, ".workbox", "worktrees"); + const branchPrefix = "wkb/"; + + await createWorktree({ + repoRoot, + worktreesDir, + branchPrefix, + baseRef: "HEAD", + name: "box1", + }); + + await removeWorktree({ + repoRoot, + worktreesDir, + branchPrefix, + name: "box1", + force: false, + deleteBranch: true, + }); + + expect(await gitSucceeds(["show-ref", "--verify", "refs/heads/wkb/box1"], repoRoot)).toBe( + false + ); + expect(await getManagedWorktrees({ repoRoot, worktreesDir, branchPrefix })).toEqual([]); + }); + }); + it("can remove a detached worktree in the workbox directory", async () => { await withRepo(async (repoRoot) => { const worktreesDir = join(repoRoot, ".workbox", "worktrees"); @@ -136,6 +166,7 @@ describe("core/git worktrees", () => { branchPrefix, name: "box1", force: false, + deleteBranch: false, }); expect(await gitSucceeds(["show-ref", "--verify", "refs/heads/wkb/box1"], repoRoot)).toBe( @@ -145,6 +176,39 @@ describe("core/git worktrees", () => { }); }); + it("refuses to delete a branch for an unmanaged worktree", async () => { + await withRepo(async (repoRoot) => { + const worktreesDir = join(repoRoot, ".workbox", "worktrees"); + const branchPrefix = "wkb/"; + + const created = await createWorktree({ + repoRoot, + worktreesDir, + branchPrefix, + baseRef: "HEAD", + name: "box1", + }); + + await runGit(["checkout", "--detach"], created.path); + + await expect( + removeWorktree({ + repoRoot, + worktreesDir, + branchPrefix, + name: "box1", + force: false, + deleteBranch: true, + }) + ).rejects.toThrow(/unmanaged worktree/); + + expect(await gitSucceeds(["show-ref", "--verify", "refs/heads/wkb/box1"], repoRoot)).toBe( + true + ); + expect(await getWorkboxWorktrees({ repoRoot, worktreesDir, branchPrefix })).toHaveLength(1); + }); + }); + it("rejects a worktreesDir that escapes the repo via symlink", async () => { await withRepo(async (repoRoot) => { const outside = await mkdtemp(join(tmpdir(), "workbox-outside-")); diff --git a/src/core/git.ts b/src/core/git.ts index 6ab45fb..5540e7d 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -270,6 +270,7 @@ export const removeWorktree = async (input: { branchPrefix: string; name: string; force: boolean; + deleteBranch: boolean; }): Promise => { await assertWorktreesDirSafe(input.repoRoot, input.worktreesDir); const worktree = await getWorkboxWorktree({ @@ -279,11 +280,19 @@ export const removeWorktree = async (input: { name: input.name, }); + if (input.deleteBranch && !worktree.managed) { + throw new CliError(`Refusing to delete a branch for unmanaged worktree "${input.name}".`); + } + await runGit( ["worktree", "remove", ...(input.force ? ["--force"] : []), "--", worktree.path], input.repoRoot ); + if (input.deleteBranch) { + await runGit(["branch", input.force ? "-D" : "-d", worktree.managedBranch], input.repoRoot); + } + return worktree; };