mod common;
use common::{FakeProvider, TestRepo};
use predicates::prelude::PredicateBooleanExt;
const MERGED_A: &str = r##"[{"number":12,"state":"MERGED","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/lararosekelley/git-stk/pull/12"}]"##;
const OPEN_B: &str = r##"[{"number":13,"state":"OPEN","baseRefName":"feature/a","headRefName":"feature/b","url":"https://github.com/lararosekelley/git-stk/pull/13"}]"##;
const CLOSED_A: &str = r##"[{"number":12,"state":"CLOSED","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/lararosekelley/git-stk/pull/12"}]"##;
const MERGED_A_OWNER: &str = r##"[{"number":12,"state":"MERGED","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"##;
const OPEN_B_OWNER: &str = r##"[{"number":13,"state":"OPEN","baseRefName":"feature/a","headRefName":"feature/b","url":"https://github.com/owner/repo/pull/13"}]"##;
const MERGED_A_TITLE: &str = r##"[{"number":12,"state":"MERGED","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12","title":"A work"}]"##;
const OPEN_B_TITLE: &str = r##"[{"number":13,"state":"OPEN","baseRefName":"feature/a","headRefName":"feature/b","url":"https://github.com/owner/repo/pull/13","title":"B work"}]"##;
#[test]
fn cleanup_retargets_children_and_detaches_merged_branch() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.stack().args(["new", "feature/b"]).assert().success();
let fake = FakeProvider::new()
.on("feature/a --state merged", MERGED_A)
.on("feature/a", "[]")
.on("feature/b", OPEN_B)
.on("pr edit", "updated child review")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains("will retarget feature/b -> main"))
.stdout(predicates::str::contains(
"will update review feature/b -> main (#13)",
))
.stdout(predicates::str::contains("updated child review"))
.stdout(predicates::str::contains("will detach feature/a"))
.stdout(predicates::str::contains(
"skipped feature/b: review #13 is open",
))
.stdout(predicates::str::contains(
"cleanup complete: 1 cleaned, 1 skipped",
));
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"main"
);
assert_eq!(
repo.git_status(["config", "--get", "branch.feature/a.stkParent"])
.status
.code(),
Some(1)
);
}
#[test]
fn cleanup_dry_run_leaves_stack_metadata_unchanged() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.stack().args(["new", "feature/b"]).assert().success();
let fake = FakeProvider::new()
.on("feature/a --state merged", MERGED_A)
.on("feature/a", "[]")
.on("feature/b", OPEN_B)
.fail("pr edit", "dry-run should not edit review")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "--dry-run", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains(
"would retarget feature/b -> main",
))
.stdout(predicates::str::contains(
"would update review feature/b -> main (#13)",
))
.stdout(predicates::str::contains("would detach feature/a"))
.stdout(predicates::str::contains("would update stack note in #12"));
assert_eq!(
repo.git(["config", "--get", "branch.feature/a.stkParent"]),
"main"
);
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"feature/a"
);
}
#[test]
fn cleanup_refreshes_the_stack_overview_ledger() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.stack().args(["new", "feature/b"]).assert().success();
repo.git(["switch", "main"]);
let fake = FakeProvider::new()
.on("pr view 12", r#"{"body":""}"#)
.on("pr view 13", r#"{"body":""}"#)
.record("pr edit 12 --body", "edit-body-12.txt", "")
.record("pr edit 13 --body", "edit-body-13.txt", "")
.on("feature/a --state merged", MERGED_A_TITLE)
.on("feature/a", "[]")
.on("feature/b", OPEN_B_TITLE)
.on("pr edit", "edited")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains("updated stack note in #12"))
.stdout(predicates::str::contains("updated stack note in #13"))
.stdout(predicates::str::contains("will delete branch feature/a"));
let survivor =
std::fs::read_to_string(repo.path().join("edit-body-13.txt")).expect("survivor body");
assert!(survivor.contains(
"- \u{1F7E3} ~~[A work (#12)](https://github.com/owner/repo/pull/12)~~ (merged)"
));
assert!(
survivor.contains(
"- \u{1F7E2} [B work (#13)](https://github.com/owner/repo/pull/13) \u{1F448}"
)
);
}
#[test]
fn cleanup_recovers_base_when_merged_parent_branch_is_gone() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
repo.stack().args(["new", "feature/b"]).assert().success();
repo.commit_file("b.txt", "b\n", "b work");
repo.git(["switch", "main"]);
repo.git(["branch", "-D", "feature/a"]);
repo.git(["config", "branch.feature/b.stkParent", "feature/a"]);
let fake = FakeProvider::new()
.on("feature/a --state merged", MERGED_A_OWNER)
.on("feature/a", "[]")
.record("pr edit 13 --base", "edit-base-13.txt", "")
.on("feature/b", OPEN_B_OWNER)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "--dry-run", "feature/b"])
.assert()
.success()
.stdout(predicates::str::contains(
"would retarget feature/b -> main",
));
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"feature/a"
);
repo.stack_faked(&fake)
.args(["cleanup", "feature/b"])
.assert()
.success()
.stdout(predicates::str::contains(
"feature/b: parent feature/a is gone, but review #12 merged into main",
))
.stdout(predicates::str::contains("will retarget feature/b -> main"))
.stdout(predicates::str::contains(
"will update review feature/b -> main (#13)",
))
.stdout(predicates::str::contains(
"cleanup complete: 0 cleaned, 1 skipped, 1 retargeted",
));
let recorded =
std::fs::read_to_string(repo.path().join("edit-base-13.txt")).expect("edit base args");
assert_eq!(recorded.trim(), "pr edit 13 --base main");
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"main"
);
}
#[test]
fn cleanup_leaves_a_gone_parent_alone_without_a_merged_review() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["switch", "-c", "feature/b"]);
repo.git(["config", "branch.feature/b.stkParent", "feature/a"]);
let fake = FakeProvider::new().fallback("[]").install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "feature/b"])
.assert()
.success()
.stdout(predicates::str::contains("retarget").not());
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"feature/a"
);
}
#[test]
fn cleanup_skips_closed_reviews_with_their_state() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.git(["switch", "main"]);
let fake = FakeProvider::new()
.on("--state closed", CLOSED_A)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains(
"skipped feature/a: review #12 is closed",
))
.stdout(predicates::str::contains(
"cleanup complete: 0 cleaned, 1 skipped",
));
assert!(
repo.git(["branch", "--list", "feature/a"])
.contains("feature/a")
);
}
#[test]
fn cleanup_deletes_cleaned_merged_branch_by_default() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "one\n", "parent change one");
repo.commit_file("a.txt", "one\ntwo\n", "parent change two");
repo.stack().args(["new", "feature/b"]).assert().success();
repo.git(["switch", "main"]);
repo.git(["merge", "--squash", "feature/a"]);
repo.git(["commit", "-m", "parent changes (#12)"]);
let fake = FakeProvider::new()
.on("feature/a --state merged", MERGED_A)
.on("feature/a", "[]")
.on("feature/b", OPEN_B)
.on("pr edit", "updated child review")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains("will delete branch feature/a"))
.stdout(predicates::str::contains(
"cleanup complete: 1 cleaned, 1 skipped",
));
assert!(
!repo
.git(["branch", "--list", "feature/a"])
.contains("feature/a")
);
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"main"
);
}
#[test]
fn cleanup_deletes_closed_branch_only_with_config_set() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "one\n", "parent change one");
repo.commit_file("a.txt", "one\ntwo\n", "parent change two");
repo.stack().args(["new", "feature/b"]).assert().success();
repo.git(["switch", "main"]);
repo.git(["merge", "--squash", "feature/a"]);
repo.git(["commit", "-m", "parent changes (#12)"]);
let fake = FakeProvider::new()
.on("feature/a --state closed", CLOSED_A)
.on("feature/a", "[]")
.on("feature/b", OPEN_B)
.on("pr edit", "updated child review")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains(
"skipped feature/a: review #12 is closed",
))
.stdout(predicates::str::contains(
"cleanup complete: 0 cleaned, 2 skipped",
));
assert!(
repo.git(["branch", "--list", "feature/a"])
.contains("feature/a")
);
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"feature/a"
);
repo.git(["config", "stk.cleanClosed", "true"]);
repo.stack_faked(&fake)
.args(["cleanup", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains(
"will delete branch feature/a (closed, not merged - `git stk undo` restores it)",
))
.stdout(predicates::str::contains(
"cleanup complete: 1 cleaned, 1 skipped",
));
assert!(
!repo
.git(["branch", "--list", "feature/a"])
.contains("feature/a")
);
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"main"
);
assert!(
!repo
.git_status(["config", "--get", "branch.feature/b.stkBase"])
.status
.success()
);
}
#[test]
fn cleanup_dry_run_keeps_branch() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.git(["switch", "main"]);
let fake = FakeProvider::new()
.on("--state merged", MERGED_A)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "--dry-run", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains("would delete branch feature/a"));
assert!(
repo.git(["branch", "--list", "feature/a"])
.contains("feature/a")
);
assert_eq!(
repo.git(["config", "--get", "branch.feature/a.stkParent"]),
"main"
);
}
#[test]
fn cleanup_keeps_the_checked_out_branch() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
let fake = FakeProvider::new()
.on("--state merged", MERGED_A)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains(
"kept feature/a: cannot delete the checked out branch \
- still stacked, so a later cleanup can finish it",
))
.stdout(predicates::str::contains(
"cleanup complete: 0 cleaned, 0 skipped, 1 kept",
));
assert!(
repo.git(["branch", "--list", "feature/a"])
.contains("feature/a")
);
assert_eq!(
repo.git(["config", "--get", "branch.feature/a.stkParent"]),
"main"
);
}
#[test]
fn cleanup_keep_branch_keeps_cleaned_merged_branch() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.git(["switch", "main"]);
let fake = FakeProvider::new()
.on("--state merged", MERGED_A)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "--keep-branch", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains("will detach feature/a"))
.stdout(predicates::str::contains("delete").not());
assert!(
repo.git(["branch", "--list", "feature/a"])
.contains("feature/a")
);
assert_eq!(
repo.git_status(["config", "--get", "branch.feature/a.stkParent"])
.status
.code(),
Some(1)
);
}
#[test]
fn cleanup_never_finishes_the_stack_base() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["switch", "-c", "rc-20260817"]);
repo.commit_file("rc.txt", "rc\n", "release commit");
repo.stack().args(["new", "fix/shared"]).assert().success();
repo.commit_file("shared.txt", "shared\n", "shared work");
let fake = FakeProvider::new()
.on("rc-20260817", r##"[{"number":99,"state":"MERGED","baseRefName":"main","headRefName":"rc-20260817","url":"https://example.com/99","title":"Release 20260817"}]"##)
.on("fix/shared", r##"[{"number":13,"state":"OPEN","baseRefName":"rc-20260817","headRefName":"fix/shared","url":"https://example.com/13","title":"Shared fix"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake).args(["cleanup"]).assert().success();
assert!(
!repo
.git_status(["branch", "--list", "rc-20260817"])
.stdout
.is_empty(),
"the base must not be deleted"
);
assert_eq!(
repo.git(["config", "--get", "branch.fix/shared.stkParent"]),
"rc-20260817",
"the layers above the base must not be detached"
);
}
#[test]
fn cleanup_says_so_when_a_stack_cannot_retarget_a_child() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["config", "stk.githubStacks", "true"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.stack().args(["new", "feature/b"]).assert().success();
let stacks = r##"[{"number":5,"open":true,"base":{"ref":"feature/a"},"pull_requests":[
{"number":13,"head":{"ref":"feature/b"}},
{"number":14,"head":{"ref":"feature/c"}}]}]"##;
let fake = FakeProvider::new()
.record("pr edit 13 --base", "retarget.txt", "")
.on("repo view", r##"{"nameWithOwner":"owner/repo"}"##)
.on("repos/owner/repo/stacks", stacks)
.on("feature/a --state merged", MERGED_A)
.on("feature/a", "[]")
.on("feature/b", OPEN_B)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["cleanup", "feature/a"])
.assert()
.success()
.stdout(predicates::str::contains(
"its stack will not move it to main",
))
.stdout(predicates::str::contains("git stk unstack"))
.stdout(predicates::str::contains("the platform retargets it").not());
assert!(
!repo.path().join("retarget.txt").exists(),
"the platform refuses this call, so it must not be attempted"
);
}