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
147 changes: 147 additions & 0 deletions src/application/use-cases/delete-branch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test";
import { createFakeGit } from "../../test-utils/fake-git.ts";
import { deleteBranch } from "./delete-branch.ts";

describe("deleteBranch", () => {
test("clean delete → deleted, remote skipped", async () => {
const git = createFakeGit({
branches: ["main", "feature"],
mergedBranches: ["feature"],
});
const outcome = await deleteBranch({ branch: "feature", force: false, deleteRemote: false }, { git });
expect(outcome).toEqual({ status: "deleted", remote: { status: "skipped" } });
});

test("not-merged + force=false → not-merged (no force fallback)", async () => {
const git = createFakeGit({
branches: ["main", "feature"],
// not in mergedBranches → deleteBranch returns BRANCH_NOT_MERGED
});
const outcome = await deleteBranch({ branch: "feature", force: false, deleteRemote: false }, { git });
expect(outcome).toEqual({ status: "not-merged" });

// force-delete must NOT have been invoked — branch still listable
const branches = await git.listBranches();
expect(branches.success && branches.data.includes("feature")).toBe(true);
});

test("not-merged + force=true → deleted (force fallback)", async () => {
const git = createFakeGit({
branches: ["main", "feature"],
});
const outcome = await deleteBranch({ branch: "feature", force: true, deleteRemote: false }, { git });
expect(outcome).toEqual({ status: "deleted", remote: { status: "skipped" } });

const branches = await git.listBranches();
expect(branches.success && branches.data.includes("feature")).toBe(false);
});

test("delete fails with non-NOT_MERGED error → failed with message", async () => {
const git = createFakeGit({
branches: ["main"], // "feature" missing → BRANCH_NOT_FOUND
});
const outcome = await deleteBranch({ branch: "feature", force: false, deleteRemote: false }, { git });
expect(outcome.status).toBe("failed");
if (outcome.status === "failed") {
expect(outcome.message).toContain("feature");
}
});

test("delete fails with non-NOT_MERGED error → failed even when force=true", async () => {
const git = createFakeGit({
branches: ["main"], // BRANCH_NOT_FOUND is NOT recoverable via force
});
const outcome = await deleteBranch({ branch: "ghost", force: true, deleteRemote: false }, { git });
// deleteBranch returns BRANCH_NOT_FOUND, which is not BRANCH_NOT_MERGED,
// so force fallback must NOT be attempted.
expect(outcome.status).toBe("failed");
});

test("force fallback fails → failed with force error message", async () => {
// "feature" is not merged, so the normal delete returns BRANCH_NOT_MERGED and
// triggers the force fallback. Stub deleteBranchForce to fail so we exercise the
// "force fallback itself failed → failed" path.
const baseGit = createFakeGit({ branches: ["main", "feature"] });
const git = {
...baseGit,
async deleteBranchForce(_branch: string) {
return { success: false as const, error: { code: "UNKNOWN" as const, message: "force boom" } };
},
};
const outcome = await deleteBranch({ branch: "feature", force: true, deleteRemote: false }, { git });
expect(outcome).toEqual({ status: "failed", message: "force boom" });
});

test("deleted + remote delete requested + remote ref exists → remote=deleted", async () => {
const git = createFakeGit({
branches: ["main", "feature"],
mergedBranches: ["feature"],
remoteBranches: ["feature"],
});
const outcome = await deleteBranch({ branch: "feature", force: false, deleteRemote: true }, { git });
expect(outcome).toEqual({ status: "deleted", remote: { status: "deleted" } });
});

test("deleted + remote delete requested + remote ref missing → remote=not-found", async () => {
const git = createFakeGit({
branches: ["main", "feature"],
mergedBranches: ["feature"],
remoteBranches: [], // empty, but state-tracking is enabled because the option is provided
});
const outcome = await deleteBranch({ branch: "feature", force: false, deleteRemote: true }, { git });
expect(outcome).toEqual({ status: "deleted", remote: { status: "not-found" } });
});

test("deleted + remote delete fails with other error → remote=failed", async () => {
const git = createFakeGit({
branches: ["main", "feature"],
mergedBranches: ["feature"],
deleteRemoteBranchFail: { code: "UNKNOWN", message: "network down" },
});
const outcome = await deleteBranch({ branch: "feature", force: false, deleteRemote: true }, { git });
expect(outcome.status).toBe("deleted");
if (outcome.status === "deleted") {
expect(outcome.remote).toEqual({ status: "failed", message: "network down" });
}
});

test("force fallback + remote delete → deleted + remote=deleted", async () => {
const git = createFakeGit({
branches: ["main", "feature"],
// not merged → force fallback
remoteBranches: ["feature"],
});
const outcome = await deleteBranch({ branch: "feature", force: true, deleteRemote: true }, { git });
expect(outcome).toEqual({ status: "deleted", remote: { status: "deleted" } });
});

test("not-merged outcome must not attempt remote deletion", async () => {
let remoteCalls = 0;
const baseGit = createFakeGit({ branches: ["main", "feature"] });
const git = {
...baseGit,
async deleteRemoteBranch(branch: string, remote?: string) {
remoteCalls += 1;
return baseGit.deleteRemoteBranch(branch, remote);
},
};
const outcome = await deleteBranch({ branch: "feature", force: false, deleteRemote: true }, { git });
expect(outcome).toEqual({ status: "not-merged" });
expect(remoteCalls).toBe(0);
});

test("failed outcome must not attempt remote deletion", async () => {
let remoteCalls = 0;
const baseGit = createFakeGit({ branches: ["main"] });
const git = {
...baseGit,
async deleteRemoteBranch(branch: string, remote?: string) {
remoteCalls += 1;
return baseGit.deleteRemoteBranch(branch, remote);
},
};
const outcome = await deleteBranch({ branch: "ghost", force: false, deleteRemote: true }, { git });
expect(outcome.status).toBe("failed");
expect(remoteCalls).toBe(0);
});
});
78 changes: 78 additions & 0 deletions src/application/use-cases/delete-branch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { GitPort } from "../../domain/ports/git-port.ts";
import { Result } from "../../shared/result.ts";

