mod common;
use common::TestRepo;
fn stack() -> TestRepo {
let repo = TestRepo::new();
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("foo.txt", "alpha\n", "add foo");
repo.stack().args(["new", "feature/b"]).assert().success();
repo.commit_file("bar.txt", "beta\n", "add bar");
repo
}
#[test]
fn absorb_dry_run_routes_hunks_to_owning_commits() {
let repo = stack();
let head = repo.git(["rev-parse", "HEAD"]);
repo.write("foo.txt", "alpha fixed\n");
repo.write("bar.txt", "beta fixed\n");
repo.git(["add", "foo.txt", "bar.txt"]);
repo.stack()
.args(["absorb", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains("foo.txt:1 -> feature/a"))
.stdout(predicates::str::contains("add foo"))
.stdout(predicates::str::contains("bar.txt:1 -> feature/b"))
.stdout(predicates::str::contains("add bar"));
assert_eq!(repo.git(["rev-parse", "HEAD"]), head);
}
#[test]
fn absorb_dry_run_leaves_trunk_owned_and_added_lines_unabsorbed() {
let repo = stack();
repo.write("README.md", "# changed\n");
repo.write("foo.txt", "alpha\nbrand new\n");
repo.git(["add", "README.md", "foo.txt"]);
repo.stack()
.args(["absorb", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains("unabsorbed (left in place)"))
.stdout(predicates::str::contains(
"README.md:1 owned by a commit outside the stack",
))
.stdout(predicates::str::contains(
"foo.txt:1 added lines - no commit to attribute",
));
}
#[test]
fn absorb_without_staged_changes_reports_nothing() {
let repo = stack();
repo.stack()
.args(["absorb", "--dry-run"])
.assert()
.failure()
.stderr(predicates::str::contains("no staged changes to absorb"));
}
#[test]
fn absorb_include_unstaged_attributes_without_staging() {
let repo = stack();
repo.write("foo.txt", "alpha fixed\n");
repo.stack()
.args(["absorb", "--dry-run"])
.assert()
.failure()
.stderr(predicates::str::contains("no staged changes"));
repo.stack()
.args(["absorb", "--dry-run", "--include-unstaged"])
.assert()
.success()
.stdout(predicates::str::contains("foo.txt:1 -> feature/a"));
}
#[test]
fn absorb_respects_include_unstaged_config() {
let repo = stack();
repo.git(["config", "stk.absorbIncludeUnstaged", "true"]);
repo.write("foo.txt", "alpha fixed\n");
repo.stack()
.args(["absorb", "--dry-run"])
.assert()
.success()
.stdout(predicates::str::contains("foo.txt:1 -> feature/a"));
}
#[test]
fn absorb_folds_a_fix_into_its_owning_commit() {
let repo = stack();
repo.write("foo.txt", "alpha fixed\n");
repo.git(["add", "foo.txt"]);
repo.stack()
.args(["absorb"])
.assert()
.success()
.stdout(predicates::str::contains("absorbed 1 hunk into 1 commit"));
assert_eq!(repo.git(["show", "feature/a:foo.txt"]), "alpha fixed");
assert_eq!(repo.git(["rev-list", "--count", "main..feature/a"]), "1");
assert!(repo.git(["status", "--porcelain"]).is_empty());
}
#[test]
fn absorb_folds_with_mnemonic_prefix_configured() {
let repo = stack();
repo.git(["config", "diff.mnemonicPrefix", "true"]);
repo.write("foo.txt", "alpha fixed\n");
repo.git(["add", "foo.txt"]);
repo.stack()
.args(["absorb"])
.assert()
.success()
.stdout(predicates::str::contains("absorbed 1 hunk into 1 commit"));
assert_eq!(repo.git(["show", "feature/a:foo.txt"]), "alpha fixed");
assert!(repo.git(["status", "--porcelain"]).is_empty());
}
#[test]
fn absorb_folds_into_multiple_commits_across_branches() {
let repo = stack();
repo.write("foo.txt", "alpha fixed\n");
repo.write("bar.txt", "beta fixed\n");
repo.git(["add", "foo.txt", "bar.txt"]);
repo.stack()
.args(["absorb"])
.assert()
.success()
.stdout(predicates::str::contains("absorbed 2 hunks into 2 commits"));
assert_eq!(repo.git(["show", "feature/a:foo.txt"]), "alpha fixed");
assert_eq!(repo.git(["show", "feature/b:bar.txt"]), "beta fixed");
assert!(repo.git(["status", "--porcelain"]).is_empty());
}
#[test]
fn absorb_leaves_unattributable_changes_in_place() {
let repo = stack();
repo.write("foo.txt", "alpha fixed\n"); repo.write("README.md", "# changed\n"); repo.git(["add", "foo.txt", "README.md"]);
repo.stack()
.args(["absorb"])
.assert()
.success()
.stdout(predicates::str::contains("absorbed 1 hunk into 1 commit"))
.stdout(predicates::str::contains(
"README.md:1 owned by a commit outside the stack",
));
assert_eq!(repo.git(["show", "feature/a:foo.txt"]), "alpha fixed");
assert_eq!(repo.git(["show", "HEAD:README.md"]), "# test repo");
assert_eq!(
std::fs::read_to_string(repo.path().join("README.md")).expect("README"),
"# changed\n"
);
}
#[test]
fn absorb_folds_then_restacks_a_branch_forking_above() {
let repo = stack(); repo.git(["switch", "feature/a"]);
repo.write("foo.txt", "alpha fixed\n");
repo.git(["add", "foo.txt"]);
repo.stack()
.args(["absorb"])
.assert()
.success()
.stdout(predicates::str::contains("absorbed 1 hunk into 1 commit"))
.stdout(predicates::str::contains("rebasing feature/b"));
assert_eq!(repo.git(["show", "feature/a:foo.txt"]), "alpha fixed");
assert_eq!(repo.git(["rev-list", "--count", "main..feature/a"]), "1");
assert_eq!(repo.git(["show", "feature/b:bar.txt"]), "beta");
assert_eq!(repo.git(["show", "feature/b:foo.txt"]), "alpha fixed");
assert!(
repo.git_status(["merge-base", "--is-ancestor", "feature/a", "feature/b"])
.status
.success(),
"feature/b should still be stacked on feature/a"
);
assert!(repo.git(["status", "--porcelain"]).is_empty());
}
#[test]
fn absorb_fork_conflict_is_resumable() {
let repo = TestRepo::new();
repo.stack().args(["new", "feature/a"]).assert().success();
repo.commit_file("foo.txt", "alpha\n", "add foo");
repo.stack().args(["new", "feature/b"]).assert().success();
repo.commit_file("foo.txt", "alpha beta\n", "rework foo on b");
repo.git(["switch", "feature/a"]);
repo.write("foo.txt", "alpha fixed\n");
repo.git(["add", "foo.txt"]);
repo.stack()
.args(["absorb"])
.assert()
.failure()
.stdout(predicates::str::contains("absorbed 1 hunk into 1 commit"))
.stderr(predicates::str::contains(
"conflict while rebasing feature/b",
))
.stderr(predicates::str::contains("git stk continue"));
assert_eq!(repo.git(["show", "feature/a:foo.txt"]), "alpha fixed");
repo.stack().args(["abort"]).assert().success();
}