mod common;
use common::TestRepo;
use predicates::prelude::PredicateBooleanExt;
#[test]
fn demo_provider_runs_the_full_merge_loop_offline() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack()
.args(["new", "feature/login"])
.assert()
.success();
repo.commit_file("login.txt", "login form\n", "add login form");
repo.stack()
.args(["new", "feature/avatar"])
.assert()
.success();
repo.commit_file("avatar.txt", "avatars\n", "add avatars");
repo.stack()
.args(["submit", "--stack"])
.assert()
.success()
.stdout(predicates::str::contains("created feature/login -> main"))
.stdout(predicates::str::contains(
"created feature/avatar -> feature/login",
))
.stdout(predicates::str::contains("updated stack note in #1"))
.stdout(predicates::str::contains("updated stack note in #2"));
repo.stack()
.args(["status", "feature/login"])
.assert()
.success()
.stdout(predicates::str::contains(
"review: #1 open feature/login -> main",
))
.stdout(predicates::str::contains("url: demo://review/1"));
repo.stack()
.args(["merge", "--all", "-y"])
.assert()
.success()
.stdout(predicates::str::contains(
"squashed feature/login into main",
))
.stdout(predicates::str::contains("merged add login form (#1)"))
.stdout(predicates::str::contains(
"squashed feature/avatar into main",
))
.stdout(predicates::str::contains("merged add avatars (#2)"))
.stdout(predicates::str::contains(
"stack complete: everything merged into main",
))
.stdout(predicates::str::contains(
"merge complete: 2 of 2 reviews merged",
));
assert_eq!(repo.git(["branch", "--show-current"]), "main");
assert_eq!(repo.git(["show", "main:login.txt"]), "login form");
assert_eq!(repo.git(["show", "main:avatar.txt"]), "avatars");
assert_eq!(
repo.git_status(["branch", "--list", "feature/login", "feature/avatar"])
.stdout
.len(),
0
);
}
#[test]
fn demo_provider_is_never_auto_detected() {
let repo = TestRepo::new();
repo.git(["remote", "add", "origin", "git@github.com:owner/repo.git"]);
repo.stack()
.arg("provider")
.assert()
.success()
.stdout(predicates::str::contains("github"))
.stdout(predicates::str::contains("demo").not());
}
#[test]
fn list_plain_format_uses_plain_text_and_bare_urls() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "add feature a");
repo.stack().args(["submit"]).assert().success();
repo.stack()
.args(["list", "--format", "plain"])
.assert()
.success()
.stdout(predicates::str::contains("1 PR, base main"))
.stdout(predicates::str::contains("1. add feature a (#1) - open"))
.stdout(predicates::str::contains(" demo://review/1"))
.stdout(predicates::str::contains("](").not());
}
#[test]
fn undo_restores_branch_tips_after_a_restack() {
let repo = TestRepo::new();
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");
let before = repo.git(["rev-parse", "feature/b"]);
repo.git(["switch", "feature/a"]);
repo.commit_file("a2.txt", "more\n", "a moves on");
repo.stack().arg("restack").assert().success();
repo.git(["switch", "feature/b"]);
assert_ne!(repo.git(["rev-parse", "feature/b"]), before);
repo.stack()
.arg("undo")
.assert()
.success()
.stdout(predicates::str::contains("undid restack"))
.stdout(predicates::str::contains(
"pushes and merged reviews are not reverted",
));
assert_eq!(repo.git(["rev-parse", "feature/b"]), before);
repo.stack()
.arg("undo")
.assert()
.failure()
.stderr(predicates::str::contains("nothing to undo"));
}
#[test]
fn undo_recreates_a_branch_cleanup_deleted() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
repo.stack().args(["submit"]).assert().success();
let sha = repo.git(["rev-parse", "feature/a"]);
repo.stack().args(["merge", "-y"]).assert().success();
assert_eq!(
repo.git_status(["rev-parse", "--verify", "feature/a"])
.status
.code(),
Some(128),
"feature/a should be gone after merge"
);
repo.stack().arg("undo").assert().success();
assert_eq!(repo.git(["rev-parse", "feature/a"]), sha);
}
#[test]
fn undo_refuses_with_a_dirty_worktree() {
let repo = TestRepo::new();
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
repo.git(["switch", "feature/a"]);
repo.commit_file("a2.txt", "more\n", "a moves on");
repo.git(["switch", "main"]); repo.git(["switch", "feature/a"]);
repo.stack().arg("restack").assert().success();
repo.write("uncommitted.txt", "work in progress\n");
repo.git(["add", "uncommitted.txt"]);
repo.stack()
.arg("undo")
.assert()
.failure()
.stderr(predicates::str::contains(
"worktree has uncommitted changes",
));
}
#[test]
fn errors_are_prefixed_and_colored() {
let repo = TestRepo::new();
repo.stack()
.arg("merge")
.assert()
.failure()
.stderr(predicates::str::contains(
"error: no stacked branches to merge",
))
.stderr(predicates::str::contains("\u{1b}[").not());
repo.stack()
.arg("merge")
.env("CLICOLOR_FORCE", "1")
.assert()
.failure()
.stderr(predicates::str::contains("\u{1b}["))
.stderr(predicates::str::contains("error:"));
}
#[test]
fn view_reports_no_review_without_one() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.stack()
.args(["view", "feature/a"])
.assert()
.failure()
.stderr(predicates::str::contains(
"no demo review found for feature/a; submit it first with `git stk submit`",
));
}
#[test]
fn view_opens_the_current_branch_review() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
repo.stack().args(["submit"]).assert().success();
repo.stack()
.args(["view"])
.assert()
.success()
.stdout(predicates::str::contains("opening #1"))
.stdout(predicates::str::contains("demo reviews have no web page"));
}
#[test]
fn guide_requires_a_terminal() {
let repo = TestRepo::new();
repo.stack()
.arg("guide")
.assert()
.failure()
.stderr(predicates::str::contains(
"the guide is interactive; run it from a terminal",
));
repo.stack()
.args(["guide", "conflicts"])
.assert()
.failure()
.stderr(predicates::str::contains(
"the guide is interactive; run it from a terminal",
));
}
#[test]
fn guide_conflicts_recipe_conflicts_and_continues() {
let repo = TestRepo::new();
repo.stack()
.args(["new", "feature/payment"])
.assert()
.success();
repo.commit_file("notes.txt", "use stripe\n", "choose payment provider");
repo.stack()
.args(["new", "feature/receipts"])
.assert()
.success();
repo.commit_file("notes.txt", "use stripe with receipts\n", "email receipts");
repo.git(["switch", "feature/payment"]);
repo.commit_file("notes.txt", "use paypal\n", "switch to paypal");
repo.stack()
.arg("restack")
.assert()
.failure()
.stderr(predicates::str::contains(
"resolve conflicts, then run `git stk continue`",
));
repo.write("notes.txt", "use paypal with receipts\n");
repo.git(["add", "notes.txt"]);
repo.stack()
.arg("continue")
.assert()
.success()
.stdout(predicates::str::contains("restack complete"));
assert_eq!(
repo.git(["show", "feature/receipts:notes.txt"]),
"use paypal with receipts"
);
}
#[test]
fn guide_repair_recipe_recovers_from_the_demo_review() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack().args(["new", "feature/api"]).assert().success();
repo.commit_file("api.txt", "endpoints\n", "add api");
repo.stack().args(["new", "feature/ui"]).assert().success();
repo.commit_file("ui.txt", "buttons\n", "add ui");
repo.stack().args(["submit", "--stack"]).assert().success();
repo.git(["config", "--unset", "branch.feature/ui.stkParent"]);
repo.stack()
.arg("repair")
.assert()
.success()
.stdout(predicates::str::contains(
"feature/ui: set parent feature/api (from demo review #2)",
));
assert_eq!(
repo.git(["config", "--get", "branch.feature/ui.stkParent"]),
"feature/api"
);
}
#[test]
fn guide_rejects_unknown_topics() {
let repo = TestRepo::new();
repo.stack()
.args(["guide", "bogus"])
.assert()
.failure()
.stderr(predicates::str::contains("invalid value"))
.stderr(predicates::str::contains("intro"));
}
#[test]
fn submit_stack_does_not_sweep_sibling_stacks_on_the_trunk() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack().args(["new", "feature/x"]).assert().success();
repo.commit_file("x.txt", "x\n", "add x");
repo.git(["switch", "main"]);
repo.stack().args(["new", "feature/y"]).assert().success();
repo.commit_file("y.txt", "y\n", "add y");
repo.git(["switch", "feature/x"]);
repo.stack()
.args(["submit", "--stack"])
.assert()
.success()
.stdout(predicates::str::contains("created feature/x -> main"))
.stdout(predicates::str::contains("feature/y").not());
repo.git(["switch", "feature/y"]);
repo.stack()
.args(["submit", "--stack"])
.assert()
.success()
.stdout(predicates::str::contains("created feature/y -> main"));
}
#[test]
fn rename_then_submit_replaces_and_prunes_the_old_review() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "add a");
repo.stack().args(["new", "feature/b"]).assert().success();
repo.commit_file("b.txt", "b\n", "add b");
repo.stack().args(["submit", "--stack"]).assert().success();
repo.stack()
.args(["rename", "feature/b2"])
.assert()
.success()
.stdout(predicates::str::contains(
"opens a fresh review for feature/b2 and closes #2",
));
assert_eq!(
repo.git(["config", "--get", "branch.feature/b2.stkRenamedFrom"]),
"feature/b"
);
repo.stack()
.args(["submit", "--stack"])
.assert()
.success()
.stdout(predicates::str::contains("created feature/b2 -> feature/a"))
.stdout(predicates::str::contains(
"closed superseded review #2 for feature/b",
));
assert!(
repo.git_status(["config", "--get", "branch.feature/b2.stkRenamedFrom"])
.stdout
.is_empty()
);
let raw = std::fs::read_to_string(repo.path().join(".git/stk-demo-reviews"))
.expect("demo review state");
let state: serde_json::Value = serde_json::from_str(&raw).expect("parse demo state");
let body_a = state["reviews"]
.as_array()
.unwrap()
.iter()
.find(|review| review["id"] == 1)
.expect("review #1")["body"]
.as_str()
.unwrap();
assert!(
body_a.contains("demo://review/3"),
"overview should list the renamed branch's fresh review"
);
assert!(
!body_a.contains("demo://review/2"),
"overview should drop the superseded review"
);
}
fn demo_review_body(repo: &TestRepo, id: u64) -> String {
let raw = std::fs::read_to_string(repo.path().join(".git/stk-demo-reviews"))
.expect("demo review state");
let state: serde_json::Value = serde_json::from_str(&raw).expect("parse demo state");
state["reviews"]
.as_array()
.unwrap()
.iter()
.find(|review| review["id"].as_u64() == Some(id))
.unwrap_or_else(|| panic!("review #{id}"))["body"]
.as_str()
.unwrap()
.to_owned()
}
#[test]
fn rebuild_overview_drops_orphaned_rows_keeping_the_live_stack() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "add a");
repo.stack().args(["new", "feature/b"]).assert().success();
repo.commit_file("b.txt", "b\n", "add b");
repo.stack().args(["submit", "--stack"]).assert().success();
repo.stack()
.args(["rename", "feature/b2"])
.assert()
.success();
repo.git(["config", "--unset", "branch.feature/b2.stkRenamedFrom"]);
repo.stack().args(["submit", "--stack"]).assert().success();
repo.stack()
.args(["submit", "--stack", "--rebuild-overview", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains("would drop drifted entry #2"));
repo.stack()
.args(["submit", "--stack", "--rebuild-overview"])
.assert()
.success();
let body = demo_review_body(&repo, 1);
assert!(body.contains("demo://review/1"));
assert!(body.contains("demo://review/3"));
assert!(
!body.contains("demo://review/2"),
"rebuild should drop the orphaned entry"
);
}
#[test]
fn rebuild_overview_keeps_merged_history() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "demo"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "add a");
repo.stack().args(["new", "feature/b"]).assert().success();
repo.commit_file("b.txt", "b\n", "add b");
repo.stack().args(["submit", "--stack"]).assert().success();
repo.git(["switch", "feature/a"]);
repo.stack().args(["merge", "-y"]).assert().success();
repo.git(["switch", "feature/b"]);
repo.stack()
.args(["submit", "--stack", "--rebuild-overview"])
.assert()
.success();
let body = demo_review_body(&repo, 2);
assert!(
body.contains("demo://review/1"),
"rebuild should keep merged history"
);
assert!(body.contains("demo://review/2"));
}