/**
* Result of attempting to delete the remote-tracking branch, when requested.
*
* - `deleted` — remote ref was removed successfully
* - `not-found` — remote returned REMOTE_REF_NOT_FOUND (treated as success: nothing to delete)
* - `failed` — any other remote failure (rendered as a warning in the CLI)
* - `skipped` — caller did not request remote deletion
*/
export type RemoteDeletionOutcome =
| { status: "deleted" }
| { status: "not-found" }
| { status: "failed"; message: string }
| { status: "skipped" };

/**
* Typed outcome of the delete-branch policy.
*
* - `deleted` — local branch was removed (either by the normal delete or by
* the force fallback). `remote` carries the remote-deletion
* sub-outcome.
* - `not-merged` — `deleteBranch` reported BRANCH_NOT_MERGED and the caller did
* not request force; no remote deletion was attempted.
* - `failed` — local deletion failed for any other reason, including a force
* fallback that itself failed. `message` is the git error message.
*/
export type DeleteBranchOutcome =
| { status: "deleted"; remote: RemoteDeletionOutcome }
| { status: "not-merged" }
| { status: "failed"; message: string };

export interface DeleteBranchInput {
branch: string;
/** When true, fall back to deleteBranchForce on BRANCH_NOT_MERGED. */
force: boolean;
/** When true, attempt to delete the remote-tracking branch after the local delete. */
deleteRemote: boolean;
}

export interface DeleteBranchDeps {
git: GitPort;
}

export async function deleteBranch(input: DeleteBranchInput, deps: DeleteBranchDeps): Promise<DeleteBranchOutcome> {
const { git } = deps;
const { branch, force, deleteRemote } = input;

const deleteResult = await git.deleteBranch(branch);

if (Result.isErr(deleteResult)) {
if (deleteResult.error.code !== "BRANCH_NOT_MERGED") {
return { status: "failed", message: deleteResult.error.message };
}
if (!force) {
return { status: "not-merged" };
}
const forceResult = await git.deleteBranchForce(branch);
if (Result.isErr(forceResult)) {
return { status: "failed", message: forceResult.error.message };
}
}

const remote = deleteRemote ? await deleteRemoteBranch(branch, git) : ({ status: "skipped" } as const);
return { status: "deleted", remote };
}

