use std::fs;
use common::{FakeProvider, TestRepo};
use predicates::prelude::PredicateBooleanExt;
mod common;
#[test]
fn merge_merges_bottom_review_then_syncs() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["config", "stk.pushOnRestack", "true"]);
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 bare = repo.add_bare_origin(&["main", "feature/a", "feature/b"]);
let fake = FakeProvider::new()
.record("pr merge 12", "merge-args.txt", "")
.on_after("feature/a --state merged", "merge-args.txt", r##"[{"number":12,"state":"MERGED","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12","title":"A work"}]"##)
.on("feature/a --state merged", "[]")
.on_after("feature/a", "merge-args.txt", "[]")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12","title":"A work"}]"##)
.on("feature/b", r##"[{"number":13,"state":"OPEN","baseRefName":"feature/a","headRefName":"feature/b","url":"https://github.com/owner/repo/pull/13","title":"B work"}]"##)
.on("pr edit", "updated review")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "-y"])
.assert()
.success()
.stdout(predicates::str::contains("merged A work (#12)"))
.stdout(predicates::str::contains("next up: feature/b -> #13"));
let recorded = fs::read_to_string(repo.path().join("merge-args.txt")).expect("merge args");
assert_eq!(recorded.trim(), "pr merge 12 --squash");
assert_eq!(
repo.git_status(["branch", "--list", "feature/a"])
.stdout
.len(),
0
);
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"main"
);
assert_eq!(
repo.remote_sha(&bare, "feature/b"),
repo.git(["rev-parse", "feature/b"])
);
}
#[test]
fn merge_respects_strategy_config() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["config", "stk.mergeStrategy", "rebase"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
let fake = FakeProvider::new()
.record("pr merge 12", "merge-args.txt", "")
.on_after("feature/a", "merge-args.txt", "[]")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12","title":"A work"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "-y"])
.assert()
.success();
let recorded = fs::read_to_string(repo.path().join("merge-args.txt")).expect("merge args");
assert_eq!(recorded.trim(), "pr merge 12 --rebase");
}
#[test]
fn merge_dry_run_and_decline_merge_nothing() {
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");
let fake = FakeProvider::new()
.record("pr merge", "merged.txt", "")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12","title":"A work"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains(
"would merge A work (#12) into main (squash)",
));
assert!(!repo.path().join("merged.txt").exists());
repo.stack_faked(&fake)
.args(["merge", "--dry-run", "--auto"])
.assert()
.success()
.stdout(predicates::str::contains(
"would merge A work (#12) into main (squash, auto)",
));
assert!(!repo.path().join("merged.txt").exists());
repo.stack_faked(&fake)
.args(["merge"])
.write_stdin("n\n")
.assert()
.success()
.stdout(predicates::str::contains("merge cancelled"));
assert!(!repo.path().join("merged.txt").exists());
}
#[test]
fn merge_all_lands_the_whole_stack() {
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");
let _bare = repo.add_bare_origin(&["main", "feature/a", "feature/b"]);
let fake = FakeProvider::new()
.record("pr merge 12", "merge-args-12.txt", "")
.record("pr merge 13", "merge-args-13.txt", "")
.record("pr edit 13 --base", "base-13.txt", "")
.on_after("feature/a --state merged", "merge-args-12.txt", r##"[{"number":12,"state":"MERGED","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
.on("feature/a --state merged", "[]")
.on_after("feature/a", "merge-args-12.txt", "[]")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
.on_after("feature/b --state merged", "merge-args-13.txt", r##"[{"number":13,"state":"MERGED","baseRefName":"main","headRefName":"feature/b","url":"https://example.com/13","title":"B work"}]"##)
.on("feature/b --state merged", "[]")
.on_after("feature/b", "merge-args-13.txt", "[]")
.on_after("feature/b", "base-13.txt", r##"[{"number":13,"state":"OPEN","baseRefName":"main","headRefName":"feature/b","url":"https://example.com/13","title":"B work"}]"##)
.on("feature/b", r##"[{"number":13,"state":"OPEN","baseRefName":"feature/a","headRefName":"feature/b","url":"https://example.com/13","title":"B work"}]"##)
.on("pr edit", "edited")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "--all", "-y"])
.assert()
.success()
.stdout(predicates::str::contains("merged A work (#12)"))
.stdout(predicates::str::contains("merged B work (#13)"))
.stdout(predicates::str::contains(
"stack complete: everything merged into main",
))
.stdout(predicates::str::contains(
"merge complete: 2 of 2 reviews merged",
));
let first = fs::read_to_string(repo.path().join("merge-args-12.txt")).expect("merge 12");
assert_eq!(first.trim(), "pr merge 12 --squash");
let second = fs::read_to_string(repo.path().join("merge-args-13.txt")).expect("merge 13");
assert_eq!(second.trim(), "pr merge 13 --squash");
assert_eq!(repo.git(["branch", "--show-current"]), "main");
assert_eq!(
repo.git_status(["branch", "--list", "feature/a", "feature/b"])
.stdout
.len(),
0
);
}
#[test]
fn merge_all_dry_run_lists_each_review() {
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()
.record("pr merge", "merged.txt", "")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
.on("feature/b", r##"[{"number":13,"state":"OPEN","baseRefName":"feature/a","headRefName":"feature/b","url":"https://example.com/13","title":"B work"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "--all", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains(
"would merge A work (#12) into main (squash)",
))
.stdout(predicates::str::contains(
"would merge B work (#13) into feature/a (squash)",
))
.stdout(predicates::str::contains("would sync after each merge"));
assert!(!repo.path().join("merged.txt").exists());
}
#[test]
fn merge_all_stops_when_a_merge_only_schedules() {
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");
let fake = FakeProvider::new()
.record("pr merge 12", "merge-args-12.txt", "")
.record("pr merge", "unexpected-merge.txt", "")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
.on("feature/b", r##"[{"number":13,"state":"OPEN","baseRefName":"feature/a","headRefName":"feature/b","url":"https://example.com/13","title":"B work"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "--all", "-y"])
.assert()
.success()
.stdout(predicates::str::contains(
"merge scheduled for A work (#12); rerun `git stk sync` once checks pass",
))
.stdout(predicates::str::contains(
"merge complete: 0 of 2 reviews merged",
));
assert!(!repo.path().join("unexpected-merge.txt").exists());
assert_eq!(repo.git(["branch", "--show-current"]), "feature/b");
}
#[test]
fn merge_all_wait_gates_each_merge_on_checks() {
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");
let fake = FakeProvider::new()
.record("pr checks 12", "checks-args.txt", "")
.record("pr merge 12", "merge-args.txt", "")
.on_after("feature/a --state merged", "merge-args.txt", r##"[{"number":12,"state":"MERGED","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
.on("feature/a --state merged", "[]")
.on_after("feature/a", "merge-args.txt", "[]")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
.on("pr edit", "edited")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "--all", "--wait", "-y"])
.assert()
.success()
.stdout(predicates::str::contains("waiting for checks on #12"))
.stdout(predicates::str::contains("merged A work (#12)"))
.stdout(predicates::str::contains(
"merge complete: 1 of 1 review merged",
));
let checks = fs::read_to_string(repo.path().join("checks-args.txt")).expect("checks args");
assert_eq!(checks.trim(), "pr checks 12");
}
#[test]
fn merge_all_wait_stops_when_checks_fail() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["config", "stk.mergeWait", "true"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
let fake = FakeProvider::new()
.fail("pr checks 12", "X lint failing")
.record("pr merge", "merged.txt", "")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "--all", "-y"])
.assert()
.failure()
.stderr(predicates::str::contains(
"checks failed for #12; fix them and rerun `git stk merge --all`",
));
assert!(!repo.path().join("merged.txt").exists());
}
#[test]
fn merge_all_no_wait_overrides_the_config() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["config", "stk.mergeWait", "true"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
let fake = FakeProvider::new()
.fail("pr checks", "checks should not run")
.record("pr merge 12", "merge-args.txt", "")
.on_after("feature/a --state merged", "merge-args.txt", r##"[{"number":12,"state":"MERGED","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
.on("feature/a --state merged", "[]")
.on_after("feature/a", "merge-args.txt", "[]")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://example.com/12","title":"A work"}]"##)
.on("pr edit", "edited")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "--all", "--no-wait", "-y"])
.assert()
.success()
.stdout(predicates::str::contains("merged A work (#12)"));
}
#[test]
fn merge_all_conflicts_with_auto() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.stack()
.args(["merge", "--all", "--auto"])
.assert()
.failure()
.stderr(predicates::str::contains("cannot be used with"));
}
#[test]
fn merge_auto_schedules_and_skips_the_sync() {
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");
let fake = FakeProvider::new()
.record("pr merge 12", "merge-args.txt", "")
.on("feature/a --state merged", "[]")
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12","title":"A work"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "-y", "--auto"])
.assert()
.success()
.stdout(predicates::str::contains(
"merge scheduled for A work (#12); rerun `git stk sync` once checks pass",
));
let recorded = fs::read_to_string(repo.path().join("merge-args.txt")).expect("merge args");
assert_eq!(recorded.trim(), "pr merge 12 --squash --auto");
assert_eq!(repo.git(["branch", "--show-current"]), "feature/a");
}
#[test]
fn merge_hints_when_required_checks_block_it() {
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");
let fake = FakeProvider::new()
.fail(
"pr merge 12",
"GraphQL: Required status check \"ci\" is expected. (mergePullRequest)",
)
.on("feature/a", r##"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12","title":"A work"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "-y"])
.assert()
.failure()
.stderr(predicates::str::contains(
"#12's required checks are not green yet - wait and rerun \
`git stk merge`, or schedule with `git stk merge --auto`",
))
.stderr(predicates::str::contains("GraphQL").not());
}
#[test]
fn merge_reports_a_scheduled_gitlab_auto_merge() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "gitlab"]);
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
let fake = FakeProvider::new()
.on("mr merge 34", "merge scheduled to run when pipeline succeeds")
.on("feature/a", r##"[{"iid":34,"state":"opened","target_branch":"main","source_branch":"feature/a","web_url":"https://gitlab.com/owner/repo/-/merge_requests/34","title":"A work"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["merge", "-y"])
.assert()
.success()
.stdout(predicates::str::contains(
"merge scheduled for A work (!34); rerun `git stk sync` once checks pass",
));
assert_eq!(repo.git(["branch", "--show-current"]), "feature/a");
}
#[test]
fn merge_requires_an_open_review_at_the_bottom() {
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");
let fake = FakeProvider::new().fallback("[]").install(&repo);
repo.stack_faked(&fake)
.args(["merge", "-y"])
.assert()
.failure()
.stderr(predicates::str::contains(
"no github review found for feature/a; submit the stack first",
));
}