mod common;
use common::{OutputAssertions, TestRepo};
#[cfg(unix)]
fn install_failing_pre_commit_hook(repo: &TestRepo) {
use std::os::unix::fs::PermissionsExt;
let hooks_dir = repo.path().join(".git").join("hooks");
std::fs::create_dir_all(&hooks_dir).expect("create hooks dir");
let hook = hooks_dir.join("pre-commit");
std::fs::write(&hook, "#!/bin/sh\necho hook failed >&2\nexit 1\n").expect("write failing hook");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))
.expect("chmod failing hook");
}
fn assert_current_parent_contains(repo: &TestRepo, expected: &str) {
let parent = repo.get_current_parent();
let expected_lower = expected.to_lowercase();
assert!(
parent
.as_ref()
.is_some_and(|p| p.to_lowercase().contains(&expected_lower)),
"Expected current parent to contain '{}', got: {:?}",
expected,
parent
);
}
fn branch_needs_restack(repo: &TestRepo, branch: &str) -> bool {
repo.get_status_json()["branches"]
.as_array()
.and_then(|branches| {
branches
.iter()
.find(|entry| entry["name"].as_str() == Some(branch))
})
.and_then(|entry| entry["needs_restack"].as_bool())
.unwrap_or(false)
}
#[test]
fn test_create_below_reparents_current_branch_and_preserves_descendants() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
let branches = repo.create_stack(&["below-parent", "below-current", "below-child"]);
let parent = &branches[0];
let current = &branches[1];
let child = &branches[2];
repo.run_stax(&["checkout", current]).assert_success();
let output = repo.run_stax(&["create", "below-mid", "--below"]);
output.assert_success();
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("Reparented") && stdout.contains("restack"),
"Expected reparent summary and restack hint, got: {}",
stdout
);
let below_mid = repo.current_branch();
assert!(
below_mid.contains("below-mid"),
"Expected to switch to new below branch, got: {}",
below_mid
);
assert_current_parent_contains(&repo, "below-parent");
repo.run_stax(&["checkout", current]).assert_success();
assert_current_parent_contains(&repo, "below-mid");
repo.run_stax(&["checkout", child]).assert_success();
assert_current_parent_contains(&repo, "below-current");
repo.run_stax(&["checkout", parent]).assert_success();
let children = repo.get_children(parent);
assert!(
children.iter().any(|name| name.contains("below-mid")),
"Expected parent to have below-mid as a direct child, got: {:?}",
children
);
}
#[test]
fn test_create_below_works_for_direct_child_of_trunk_via_bc_alias() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
let branches = repo.create_stack(&["below-root"]);
let original = &branches[0];
repo.run_stax(&["checkout", original]).assert_success();
repo.run_stax(&["bc", "below-root-mid", "--below"])
.assert_success();
let below_root_mid = repo.current_branch();
assert!(
below_root_mid.contains("below-root-mid"),
"Expected to switch to new below branch, got: {}",
below_root_mid
);
assert_current_parent_contains(&repo, "main");
repo.run_stax(&["checkout", original]).assert_success();
assert_current_parent_contains(&repo, "below-root-mid");
}
#[test]
fn test_create_below_works_via_branch_create() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
let branches = repo.create_stack(&["below-branch-create"]);
let original = &branches[0];
repo.run_stax(&["branch", "create", "below-via-branch", "--below"])
.assert_success();
let new_branch = repo.current_branch();
assert!(
new_branch.contains("below-via-branch"),
"Expected branch create --below to switch to new branch, got: {}",
new_branch
);
assert_current_parent_contains(&repo, "main");
repo.run_stax(&["checkout", original]).assert_success();
assert_current_parent_contains(&repo, "below-via-branch");
}
#[test]
fn test_create_below_with_message_commits_on_new_branch() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
let branches = repo.create_stack(&["below-message-parent", "below-message-current"]);
let parent = &branches[0];
let current = &branches[1];
let parent_before = repo.get_commit_sha(parent);
let current_before = repo.get_commit_sha(current);
repo.run_stax(&["checkout", current]).assert_success();
repo.create_file("prep.txt", "prep work\n");
let output = repo.run_stax(&["create", "-a", "-m", "Prep below", "--below"]);
output.assert_success();
output.assert_stdout_contains("Committed: Prep below");
let prep_branch = repo.current_branch();
assert!(
prep_branch.to_lowercase().contains("prep-below"),
"Expected generated prep-below branch, got: {}",
prep_branch
);
let subject = repo.git(&["log", "-1", "--pretty=%s"]);
assert!(subject.status.success(), "{}", TestRepo::stderr(&subject));
assert_eq!(TestRepo::stdout(&subject).trim(), "Prep below");
let commit_parent = repo.git(&["rev-parse", "HEAD^"]);
assert!(
commit_parent.status.success(),
"{}",
TestRepo::stderr(&commit_parent)
);
assert_eq!(
TestRepo::stdout(&commit_parent).trim(),
parent_before,
"below commit should be based on the original parent"
);
assert_eq!(
repo.get_commit_sha(current),
current_before,
"original branch should not advance when committing below it"
);
repo.run_stax(&["checkout", current]).assert_success();
assert_current_parent_contains(&repo, "prep-below");
assert!(
branch_needs_restack(&repo, current),
"original branch should need restack after the below branch gets a commit"
);
}
#[test]
#[cfg(unix)]
fn test_create_below_restores_original_metadata_when_commit_fails() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
let branches = repo.create_stack(&["below-fail-parent", "below-fail-current"]);
let parent = &branches[0];
let current = &branches[1];
let current_before = repo.get_commit_sha(current);
repo.run_stax(&["checkout", current]).assert_success();
repo.create_file("fail-below.txt", "work that should stay\n");
install_failing_pre_commit_hook(&repo);
let output = repo.run_stax(&["create", "-a", "-m", "Fail below", "--below"]);
output.assert_failure();
output.assert_stderr_contains("No branch was created");
assert_eq!(
repo.current_branch(),
*current,
"rollback should return to the original branch"
);
assert_eq!(
repo.get_commit_sha(current),
current_before,
"original branch should not advance after failed below commit"
);
assert!(
!repo
.list_branches()
.iter()
.any(|branch| branch.to_lowercase().contains("fail-below")),
"failed below branch should be deleted"
);
assert_current_parent_contains(&repo, parent);
assert!(
repo.path().join("fail-below.txt").exists(),
"rollback should preserve the user's working-tree file"
);
repo.run_stax(&["create", "-a", "-m", "Fail below", "--below"])
.assert_failure();
assert!(
!repo
.list_branches()
.iter()
.any(|branch| branch.to_lowercase().contains("fail-below-2")),
"retry should not drift into a suffixed branch"
);
}
#[test]
#[cfg(unix)]
fn test_create_below_no_verify_skips_hook() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
let branches = repo.create_stack(&["below-no-verify-parent", "below-no-verify-current"]);
let current = &branches[1];
let current_before = repo.get_commit_sha(current);
repo.run_stax(&["checkout", current]).assert_success();
repo.create_file("skip-below-hook.txt", "work that should commit\n");
install_failing_pre_commit_hook(&repo);
let output = repo.run_stax(&[
"create",
"-a",
"-m",
"Skip below hook",
"--below",
"--no-verify",
]);
output.assert_success();
output.assert_stdout_contains("Committed: Skip below hook");
let new_branch = repo.current_branch();
assert!(
new_branch.to_lowercase().contains("skip-below-hook"),
"Expected generated skip-below-hook branch, got: {}",
new_branch
);
let subject = repo.git(&["log", "-1", "--pretty=%s"]);
assert!(subject.status.success(), "{}", TestRepo::stderr(&subject));
assert_eq!(TestRepo::stdout(&subject).trim(), "Skip below hook");
assert_eq!(
repo.get_commit_sha(current),
current_before,
"original branch should not advance when committing below it"
);
repo.run_stax(&["checkout", current]).assert_success();
assert_current_parent_contains(&repo, "skip-below-hook");
}
#[test]
fn test_create_below_rejects_from() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
repo.create_stack(&["below-from-conflict"]);
let output = repo.run_stax(&["create", "bad", "--below", "--from", "main"]);
output.assert_failure();
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("cannot be used") || stderr.contains("conflict"),
"Expected conflicting --from error, got: {}",
stderr
);
}
#[test]
fn test_create_below_untracked_branch_fails() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
repo.git(&["checkout", "-b", "manual-untracked"]);
let output = repo.run_stax(&["create", "bad", "--below"]);
output.assert_failure();
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("not tracked") || stderr.contains("branch track"),
"Expected untracked branch guidance, got: {}",
stderr
);
}
#[test]
fn test_create_below_from_trunk_fails() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
let output = repo.run_stax(&["create", "below-main", "--below"]);
output.assert_failure();
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("below trunk") || stderr.contains("Checkout a stacked branch"),
"Expected below-trunk guidance, got: {}",
stderr
);
}
#[test]
fn test_create_below_rejects_conflicting_placement_flags() {
let repo = TestRepo::new();
repo.run_stax(&["status"]).assert_success();
repo.create_stack(&["below-conflict"]);
let output = repo.run_stax(&["create", "bad", "--below", "--insert"]);
output.assert_failure();
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("cannot be used") || stderr.contains("conflict"),
"Expected conflicting flag error, got: {}",
stderr
);
}