async function deleteRemoteBranch(branch: string, git: GitPort): Promise<RemoteDeletionOutcome> {
const remoteResult = await git.deleteRemoteBranch(branch);
if (Result.isOk(remoteResult)) {
return { status: "deleted" };
}
if (remoteResult.error.code === "REMOTE_REF_NOT_FOUND") {
return { status: "not-found" };
}
return { status: "failed", message: remoteResult.error.message };
}
61 changes: 59 additions & 2 deletions src/cli/commands/remove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,21 @@ interface FakeMultiSpinnerLog {
fail: MultiSpinnerCall[];
}

interface FakeSpinnerLog {
start: string[];
message: string[];
stop: string[];
}

function createFakeUi(opts: { nonInteractive?: boolean; confirm?: boolean; multiselectResult?: string[] } = {}): {
ui: UiPort;
log: FakeUiLog;
multiSpinnerLog: FakeMultiSpinnerLog;
spinnerLog: FakeSpinnerLog;
} {
const log: FakeUiLog = { info: [], success: [], warn: [], error: [], outro: [] };
const multiSpinnerLog: FakeMultiSpinnerLog = { complete: [], fail: [] };
const spinnerLog: FakeSpinnerLog = { start: [], message: [], stop: [] };

const ui = {
nonInteractive: opts.nonInteractive ?? false,
Expand All @@ -57,7 +65,17 @@ function createFakeUi(opts: { nonInteractive?: boolean; confirm?: boolean; multi
return fn();
},
createSpinner() {
return { start() {}, message() {}, stop() {} };
return {
start(message: string) {
spinnerLog.start.push(message);
},
message(message: string) {
spinnerLog.message.push(message);
},
stop(message?: string) {
if (message !== undefined) spinnerLog.stop.push(message);
},
};
},
createMultiSpinner(_keys: string[]) {
return {
Expand Down Expand Up @@ -89,7 +107,7 @@ function createFakeUi(opts: { nonInteractive?: boolean; confirm?: boolean; multi
cancel() {},
} satisfies UiPort;

return { ui, log, multiSpinnerLog };
return { ui, log, multiSpinnerLog, spinnerLog };
}

function buildContainer(
Expand Down Expand Up @@ -251,3 +269,42 @@ describe("remove — multi path branch delete failures", () => {
expect(code).toBe(0);
});
});

describe("remove — single path force on unmerged branch", () => {
// Regression for the branch-deletion-policy extraction: with --force/--yes on an
// unmerged branch the CLI must still surface the "not merged" warning and the
// "Force deleting" step before deleting, not silently force on the first attempt.
test("--yes on unmerged branch → shows 'not merged' + 'Force deleting', then deletes", async () => {
const fs = createFakeFilesystem({
files: { [`${ROOT}/${CONFIG_FILENAME}`]: JSON.stringify({ rootDir: ".worktrees" }) },
directories: [ROOT, `${ROOT}/.worktrees`, featureWt.path],
});
const git = createFakeGit({
root: ROOT,
mainRoot: ROOT,
worktrees: [mainWt, featureWt],
branches: ["main", "feature"],
mergedBranches: [], // "feature" not merged → BRANCH_NOT_MERGED on the normal delete
});
const { ui, spinnerLog } = createFakeUi();
const container = buildContainer(ui, git, fs);

const code = await runRemove(container, {
branch: "feature",
"delete-branch": true,
"delete-remote-branch": false,
yes: true,
force: false,
"dry-run": false,
});

expect(spinnerLog.stop.some((m) => m.includes("not merged"))).toBe(true);
expect(spinnerLog.start.some((m) => m.includes("Force deleting"))).toBe(true);
expect(spinnerLog.stop.some((m) => m.includes("deleted (local)"))).toBe(true);

const branches = await git.listBranches();
expect(branches.success && branches.data.includes("feature")).toBe(false);

expect(code).toBe(0);
});
});
Loading