Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ bun add -g github:AlecRust/workbox

```sh
wkb new <name> [--from <ref>] # create sandbox worktree
wkb rm <name> [--force] [--unmanaged] # remove worktree (keep branch)
wkb rm <name> [--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
Expand Down
95 changes: 95 additions & 0 deletions src/commands/rm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
);
});
});
});
9 changes: 7 additions & 2 deletions src/commands/rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [--force] [--unmanaged]",
usage: "workbox rm <name> [--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,
Expand Down Expand Up @@ -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,
};
},
Expand Down
64 changes: 64 additions & 0 deletions src/core/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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");
Expand Down Expand Up @@ -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(
Expand All @@ -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-"));
Expand Down
9 changes: 9 additions & 0 deletions src/core/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export const removeWorktree = async (input: {
branchPrefix: string;
name: string;
force: boolean;
deleteBranch: boolean;
}): Promise<WorktreeInfo> => {
await assertWorktreesDirSafe(input.repoRoot, input.worktreesDir);
const worktree = await getWorkboxWorktree({
Expand All @@ -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;
};

Expand Down