mod common;
use common::{FakeProvider, TestRepo};
use predicates::prelude::PredicateBooleanExt;
fn git_in(dir: &std::path::Path, args: &[&str], stdin: &str) -> String {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.t")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn git");
child
.stdin
.take()
.unwrap()
.write_all(stdin.as_bytes())
.unwrap();
let out = child.wait_with_output().expect("git plumbing");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
#[test]
fn repair_reconstructs_wiped_stack_from_ancestry() {
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");
repo.stack().args(["new", "feature/c"]).assert().success();
repo.commit_file("c.txt", "c\n", "c work");
for branch in ["feature/a", "feature/b", "feature/c"] {
repo.git(["config", "--unset", &format!("branch.{branch}.stkParent")]);
repo.git(["config", "--unset", &format!("branch.{branch}.stkBase")]);
}
repo.stack()
.arg("repair")
.assert()
.success()
.stdout(predicates::str::contains(
"feature/a: set parent main (from ancestry)",
))
.stdout(predicates::str::contains(
"feature/b: set parent feature/a (from ancestry)",
))
.stdout(predicates::str::contains(
"feature/c: set parent feature/b (from ancestry)",
))
.stdout(predicates::str::contains(
"repair complete: 3 repaired, 0 verified, 0 unresolved",
));
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"feature/a"
);
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkBase"]),
repo.git(["rev-parse", "feature/a"])
);
assert_eq!(
repo.git_status(["config", "--get", "branch.main.stkParent"])
.status
.code(),
Some(1)
);
}
#[test]
fn repair_prefers_provider_review_base_over_ancestry() {
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(["config", "--unset", "branch.feature/b.stkParent"]);
repo.git(["config", "--unset", "branch.feature/b.stkBase"]);
let fake = FakeProvider::new()
.on(
"feature/b",
r##"[{"number":7,"state":"OPEN","baseRefName":"feature/a","headRefName":"feature/b","url":"https://github.com/owner/repo/pull/7"}]"##,
)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.arg("repair")
.assert()
.success()
.stdout(predicates::str::contains(
"feature/b: set parent feature/a (from github review #7)",
));
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"feature/a"
);
}
#[test]
fn repair_re_records_stale_fork_point() {
let repo = TestRepo::new();
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
repo.git([
"config",
"branch.feature/a.stkBase",
"0000000000000000000000000000000000000000",
]);
repo.stack()
.arg("repair")
.assert()
.success()
.stdout(predicates::str::contains(
"feature/a: re-recorded fork point from main",
))
.stdout(predicates::str::contains("1 repaired, 0 verified"));
assert_eq!(
repo.git(["config", "--get", "branch.feature/a.stkBase"]),
repo.git(["rev-parse", "main"])
);
}
#[test]
fn repair_re_records_a_stale_but_still_ancestor_fork_point() {
let repo = TestRepo::new();
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
let old_base = repo.git(["config", "--get", "branch.feature/a.stkBase"]);
repo.git(["switch", "main"]);
repo.commit_file("m.txt", "m\n", "trunk moves");
repo.git(["switch", "feature/a"]);
repo.git(["rebase", "--onto", "main", &old_base, "feature/a"]);
repo.stack()
.arg("repair")
.assert()
.success()
.stdout(predicates::str::contains(
"feature/a: re-recorded fork point from main",
))
.stdout(predicates::str::contains("1 repaired, 0 verified"));
assert_eq!(
repo.git(["config", "--get", "branch.feature/a.stkBase"]),
repo.git(["rev-parse", "main"])
);
}
#[test]
fn repair_verifies_a_current_fork_point() {
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");
repo.stack()
.arg("repair")
.assert()
.success()
.stdout(predicates::str::contains(
"repair complete: 0 repaired, 2 verified, 0 unresolved",
));
}
#[test]
fn repair_dry_run_changes_nothing() {
let repo = TestRepo::new();
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "a work");
repo.git(["config", "--unset", "branch.feature/a.stkParent"]);
repo.git(["config", "--unset", "branch.feature/a.stkBase"]);
repo.stack()
.args(["repair", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains(
"feature/a: would set parent main (from ancestry)",
));
assert_eq!(
repo.git_status(["config", "--get", "branch.feature/a.stkParent"])
.status
.code(),
Some(1)
);
}
#[test]
fn repair_reports_unrepairable_branches() {
let repo = TestRepo::new();
repo.git(["switch", "-c", "feature/empty"]);
repo.git(["switch", "main"]);
repo.stack()
.arg("repair")
.assert()
.success()
.stdout(predicates::str::contains("feature/empty: no parent found"))
.stdout(predicates::str::contains(
"0 repaired, 0 verified, 1 unresolved",
));
}
#[test]
fn repair_from_remote_rebuilds_a_stack_from_the_pushed_metadata() {
let repo = TestRepo::new();
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");
let _origin = repo.add_bare_origin(&["main"]);
repo.stack().args(["restack", "--push"]).assert().success();
repo.git(["config", "--unset", "branch.feature/a.stkParent"]);
repo.git(["config", "--unset", "branch.feature/b.stkParent"]);
repo.stack()
.args(["repair", "--from-remote"])
.assert()
.success()
.stdout(predicates::str::contains("attached feature/a to main"))
.stdout(predicates::str::contains("attached feature/b to feature/a"))
.stdout(predicates::str::contains("rebuilt 2 branches"));
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 repair_from_remote_errors_clearly_when_no_metadata_was_published() {
let repo = TestRepo::new();
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "add a");
let _origin = repo.add_bare_origin(&["main", "feature/a"]);
repo.stack()
.args(["repair", "--from-remote"])
.assert()
.failure()
.stderr(predicates::str::contains("no stack metadata on the remote"));
}
#[test]
fn repair_from_remote_conflicts_with_dry_run() {
let repo = TestRepo::new();
repo.stack()
.args(["repair", "--from-remote", "--dry-run"])
.assert()
.failure()
.stderr(predicates::str::contains("cannot be used with"));
}
#[test]
fn repair_from_remote_skips_unsafe_metadata_names() {
let repo = TestRepo::new();
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("a.txt", "a\n", "add a");
let origin = repo.add_bare_origin(&["main", "feature/a"]);
let bare = origin.path();
let json =
r#"{"trunk":"main","parents":{"--upload-pack=touch PWNED":"main","feature/a":"main"}}"#;
let blob = git_in(bare, &["hash-object", "-w", "--stdin"], json);
let tree = git_in(
bare,
&["mktree"],
&format!("100644 blob {blob}\tstack.json\n"),
);
let commit = git_in(bare, &["commit-tree", &tree, "-m", "metadata"], "");
git_in(bare, &["update-ref", "refs/stk/metadata", &commit], "");
repo.git(["config", "--unset", "branch.feature/a.stkParent"]);
repo.stack()
.args(["repair", "--from-remote"])
.assert()
.success()
.stdout(predicates::str::contains("attached feature/a to main"))
.stderr(predicates::str::contains(
"skipping unsafe stack metadata entry",
));
assert_eq!(
repo.git(["config", "--get", "branch.feature/a.stkParent"]),
"main"
);
assert!(
!repo.path().join("PWNED").exists(),
"the malicious metadata name must not have executed anything"
);
}
#[test]
fn repair_from_remote_restores_the_stack_base() {
let repo = TestRepo::new();
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 _origin = repo.add_bare_origin(&["main", "rc-20260817"]);
repo.stack().args(["restack", "--push"]).assert().success();
repo.git(["config", "--unset", "branch.fix/shared.stkParent"]);
repo.git(["config", "--unset", "branch.rc-20260817.stkFloor"]);
repo.git(["branch", "-D", "rc-20260817"]);
repo.stack()
.args(["repair", "--from-remote"])
.assert()
.success()
.stdout(predicates::str::contains("rc-20260817 is now a stack base"));
assert_eq!(
repo.git(["config", "--get", "branch.fix/shared.stkParent"]),
"rc-20260817"
);
assert!(
!repo
.git_status(["branch", "--list", "rc-20260817"])
.stdout
.is_empty(),
"the base should have been fetched: the stack is unusable without it"
);
assert_eq!(
repo.git(["config", "--get", "branch.rc-20260817.stkFloor"]),
"true",
"the base should have been restored from the metadata ref"
);
}
#[test]
fn repair_from_remote_clears_a_base_the_other_machine_adopted() {
let repo = TestRepo::new();
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 _origin = repo.add_bare_origin(&["main", "rc-20260817"]);
repo.stack()
.args(["adopt", "rc-20260817", "--parent", "main"])
.assert()
.success();
repo.stack().args(["restack", "--push"]).assert().success();
repo.git(["config", "branch.rc-20260817.stkFloor", "true"]);
repo.stack()
.args(["repair", "--from-remote"])
.assert()
.success()
.stdout(predicates::str::contains(
"rc-20260817 is no longer a stack base",
));
assert_eq!(
repo.git_status(["config", "--get", "branch.rc-20260817.stkFloor"])
.stdout
.len(),
0
);
}
#[test]
fn publish_metadata_keeps_a_base_a_base_even_with_a_stray_parent() {
let repo = TestRepo::new();
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");
repo.git(["config", "branch.rc-20260817.stkParent", "main"]);
let _origin = repo.add_bare_origin(&["main", "rc-20260817"]);
repo.stack().args(["restack", "--push"]).assert().success();
repo.git(["config", "--unset", "branch.rc-20260817.stkFloor"]);
repo.stack()
.args(["repair", "--from-remote"])
.assert()
.success();
assert_eq!(
repo.git(["config", "--get", "branch.rc-20260817.stkFloor"]),
"true",
"a base with a stray parent must publish as a base, not a layer"
);
}
#[test]
fn repair_leaves_a_recorded_stack_base_alone() {
let repo = TestRepo::new();
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");
repo.stack()
.args(["repair"])
.assert()
.success()
.stdout(predicates::str::contains(
"rc-20260817: stack base, left alone",
));
assert_eq!(
repo.git_status(["config", "--get", "branch.rc-20260817.stkParent"])
.stdout
.len(),
0,
"repair must not invent a parent for a recorded base"
);
}
#[test]
fn repair_from_remote_keeps_a_base_when_the_remote_predates_floors() {
let repo = TestRepo::new();
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 _origin = repo.add_bare_origin(&["main", "rc-20260817"]);
repo.stack().args(["restack", "--push"]).assert().success();
let older = r#"{"trunk":"main","parents":{"fix/shared":"rc-20260817","rc-20260817":"main"}}"#;
repo.write_metadata_ref(older);
repo.stack()
.args(["repair", "--from-remote"])
.assert()
.success();
assert_eq!(
repo.git(["config", "--get", "branch.rc-20260817.stkFloor"]),
"true",
"a floors-less document must not revoke a recorded base"
);
}
#[test]
fn repair_prefers_githubs_stack_to_the_review_base() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["config", "stk.githubStacks", "true"]);
repo.git(["switch", "-c", "feature/a"]);
repo.commit_file("a.txt", "a\n", "a work");
repo.git(["switch", "-c", "feature/b"]);
repo.commit_file("b.txt", "b\n", "b work");
let stacks = r##"[{"number":4,"open":true,"base":{"ref":"main"},"pull_requests":[
{"number":12,"state":"open","head":{"ref":"feature/a"}},
{"number":13,"state":"open","head":{"ref":"feature/b"}}]}]"##;
let fake = FakeProvider::new()
.on("repo view", r##"{"nameWithOwner":"owner/repo"}"##)
.on("repos/owner/repo/stacks", stacks)
.on("feature/b", r##"[{"number":13,"state":"OPEN","baseRefName":"main","headRefName":"feature/b","url":"https://example.com/13"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["repair"])
.assert()
.success()
.stdout(predicates::str::contains(
"feature/b: set parent feature/a (from stack 4 (#13))",
));
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"feature/a"
);
}
#[test]
fn repair_uses_githubs_stack_even_when_registration_is_off() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["switch", "-c", "feature/a"]);
repo.commit_file("a.txt", "a\n", "a work");
repo.git(["switch", "-c", "feature/b"]);
repo.commit_file("b.txt", "b\n", "b work");
let stacks = r##"[{"number":4,"open":true,"base":{"ref":"main"},"pull_requests":[
{"number":12,"state":"open","head":{"ref":"feature/a"}},
{"number":13,"state":"open","head":{"ref":"feature/b"}}]}]"##;
let fake = FakeProvider::new()
.on("repo view", r##"{"nameWithOwner":"owner/repo"}"##)
.on("repos/owner/repo/stacks", stacks)
.on("feature/b", r##"[{"number":13,"state":"OPEN","baseRefName":"main","headRefName":"feature/b","url":"https://example.com/13"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["repair"])
.assert()
.success()
.stdout(predicates::str::contains(
"feature/b: set parent feature/a (from stack 4 (#13))",
));
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"feature/a"
);
}
#[test]
fn repair_asks_github_for_the_stack_listing_once_per_run() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
for (branch, file) in [
("feature/a", "a.txt"),
("feature/b", "b.txt"),
("feature/c", "c.txt"),
("feature/d", "d.txt"),
] {
repo.stack().args(["new", branch]).assert().success();
repo.commit_file(file, "x\n", "work");
}
for branch in ["feature/a", "feature/b", "feature/c", "feature/d"] {
for key in ["stkParent", "stkBase", "stkFloor"] {
let _ = repo.git_status(["config", "--unset", &format!("branch.{branch}.{key}")]);
}
}
let fake = FakeProvider::new()
.log_all("calls.txt")
.on("repo view", r##"{"nameWithOwner":"owner/repo"}"##)
.fail("repos/owner/repo/stacks", "gh: Not Found (HTTP 404)")
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake).args(["repair"]).assert().success();
let calls = std::fs::read_to_string(repo.path().join("calls.txt")).expect("call log");
let listings = calls
.lines()
.filter(|line| line.contains("repos/owner/repo/stacks"))
.count();
assert_eq!(
listings, 1,
"the listing is asked once per command, not once per branch:\n{calls}"
);
}
#[test]
fn repair_prefers_the_review_base_once_the_layer_below_has_landed() {
let repo = TestRepo::new();
repo.git(["config", "stk.provider", "github"]);
repo.git(["config", "stk.githubStacks", "true"]);
repo.git(["switch", "-c", "feature/a"]);
repo.commit_file("a.txt", "a\n", "a work");
repo.git(["switch", "-c", "feature/b"]);
repo.commit_file("b.txt", "b\n", "b work");
let stacks = r##"[{"number":4,"open":true,"base":{"ref":"main"},"pull_requests":[
{"number":12,"state":"closed","head":{"ref":"feature/a"}},
{"number":13,"state":"open","head":{"ref":"feature/b"}}]}]"##;
let fake = FakeProvider::new()
.on("repo view", r##"{"nameWithOwner":"owner/repo"}"##)
.on("repos/owner/repo/stacks", stacks)
.on("feature/b", r##"[{"number":13,"state":"OPEN","baseRefName":"main","headRefName":"feature/b","url":"https://example.com/13"}]"##)
.fallback("[]")
.install(&repo);
repo.stack_faked(&fake)
.args(["repair"])
.assert()
.success()
.stdout(predicates::str::contains(
"feature/b: set parent main (from github review #13)",
))
.stdout(predicates::str::contains("feature/b: set parent feature/a").not());
assert_eq!(
repo.git(["config", "--get", "branch.feature/b.stkParent"]),
"main",
"the parent must be where the platform actually put the base"
);
}