use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;
fn stax_bin() -> &'static str {
env!("CARGO_BIN_EXE_stax")
}
fn test_tempdir() -> TempDir {
if let Ok(root) = std::env::var("STAX_TEST_TMPDIR") {
let root_path = Path::new(&root);
fs::create_dir_all(root_path).expect("Failed to create STAX_TEST_TMPDIR");
TempDir::new_in(root_path).expect("Failed to create temp dir in STAX_TEST_TMPDIR")
} else {
TempDir::new().expect("Failed to create temp dir")
}
}
fn sanitized_stax_command() -> Command {
let mut cmd = Command::new(stax_bin());
let null_path = if cfg!(windows) { "NUL" } else { "/dev/null" };
cmd.env_remove("GITHUB_TOKEN")
.env_remove("STAX_GITHUB_TOKEN")
.env_remove("GH_TOKEN")
.env("GIT_CONFIG_GLOBAL", null_path)
.env("GIT_CONFIG_SYSTEM", null_path)
.env("STAX_DISABLE_UPDATE_CHECK", "1");
cmd
}
fn hermetic_git_command() -> Command {
let mut cmd = Command::new("git");
let null_path = if cfg!(windows) { "NUL" } else { "/dev/null" };
cmd.env("GIT_CONFIG_GLOBAL", null_path)
.env("GIT_CONFIG_SYSTEM", null_path);
cmd
}
struct TestRepo {
dir: TempDir,
#[allow(dead_code)]
remote_dir: Option<TempDir>,
}
impl TestRepo {
fn new() -> Self {
let dir = test_tempdir();
let path = dir.path();
hermetic_git_command()
.args(["init", "-b", "main"])
.current_dir(path)
.output()
.expect("Failed to init git repo");
hermetic_git_command()
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.expect("Failed to set git email");
hermetic_git_command()
.args(["config", "user.name", "Test User"])
.current_dir(path)
.output()
.expect("Failed to set git name");
let readme = path.join("README.md");
fs::write(&readme, "# Test Repo\n").expect("Failed to write README");
hermetic_git_command()
.args(["add", "-A"])
.current_dir(path)
.output()
.expect("Failed to stage files");
hermetic_git_command()
.args(["commit", "-m", "Initial commit"])
.current_dir(path)
.output()
.expect("Failed to create initial commit");
Self {
dir,
remote_dir: None,
}
}
fn new_with_remote() -> Self {
let mut repo = Self::new();
let remote_dir = test_tempdir();
hermetic_git_command()
.args(["init", "--bare"])
.current_dir(remote_dir.path())
.output()
.expect("Failed to init bare repo");
hermetic_git_command()
.args([
"remote",
"add",
"origin",
remote_dir.path().to_str().unwrap(),
])
.current_dir(repo.path())
.output()
.expect("Failed to add remote");
hermetic_git_command()
.args(["push", "-u", "origin", "main"])
.current_dir(repo.path())
.output()
.expect("Failed to push to origin");
repo.remote_dir = Some(remote_dir);
repo
}
fn remote_path(&self) -> Option<PathBuf> {
self.remote_dir.as_ref().map(|d| d.path().to_path_buf())
}
fn simulate_remote_commit(&self, filename: &str, content: &str, message: &str) {
let remote_path = self.remote_path().expect("No remote configured");
let clone_dir = test_tempdir();
hermetic_git_command()
.args(["clone", remote_path.to_str().unwrap(), "."])
.current_dir(clone_dir.path())
.output()
.expect("Failed to clone remote");
hermetic_git_command()
.args(["checkout", "-B", "main", "origin/main"])
.current_dir(clone_dir.path())
.output()
.expect("Failed to checkout main");
hermetic_git_command()
.args(["config", "user.email", "other@test.com"])
.current_dir(clone_dir.path())
.output()
.expect("Failed to set git email");
hermetic_git_command()
.args(["config", "user.name", "Other User"])
.current_dir(clone_dir.path())
.output()
.expect("Failed to set git name");
fs::write(clone_dir.path().join(filename), content).expect("Failed to write file");
hermetic_git_command()
.args(["add", "-A"])
.current_dir(clone_dir.path())
.output()
.expect("Failed to stage");
hermetic_git_command()
.args(["commit", "-m", message])
.current_dir(clone_dir.path())
.output()
.expect("Failed to commit");
hermetic_git_command()
.args(["push", "origin", "main"])
.current_dir(clone_dir.path())
.output()
.expect("Failed to push to origin");
}
fn merge_branch_on_remote(&self, branch: &str) {
let remote_path = self.remote_path().expect("No remote configured");
let clone_dir = test_tempdir();
hermetic_git_command()
.args(["clone", remote_path.to_str().unwrap(), "."])
.current_dir(clone_dir.path())
.output()
.expect("Failed to clone remote");
hermetic_git_command()
.args(["checkout", "-B", "main", "origin/main"])
.current_dir(clone_dir.path())
.output()
.expect("Failed to checkout main");
hermetic_git_command()
.args(["config", "user.email", "merger@test.com"])
.current_dir(clone_dir.path())
.output()
.expect("Failed to set git email");
hermetic_git_command()
.args(["config", "user.name", "Merger"])
.current_dir(clone_dir.path())
.output()
.expect("Failed to set git name");
hermetic_git_command()
.args(["fetch", "origin", branch])
.current_dir(clone_dir.path())
.output()
.expect("Failed to fetch branch");
hermetic_git_command()
.args([
"merge",
&format!("origin/{}", branch),
"--no-ff",
"-m",
&format!("Merge {}", branch),
])
.current_dir(clone_dir.path())
.output()
.expect("Failed to merge branch");
hermetic_git_command()
.args(["push", "origin", "main"])
.current_dir(clone_dir.path())
.output()
.expect("Failed to push merge");
}
fn list_remote_branches(&self) -> Vec<String> {
let output = hermetic_git_command()
.args(["ls-remote", "--heads", "origin"])
.current_dir(self.path())
.output()
.expect("Failed to list remote branches");
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split("refs/heads/").nth(1).map(|s| s.to_string()))
.collect()
}
fn find_branch_containing(&self, pattern: &str) -> Option<String> {
self.list_branches()
.into_iter()
.find(|b| b.contains(pattern))
}
fn current_branch_contains(&self, pattern: &str) -> bool {
self.current_branch().contains(pattern)
}
fn path(&self) -> PathBuf {
self.dir.path().to_path_buf()
}
fn run_stax(&self, args: &[&str]) -> Output {
sanitized_stax_command()
.args(args)
.current_dir(self.path())
.output()
.expect("Failed to execute stax")
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
fn create_file(&self, name: &str, content: &str) {
let file_path = self.path().join(name);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).expect("Failed to create parent dirs");
}
fs::write(file_path, content).expect("Failed to write file");
}
fn commit(&self, message: &str) {
hermetic_git_command()
.args(["add", "-A"])
.current_dir(self.path())
.output()
.expect("Failed to stage files");
hermetic_git_command()
.args(["commit", "-m", message])
.current_dir(self.path())
.output()
.expect("Failed to commit");
}
fn current_branch(&self) -> String {
let output = hermetic_git_command()
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(self.path())
.output()
.expect("Failed to get current branch");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn list_branches(&self) -> Vec<String> {
let output = hermetic_git_command()
.args(["branch", "--format=%(refname:short)"])
.current_dir(self.path())
.output()
.expect("Failed to list branches");
String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.to_string())
.collect()
}
fn get_commit_sha(&self, reference: &str) -> String {
let output = hermetic_git_command()
.args(["rev-parse", reference])
.current_dir(self.path())
.output()
.expect("Failed to get commit SHA");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn head_sha(&self) -> String {
self.get_commit_sha("HEAD")
}
fn git(&self, args: &[&str]) -> Output {
hermetic_git_command()
.args(args)
.current_dir(self.path())
.output()
.expect("Failed to run git command")
}
fn create_restack_progress_conflict_scenario(&self) -> (String, String) {
self.run_stax(&["bc", "progress-parent"]);
let parent = self.current_branch();
self.create_file("parent.txt", "parent content\n");
self.commit("Parent commit");
self.run_stax(&["bc", "progress-child"]);
let child = self.current_branch();
self.create_file("conflict.txt", "child content\n");
self.commit("Child conflict commit");
self.run_stax(&["t"]);
self.create_file("main-update.txt", "main update\n");
self.create_file("conflict.txt", "main content\n");
self.commit("Main conflict commit");
self.run_stax(&["checkout", &child]);
(parent, child)
}
}
fn configure_submit_remote(repo: &TestRepo) {
let remote_path = repo
.remote_path()
.expect("Expected remote path for repository with origin");
let remote_path_str = remote_path.to_string_lossy().to_string();
repo.git(&[
"remote",
"set-url",
"origin",
"https://github.com/test-owner/test-repo.git",
]);
repo.git(&["remote", "set-url", "--push", "origin", &remote_path_str]);
}
fn list_remote_heads(repo: &TestRepo) -> Vec<String> {
let remote_path = repo
.remote_path()
.expect("Expected remote path for repository with origin");
let output = hermetic_git_command()
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads"])
.current_dir(remote_path)
.output()
.expect("Failed to read bare remote refs");
String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[test]
fn test_repo_setup() {
let repo = TestRepo::new();
assert!(repo.path().exists());
assert_eq!(repo.current_branch(), "main");
assert!(repo.list_branches().contains(&"main".to_string()));
}
#[test]
fn test_branch_create_simple() {
let repo = TestRepo::new();
let output = repo.run_stax(&["bc", "feature-1"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert!(repo.current_branch_contains("feature-1"));
assert!(repo.find_branch_containing("feature-1").is_some());
}
#[test]
fn test_branch_create_with_message() {
let repo = TestRepo::new();
repo.create_file("new_feature.rs", "fn main() {}");
let output = repo.run_stax(&["bc", "-m", "Add new feature"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let branches = repo.list_branches();
assert!(
branches
.iter()
.any(|b| b.contains("add-new-feature") || b.contains("Add-new-feature")),
"Expected branch from message, got: {:?}",
branches
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("Committed") || stdout.contains("No changes"),
"Expected commit message, got: {}",
stdout
);
}
#[test]
fn test_branch_create_with_message_uses_unique_suffix_on_collision() {
let repo = TestRepo::new();
let output = repo.run_stax(&["bc", "-m", "Add new feature"]);
assert!(
output.status.success(),
"Failed first create: {}",
TestRepo::stderr(&output)
);
let first_branch = repo.current_branch();
let output = repo.run_stax(&["bc", "-m", "Add new feature"]);
assert!(
output.status.success(),
"Failed second create: {}",
TestRepo::stderr(&output)
);
let second_branch = repo.current_branch();
assert_ne!(first_branch, second_branch);
assert!(
second_branch.ends_with("-2") && second_branch.to_lowercase().contains("new-feature"),
"Expected suffixed branch name, got: {}",
second_branch
);
let branches = repo.list_branches();
assert!(branches.iter().any(|b| b == &first_branch));
assert!(branches.iter().any(|b| b == &second_branch));
}
#[test]
fn test_branch_create_from_another_branch() {
let repo = TestRepo::new();
let output = repo.run_stax(&["bc", "feature-1"]);
assert!(output.status.success());
repo.create_file("feature1.txt", "feature 1 content");
repo.commit("Add feature 1");
let output = repo.run_stax(&["bc", "feature-2", "--from", "main"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert!(repo.current_branch_contains("feature-2"));
assert!(!repo.path().join("feature1.txt").exists());
}
#[test]
fn test_branch_create_nested() {
let repo = TestRepo::new();
let output = repo.run_stax(&["bc", "feature-1"]);
assert!(output.status.success());
assert!(repo.current_branch_contains("feature-1"));
let output = repo.run_stax(&["bc", "feature-2"]);
assert!(output.status.success());
assert!(repo.current_branch_contains("feature-2"));
let output = repo.run_stax(&["bc", "feature-3"]);
assert!(output.status.success());
assert!(repo.current_branch_contains("feature-3"));
assert!(repo.find_branch_containing("feature-1").is_some());
assert!(repo.find_branch_containing("feature-2").is_some());
assert!(repo.find_branch_containing("feature-3").is_some());
}
#[test]
fn test_branch_create_exact_name_conflict_has_clear_error() {
let repo = TestRepo::new();
let output = repo.run_stax(&["bc", "feature-1"]);
assert!(output.status.success());
let output = repo.run_stax(&["bc", "feature-1"]);
assert!(!output.status.success());
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("already exists") && stderr.contains("Use `st checkout "),
"Expected exact-conflict guidance, got: {}",
stderr
);
}
#[test]
fn test_branch_create_requires_name() {
let repo = TestRepo::new();
let output = repo.run_stax(&["bc"]);
assert!(!output.status.success());
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("name") || stderr.contains("required"),
"Expected error about name, got: {}",
stderr
);
}
#[test]
fn test_branch_create_requires_name_via_create_alias() {
let repo = TestRepo::new();
let output = repo.run_stax(&["create"]);
assert!(!output.status.success());
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("name") || stderr.contains("required") || stderr.contains("stax create"),
"Expected error about name, got: {}",
stderr
);
}
#[test]
fn test_branch_create_wizard_shows_usage_hint() {
let repo = TestRepo::new();
let output = repo.run_stax(&["create"]);
assert!(!output.status.success());
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("stax create <name>") || stderr.contains("-m"),
"Expected usage hint in error, got: {}",
stderr
);
}
#[test]
fn test_status_empty_stack() {
let repo = TestRepo::new();
let output = repo.run_stax(&["status"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("main"),
"Expected main in output: {}",
stdout
);
}
#[test]
fn test_status_with_branches() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let output = repo.run_stax(&["status"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("feature-1"),
"Expected feature-1 in output: {}",
stdout
);
assert!(
stdout.contains("main"),
"Expected main in output: {}",
stdout
);
}
#[test]
fn test_status_orders_behind_before_ahead() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature");
repo.commit("Feature commit");
repo.run_stax(&["t"]);
repo.create_file("main.txt", "main");
repo.commit("Main commit");
let output = repo.run_stax(&["ll"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
let line = stdout
.lines()
.find(|line| line.contains(&branch_name))
.expect("Expected branch line in status output");
let behind_pos = line
.find("behind")
.expect("Expected 'behind' in status output line");
let ahead_pos = line
.find("ahead")
.expect("Expected 'ahead' in status output line");
assert!(
behind_pos < ahead_pos,
"Expected 'behind' before 'ahead' in status output line: {}",
line
);
}
#[test]
fn test_status_json_output() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let output = repo.run_stax(&["status", "--json"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).expect("Invalid JSON output");
assert_eq!(json["trunk"], "main");
assert!(json["branches"].is_array());
let branches = json["branches"].as_array().unwrap();
assert!(
branches
.iter()
.any(|b| b["name"].as_str().unwrap_or("").contains("feature-1")),
"Expected branch containing feature-1 in branches: {:?}",
branches
);
}
#[test]
fn test_status_compact_output() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let output = repo.run_stax(&["status", "--compact"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(stdout.contains("feature-1"));
assert!(stdout.contains('\t'));
}
#[test]
fn test_status_alias_s() {
let repo = TestRepo::new();
let output1 = repo.run_stax(&["status"]);
let output2 = repo.run_stax(&["s"]);
assert!(output1.status.success());
assert!(output2.status.success());
}
#[test]
fn test_log_command() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.create_file("feature.txt", "content");
repo.commit("Add feature");
let output = repo.run_stax(&["log"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
}
#[test]
fn test_log_json_output() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let output = repo.run_stax(&["log", "--json"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).expect("Invalid JSON output");
assert!(json["branches"].is_array());
}
#[test]
fn test_trunk_command() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
assert!(repo.current_branch_contains("feature-1"));
let output = repo.run_stax(&["trunk"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert_eq!(repo.current_branch(), "main");
}
#[test]
fn test_trunk_alias_t() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let output = repo.run_stax(&["t"]);
assert!(output.status.success());
assert_eq!(repo.current_branch(), "main");
}
#[test]
fn test_branch_down_bd() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.run_stax(&["bc", "feature-2"]);
assert!(repo.current_branch_contains("feature-2"));
let output = repo.run_stax(&["bd"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert!(repo.current_branch_contains("feature-1"));
let output = repo.run_stax(&["bd"]);
assert!(output.status.success());
assert_eq!(repo.current_branch(), "main");
}
#[test]
fn test_branch_up_bu() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.run_stax(&["t"]);
assert_eq!(repo.current_branch(), "main");
let output = repo.run_stax(&["bu"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert!(repo.current_branch_contains("feature-1"));
}
#[test]
fn test_checkout_explicit_branch() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let feature_branch = repo.current_branch();
repo.run_stax(&["t"]);
assert_eq!(repo.current_branch(), "main");
let output = repo.run_stax(&["checkout", &feature_branch]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert!(repo.current_branch_contains("feature-1"));
}
#[test]
fn test_checkout_trunk_flag() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let output = repo.run_stax(&["checkout", "--trunk"]);
assert!(output.status.success());
assert_eq!(repo.current_branch(), "main");
}
#[test]
fn test_checkout_parent_flag() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.run_stax(&["bc", "feature-2"]);
assert!(repo.current_branch_contains("feature-2"));
let output = repo.run_stax(&["checkout", "--parent"]);
assert!(output.status.success());
assert!(repo.current_branch_contains("feature-1"));
}
#[test]
fn test_checkout_alias_co() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let feature_branch = repo.current_branch();
repo.run_stax(&["t"]);
let output = repo.run_stax(&["co", &feature_branch]);
assert!(output.status.success());
assert!(repo.current_branch_contains("feature-1"));
}
#[test]
fn test_branch_track() {
let repo = TestRepo::new();
repo.git(&["checkout", "-b", "untracked-branch"]);
repo.create_file("untracked.txt", "content");
repo.commit("Untracked commit");
let output = repo.run_stax(&["branch", "track", "--parent", "main"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let output = repo.run_stax(&["status", "--json"]);
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).unwrap();
let branches = json["branches"].as_array().unwrap();
assert!(
branches.iter().any(|b| b["name"] == "untracked-branch"),
"Expected untracked-branch to be tracked"
);
}
#[test]
fn test_branch_reparent() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let feature1_name = repo.current_branch();
repo.run_stax(&["t"]);
repo.run_stax(&["bc", "feature-2"]);
let feature2_name = repo.current_branch();
let output = repo.run_stax(&["branch", "reparent", "--parent", &feature1_name]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let output = repo.run_stax(&["status", "--json"]);
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).unwrap();
let branches = json["branches"].as_array().unwrap();
let feature2 = branches
.iter()
.find(|b| b["name"].as_str().unwrap() == feature2_name)
.expect("Should find feature-2 branch");
assert!(
feature2["parent"].as_str().unwrap().contains("feature-1"),
"Expected parent to contain feature-1, got: {}",
feature2["parent"]
);
}
#[test]
fn test_branch_delete() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-to-delete"]);
let branch_name = repo.current_branch();
repo.run_stax(&["t"]);
let output = repo.run_stax(&["branch", "delete", &branch_name, "--force"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert!(repo.find_branch_containing("feature-to-delete").is_none());
}
#[test]
fn test_branch_squash() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-squash"]);
repo.create_file("file1.txt", "content 1");
repo.commit("Commit 1");
repo.create_file("file2.txt", "content 2");
repo.commit("Commit 2");
repo.create_file("file3.txt", "content 3");
repo.commit("Commit 3");
let log_output = repo.git(&["rev-list", "--count", "main..HEAD"]);
let count_before: i32 = String::from_utf8_lossy(&log_output.stdout)
.trim()
.parse()
.unwrap();
assert_eq!(count_before, 3);
let output = repo.run_stax(&["branch", "squash", "-m", "Squashed feature"]);
let _ = output;
}
#[test]
fn test_modify_amend() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-modify"]);
repo.create_file("feature.txt", "original content");
repo.commit("Initial feature");
let commit_before = repo.head_sha();
repo.create_file("feature.txt", "modified content");
let output = repo.run_stax(&["modify"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let commit_after = repo.head_sha();
assert_ne!(
commit_before, commit_after,
"Commit should have changed after amend"
);
}
#[test]
fn test_modify_with_message() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-modify"]);
repo.create_file("feature.txt", "content");
repo.commit("Old message");
repo.create_file("feature.txt", "new content");
let output = repo.run_stax(&["modify", "-m", "New commit message"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let log_output = repo.git(&["log", "-1", "--format=%s"]);
let message = String::from_utf8_lossy(&log_output.stdout)
.trim()
.to_string();
assert_eq!(message, "New commit message");
}
#[test]
fn test_modify_no_changes() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-no-changes"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let output = repo.run_stax(&["modify"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("No changes") || stdout.to_lowercase().contains("no changes"),
"Expected 'no changes' message, got: {}",
stdout
);
}
#[test]
fn test_modify_alias_m() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-m"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature");
repo.create_file("feature.txt", "modified");
let output = repo.run_stax(&["m"]);
assert!(output.status.success());
}
#[test]
fn test_restack_up_to_date() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let output = repo.run_stax(&["restack", "--quiet"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
}
#[test]
fn test_restack_after_parent_change() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let feature_branch = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.run_stax(&["t"]);
repo.create_file("main-update.txt", "main update");
repo.commit("Main update");
repo.run_stax(&["checkout", &feature_branch]);
let output = repo.run_stax(&["status", "--json"]);
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).unwrap();
let branches = json["branches"].as_array().unwrap();
let feature1 = branches
.iter()
.find(|b| b["name"].as_str().unwrap_or("").contains("feature-1"))
.expect("Should find feature-1 branch");
assert!(feature1["needs_restack"].as_bool().unwrap_or(false));
let output = repo.run_stax(&["restack", "--quiet"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let output = repo.run_stax(&["status", "--json"]);
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).unwrap();
let branches = json["branches"].as_array().unwrap();
let feature1 = branches
.iter()
.find(|b| b["name"].as_str().unwrap_or("").contains("feature-1"))
.expect("Should find feature-1 branch after restack");
assert!(!feature1["needs_restack"].as_bool().unwrap_or(true));
}
#[test]
fn test_restack_auto_normalizes_squash_merged_parent() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "restack-parent"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent 1\n");
repo.commit("Parent commit 1");
repo.run_stax(&["bc", "restack-child"]);
let child = repo.current_branch();
repo.create_file("child.txt", "child change\n");
repo.commit("Child commit");
repo.run_stax(&["t"]);
let squash = repo.git(&["merge", "--squash", &parent]);
assert!(
squash.status.success(),
"Failed squash merge: {}",
TestRepo::stderr(&squash)
);
repo.commit("Squash merge parent");
repo.run_stax(&["checkout", &child]);
let output = repo.run_stax(&["restack", "--quiet"]);
assert!(
output.status.success(),
"restack failed\nstdout: {}\nstderr: {}",
TestRepo::stdout(&output),
TestRepo::stderr(&output)
);
let metadata_ref = format!("refs/branch-metadata/{}", child);
let metadata_output = repo.git(&["show", &metadata_ref]);
assert!(
metadata_output.status.success(),
"Failed to read metadata: {}",
TestRepo::stderr(&metadata_output)
);
let metadata = TestRepo::stdout(&metadata_output);
assert!(
metadata.contains("\"parentBranchName\":\"main\""),
"Expected child to be reparented to trunk, metadata was: {}",
metadata
);
let count_output = repo.git(&["rev-list", "--count", &format!("main..{}", child)]);
assert!(count_output.status.success());
assert_eq!(
String::from_utf8_lossy(&count_output.stdout).trim(),
"1",
"Expected child to retain only novel commits after restack"
);
}
#[test]
fn test_restack_auto_normalizes_missing_parent() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "missing-parent"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent");
repo.commit("Parent commit");
repo.run_stax(&["bc", "missing-child"]);
let child = repo.current_branch();
repo.create_file("child.txt", "child");
repo.commit("Child commit");
let delete_parent = repo.git(&["branch", "-D", &parent]);
assert!(
delete_parent.status.success(),
"Failed to delete parent branch: {}",
TestRepo::stderr(&delete_parent)
);
let output = repo.run_stax(&["restack", "--quiet"]);
assert!(
output.status.success(),
"restack failed\nstdout: {}\nstderr: {}",
TestRepo::stdout(&output),
TestRepo::stderr(&output)
);
let metadata_ref = format!("refs/branch-metadata/{}", child);
let metadata_output = repo.git(&["show", &metadata_ref]);
assert!(
metadata_output.status.success(),
"Failed to read metadata: {}",
TestRepo::stderr(&metadata_output)
);
let metadata = TestRepo::stdout(&metadata_output);
assert!(
metadata.contains("\"parentBranchName\":\"main\""),
"Expected missing-parent child to be reparented to trunk, metadata was: {}",
metadata
);
}
#[test]
fn test_restack_cleanup_reparents_children_before_deleting_merged_parent() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "merged-parent"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent");
repo.commit("Parent commit");
repo.run_stax(&["bc", "merged-child"]);
let child = repo.current_branch();
repo.create_file("child.txt", "child");
repo.commit("Child commit");
repo.run_stax(&["t"]);
let merge_parent = repo.git(&["merge", "--no-ff", &parent, "-m", "Merge parent"]);
assert!(
merge_parent.status.success(),
"Failed to merge parent into main: {}",
TestRepo::stderr(&merge_parent)
);
repo.run_stax(&["bc", "cleanup-trigger"]);
let trigger = repo.current_branch();
repo.create_file("trigger.txt", "trigger");
repo.commit("Trigger commit");
repo.run_stax(&["t"]);
repo.create_file("main-update.txt", "main update");
repo.commit("Main update");
repo.run_stax(&["checkout", &trigger]);
let output = repo.run_stax(&["restack", "--yes"]);
assert!(
output.status.success(),
"restack failed\nstdout: {}\nstderr: {}",
TestRepo::stdout(&output),
TestRepo::stderr(&output)
);
let branches = repo.list_branches();
assert!(
!branches.iter().any(|b| b == &parent),
"Expected merged parent branch to be deleted during cleanup"
);
let metadata_ref = format!("refs/branch-metadata/{}", child);
let metadata_output = repo.git(&["show", &metadata_ref]);
assert!(
metadata_output.status.success(),
"Failed to read child metadata after cleanup: {}",
TestRepo::stderr(&metadata_output)
);
let metadata = TestRepo::stdout(&metadata_output);
assert!(
metadata.contains("\"parentBranchName\":\"main\""),
"Expected cleanup to reparent child before deleting parent, metadata was: {}",
metadata
);
}
#[test]
fn test_restack_all_flag() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.create_file("f1.txt", "content");
repo.commit("Feature 1");
repo.run_stax(&["bc", "feature-2"]);
repo.create_file("f2.txt", "content");
repo.commit("Feature 2");
repo.run_stax(&["t"]);
repo.create_file("main.txt", "main");
repo.commit("Main update");
repo.run_stax(&["checkout", "feature-2"]);
let output = repo.run_stax(&["restack", "--all", "--quiet"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
}
#[test]
fn test_restack_conflict_reports_branch_progress_and_files() {
let repo = TestRepo::new();
let (parent, child) = repo.create_restack_progress_conflict_scenario();
let output = repo.run_stax(&["restack", "--yes"]);
assert!(
output.status.success(),
"restack failed\nstdout: {}\nstderr: {}",
TestRepo::stdout(&output),
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("Restack stopped on conflict:"),
"Expected conflict heading, got: {}",
stdout
);
assert!(
stdout.contains(&format!("Stopped at: {}", child)),
"Expected stopped-at branch, got: {}",
stdout
);
assert!(
stdout.contains(&format!("Parent: {}", parent)),
"Expected parent branch, got: {}",
stdout
);
assert!(
stdout
.contains("Progress: 1 branch rebased before conflict, 0 branches remaining in stack"),
"Expected progress summary, got: {}",
stdout
);
assert!(
stdout.contains(&format!("Completed: {}", parent)),
"Expected completed branch list, got: {}",
stdout
);
assert!(
stdout.contains("Conflicted files:") && stdout.contains("conflict.txt"),
"Expected conflicted files in output, got: {}",
stdout
);
assert!(
stdout.contains("stax restack --continue"),
"Expected continue guidance, got: {}",
stdout
);
let abort = repo.git(&["rebase", "--abort"]);
assert!(
abort.status.success(),
"Failed to abort rebase during cleanup: {}",
TestRepo::stderr(&abort)
);
}
#[test]
fn test_cascade_no_submit_keeps_original_branch() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.run_stax(&["bc", "feature-2"]);
let original = repo.current_branch();
let output = repo.run_stax(&["cascade", "--no-submit"]);
assert!(output.status.success());
let after = repo.current_branch();
assert_eq!(after, original, "cascade should restore original branch");
}
#[test]
fn test_cascade_no_submit_from_middle_restacks_full_stack() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "cascade-base"]);
let base = repo.current_branch();
repo.create_file("base.txt", "base");
repo.commit("base commit");
repo.run_stax(&["bc", "cascade-middle"]);
let middle = repo.current_branch();
repo.create_file("middle.txt", "middle");
repo.commit("middle commit");
repo.run_stax(&["bc", "cascade-tip"]);
let tip = repo.current_branch();
repo.create_file("tip.txt", "tip");
repo.commit("tip commit");
repo.run_stax(&["t"]);
repo.create_file("main-update.txt", "main update");
repo.commit("main update");
repo.run_stax(&["checkout", &middle]);
let original = repo.current_branch();
let before_output = repo.run_stax(&["status", "--json"]);
assert!(
before_output.status.success(),
"Failed: {}",
TestRepo::stderr(&before_output)
);
let before_json: Value =
serde_json::from_str(&TestRepo::stdout(&before_output)).expect("Invalid JSON");
let before_branches = before_json["branches"]
.as_array()
.expect("Expected branches array");
let tracked = [&base, &middle, &tip];
assert!(
tracked.iter().any(|name| {
before_branches
.iter()
.find(|b| b["name"].as_str() == Some(name.as_str()))
.and_then(|b| b["needs_restack"].as_bool())
.unwrap_or(false)
}),
"Expected at least one stack branch to need restack before cascade"
);
let output = repo.run_stax(&["cascade", "--no-submit"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let after = repo.current_branch();
assert_eq!(after, original, "cascade should restore original branch");
let after_output = repo.run_stax(&["status", "--json"]);
assert!(
after_output.status.success(),
"Failed: {}",
TestRepo::stderr(&after_output)
);
let after_json: Value =
serde_json::from_str(&TestRepo::stdout(&after_output)).expect("Invalid JSON");
let after_branches = after_json["branches"]
.as_array()
.expect("Expected branches array");
for name in tracked {
let branch = after_branches
.iter()
.find(|b| b["name"].as_str() == Some(name.as_str()))
.unwrap_or_else(|| panic!("Missing branch {} in status output", name));
assert_eq!(
branch["needs_restack"],
Value::Bool(false),
"Expected {} to be fully restacked by cascade",
name
);
}
}
#[test]
fn test_cascade_conflict_reports_restack_context() {
let repo = TestRepo::new();
let (_parent, child) = repo.create_restack_progress_conflict_scenario();
let output = repo.run_stax(&["cascade", "--no-submit"]);
assert!(
output.status.success(),
"cascade failed\nstdout: {}\nstderr: {}",
TestRepo::stdout(&output),
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("Cascading stack..."),
"Expected cascade banner, got: {}",
stdout
);
assert!(
stdout.contains("Restack stopped on conflict:"),
"Expected restack conflict block, got: {}",
stdout
);
assert!(
stdout.contains(&format!("Stopped at: {}", child)),
"Expected stopped-at branch in cascade output, got: {}",
stdout
);
assert!(
stdout.contains("Conflicted files:") && stdout.contains("conflict.txt"),
"Expected conflicted files in cascade output, got: {}",
stdout
);
let abort = repo.git(&["rebase", "--abort"]);
assert!(
abort.status.success(),
"Failed to abort rebase during cleanup: {}",
TestRepo::stderr(&abort)
);
}
#[test]
fn test_branch_rename() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "old-name"]);
let old_branch = repo.current_branch();
assert!(old_branch.contains("old-name"));
let output = repo.run_stax(&["rename", "new-name"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let new_branch = repo.current_branch();
assert!(
new_branch.contains("new-name"),
"Expected branch with 'new-name', got: {}",
new_branch
);
assert!(!new_branch.contains("old-name"));
let branches = repo.list_branches();
assert!(
!branches.iter().any(|b| b.contains("old-name")),
"Old branch should not exist"
);
}
#[test]
fn test_branch_rename_updates_children() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "parent-branch"]);
let parent_name = repo.current_branch();
repo.run_stax(&["bc", "child-branch"]);
let child_name = repo.current_branch();
repo.run_stax(&["checkout", &parent_name]);
let output = repo.run_stax(&["rename", "renamed-parent"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let new_parent = repo.current_branch();
repo.run_stax(&["checkout", &child_name]);
let output = repo.run_stax(&["status", "--json"]);
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).unwrap();
let branches = json["branches"].as_array().unwrap();
let child = branches
.iter()
.find(|b| b["name"].as_str().unwrap() == child_name)
.expect("Should find child branch");
assert_eq!(
child["parent"].as_str().unwrap(),
new_parent,
"Child's parent should be updated to new name"
);
}
#[test]
fn test_branch_rename_trunk_fails() {
let repo = TestRepo::new();
let output = repo.run_stax(&["rename", "not-main"]);
assert!(!output.status.success(), "Should fail when renaming trunk");
let stderr = TestRepo::stderr(&output);
assert!(stderr.contains("trunk") || stderr.contains("Cannot rename"));
}
#[test]
fn test_doctor_command() {
let repo = TestRepo::new();
let output = repo.run_stax(&["doctor"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
}
#[test]
fn test_config_command() {
let repo = TestRepo::new();
let output = repo.run_stax(&["config"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(stdout.contains("Config path:"));
assert!(stdout.contains("config.toml"));
}
#[test]
fn test_status_outside_git_repo() {
#[cfg(unix)]
let dir = TempDir::new_in("/tmp").expect("Failed to create external temp dir");
#[cfg(not(unix))]
let dir = TempDir::new().expect("Failed to create external temp dir");
let output = sanitized_stax_command()
.args(["status"])
.current_dir(dir.path())
.output()
.expect("Failed to execute stax");
assert!(!output.status.success());
}
#[test]
fn test_checkout_nonexistent_branch() {
let repo = TestRepo::new();
let output = repo.run_stax(&["checkout", "nonexistent-branch"]);
assert!(!output.status.success());
}
#[test]
fn test_branch_delete_trunk_fails() {
let repo = TestRepo::new();
let output = repo.run_stax(&["branch", "delete", "main", "--force"]);
assert!(!output.status.success());
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("trunk") || stderr.contains("Cannot delete"),
"Expected error about trunk, got: {}",
stderr
);
}
#[test]
fn test_branch_delete_current_fails() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let feature_branch = repo.current_branch();
let output = repo.run_stax(&["branch", "delete", &feature_branch, "--force"]);
assert!(!output.status.success());
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("current") || stderr.contains("Checkout"),
"Expected error about current branch, got: {}",
stderr
);
}
#[test]
fn test_bd_at_bottom_of_stack() {
let repo = TestRepo::new();
let output = repo.run_stax(&["bd"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("bottom") || stdout.contains("trunk") || stdout.contains("Already"),
"Expected message about being at bottom, got: {}",
stdout
);
}
#[test]
fn test_bu_at_top_of_stack() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let output = repo.run_stax(&["bu"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("top") || stdout.contains("no child") || stdout.contains("Already"),
"Expected message about being at top, got: {}",
stdout
);
}
#[test]
fn test_multiple_stacks() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "stack1-feature"]);
repo.run_stax(&["t"]);
repo.run_stax(&["bc", "stack2-feature"]);
let output = repo.run_stax(&["status", "--json"]);
assert!(
output.status.success(),
"Status failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).expect("Invalid JSON");
let branches = json["branches"].as_array().unwrap();
assert!(
branches
.iter()
.any(|b| b["name"].as_str().unwrap_or("").contains("stack1-feature")),
"Expected stack1-feature in branches"
);
assert!(
branches
.iter()
.any(|b| b["name"].as_str().unwrap_or("").contains("stack2-feature")),
"Expected stack2-feature in branches"
);
}
#[test]
fn test_diff_command() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let output = repo.run_stax(&["diff"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
}
#[test]
fn test_range_diff_command() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let output = repo.run_stax(&["range-diff"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
}
#[test]
fn test_repo_with_remote_setup() {
let repo = TestRepo::new_with_remote();
let output = repo.git(&["remote", "-v"]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("origin"),
"Expected origin remote, got: {}",
stdout
);
let remote_branches = list_remote_heads(&repo);
assert!(remote_branches.contains(&"main".to_string()));
}
#[test]
fn test_push_branch_to_remote() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-push"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Add feature");
let output = repo.git(&["push", "-u", "origin", &branch_name]);
assert!(
output.status.success(),
"Failed to push: {}",
String::from_utf8_lossy(&output.stderr)
);
let remote_branches = list_remote_heads(&repo);
assert!(
remote_branches.iter().any(|b| b.contains("feature-push")),
"Expected feature-push on remote, got: {:?}",
remote_branches
);
}
#[test]
fn test_push_multiple_branches_to_remote() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-1"]);
let branch1 = repo.current_branch();
repo.create_file("f1.txt", "content 1");
repo.commit("Feature 1");
repo.run_stax(&["bc", "feature-2"]);
let branch2 = repo.current_branch();
repo.create_file("f2.txt", "content 2");
repo.commit("Feature 2");
repo.git(&["push", "-u", "origin", &branch1]);
repo.git(&["push", "-u", "origin", &branch2]);
let remote_branches = list_remote_heads(&repo);
assert!(
remote_branches.iter().any(|b| b.contains("feature-1")),
"Expected feature-1 on remote"
);
assert!(
remote_branches.iter().any(|b| b.contains("feature-2")),
"Expected feature-2 on remote"
);
}
#[test]
fn test_sync_pulls_trunk_updates() {
let repo = TestRepo::new_with_remote();
repo.simulate_remote_commit("remote-file.txt", "from remote", "Remote commit");
assert!(!repo.path().join("remote-file.txt").exists());
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert!(
repo.path().join("remote-file.txt").exists(),
"Expected remote-file.txt to be pulled"
);
}
#[test]
fn test_sync_with_feature_branch() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-sync"]);
repo.create_file("feature.txt", "feature");
repo.commit("Feature commit");
repo.simulate_remote_commit("remote.txt", "remote content", "Remote update");
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("Sync") || stdout.contains("complete") || stdout.contains("Updating"),
"Expected sync output, got: {}",
stdout
);
}
#[test]
fn test_sync_verbose_shows_step_timing_summary() {
let repo = TestRepo::new_with_remote();
let output = repo.run_stax(&["sync", "--force", "--verbose"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("Sync timing summary:"),
"Expected timing summary in verbose sync output, got: {}",
stdout
);
assert!(
stdout.contains("fetch origin"),
"Expected fetch step timing in verbose sync output, got: {}",
stdout
);
assert!(
stdout.contains("total"),
"Expected total timing in verbose sync output, got: {}",
stdout
);
}
#[test]
fn test_sync_with_restack_flag() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-restack"]);
let feature_branch = repo.current_branch();
repo.create_file("feature.txt", "feature");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &feature_branch]);
repo.simulate_remote_commit("remote.txt", "content", "Remote update");
let output = repo.run_stax(&["sync", "--restack", "--force"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert!(repo.current_branch_contains("feature-restack"));
repo.run_stax(&["checkout", &feature_branch]);
}
#[test]
fn test_sync_restack_only_targets_current_stack() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "stack-a1"]);
let a1 = repo.current_branch();
repo.create_file("a1.txt", "a1");
repo.commit("a1 commit");
repo.run_stax(&["bc", "stack-a2"]);
let a2 = repo.current_branch();
repo.create_file("a2.txt", "a2");
repo.commit("a2 commit");
repo.run_stax(&["t"]);
repo.run_stax(&["bc", "stack-b1"]);
let b1 = repo.current_branch();
repo.create_file("b1.txt", "b1");
repo.commit("b1 commit");
repo.run_stax(&["t"]);
repo.create_file("main.txt", "main change");
repo.commit("main commit");
repo.run_stax(&["checkout", &a2]);
let b1_before = repo.get_commit_sha(&b1);
let output = repo.run_stax(&["sync", "--restack", "--force"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let b1_after = repo.get_commit_sha(&b1);
assert_eq!(
b1_before, b1_after,
"Expected unrelated stack branch to remain untouched by sync --restack"
);
let status_output = repo.run_stax(&["status", "--json"]);
assert!(
status_output.status.success(),
"Failed: {}",
TestRepo::stderr(&status_output)
);
let status_json: Value =
serde_json::from_str(&TestRepo::stdout(&status_output)).expect("Invalid JSON");
let branches = status_json["branches"]
.as_array()
.expect("Expected branches array");
let b1_entry = branches
.iter()
.find(|b| b["name"].as_str().unwrap_or("") == b1)
.expect("Expected b1 in status");
assert_eq!(b1_entry["needs_restack"], Value::Bool(true));
assert!(!a1.is_empty());
}
#[test]
fn test_sync_deletes_merged_branches() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-merged"]);
let feature_branch = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &feature_branch]);
repo.run_stax(&["t"]);
repo.merge_branch_on_remote(&feature_branch);
repo.git(&["pull", "origin", "main"]);
let merged_output = repo.git(&["branch", "--merged", "main"]);
let merged_str = String::from_utf8_lossy(&merged_output.stdout);
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let branches = repo.list_branches();
if branches.iter().any(|b| b.contains("feature-merged")) {
assert!(
!merged_str.contains("feature-merged") || merged_str.contains("feature-merged"),
"Sync completed but branch handling may differ"
);
}
}
#[test]
fn test_sync_preserves_unmerged_branches() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-unmerged"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &branch_name]);
repo.run_stax(&["t"]);
let output = repo.run_stax(&["sync", "--force"]);
assert!(output.status.success());
let branches = repo.list_branches();
assert!(
branches.iter().any(|b| b.contains("feature-unmerged")),
"Expected feature-unmerged to still exist"
);
}
#[test]
fn test_submit_without_remote_fails_gracefully() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.create_file("f.txt", "content");
repo.commit("Feature");
let output = repo.run_stax(&["submit", "--no-pr", "--yes"]);
assert!(!output.status.success());
}
#[test]
fn test_branch_submit_no_pr_pushes_only_current_branch() {
let repo = TestRepo::new_with_remote();
configure_submit_remote(&repo);
repo.run_stax(&["bc", "scope-a"]);
let branch_a = repo.current_branch();
repo.create_file("a.txt", "a");
repo.commit("A commit");
repo.run_stax(&["t"]);
repo.run_stax(&["bc", "scope-b"]);
repo.create_file("b.txt", "b");
repo.commit("B commit");
repo.run_stax(&["checkout", &branch_a]);
let output = repo.run_stax(&["branch", "submit", "--no-pr", "--yes"]);
assert!(
output.status.success(),
"branch submit failed: {}",
TestRepo::stderr(&output)
);
let remote_branches = list_remote_heads(&repo);
assert!(
remote_branches
.iter()
.any(|b| b == &branch_a || b.contains("scope-a")),
"Expected scope-a branch on remote: {:?}",
remote_branches
);
assert!(
!remote_branches.iter().any(|b| b.contains("scope-b")),
"scope-b should not be submitted by branch submit"
);
}
#[test]
fn test_downstack_submit_no_pr_pushes_ancestors_and_current() {
let repo = TestRepo::new_with_remote();
configure_submit_remote(&repo);
repo.run_stax(&["bc", "ds-parent"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent");
repo.commit("Parent commit");
repo.run_stax(&["bc", "ds-middle"]);
let middle = repo.current_branch();
repo.create_file("middle.txt", "middle");
repo.commit("Middle commit");
repo.run_stax(&["bc", "ds-leaf"]);
let leaf = repo.current_branch();
repo.create_file("leaf.txt", "leaf");
repo.commit("Leaf commit");
repo.run_stax(&["checkout", &middle]);
let output = repo.run_stax(&["downstack", "submit", "--no-pr", "--yes"]);
assert!(
output.status.success(),
"downstack submit failed: {}",
TestRepo::stderr(&output)
);
let remote_branches = list_remote_heads(&repo);
assert!(
remote_branches
.iter()
.any(|b| b == &parent || b.contains("ds-parent")),
"Expected parent on remote: {:?}",
remote_branches
);
assert!(
remote_branches
.iter()
.any(|b| b == &middle || b.contains("ds-middle")),
"Expected middle on remote: {:?}",
remote_branches
);
assert!(
!remote_branches
.iter()
.any(|b| b == &leaf || b.contains("ds-leaf")),
"Leaf should not be submitted by downstack submit from middle"
);
}
#[test]
fn test_upstack_submit_no_pr_pushes_current_and_descendants() {
let repo = TestRepo::new_with_remote();
configure_submit_remote(&repo);
repo.run_stax(&["bc", "us-parent"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent");
repo.commit("Parent commit");
repo.git(&["push", "-u", "origin", &parent]);
repo.run_stax(&["bc", "us-middle"]);
let middle = repo.current_branch();
repo.create_file("middle.txt", "middle");
repo.commit("Middle commit");
repo.run_stax(&["bc", "us-leaf"]);
let leaf = repo.current_branch();
repo.create_file("leaf.txt", "leaf");
repo.commit("Leaf commit");
repo.run_stax(&["checkout", &middle]);
let output = repo.run_stax(&["upstack", "submit", "--no-pr", "--yes"]);
assert!(
output.status.success(),
"upstack submit failed: {}",
TestRepo::stderr(&output)
);
let remote_branches = list_remote_heads(&repo);
assert!(
remote_branches
.iter()
.any(|b| b == &middle || b.contains("us-middle")),
"Expected middle on remote: {:?}",
remote_branches
);
assert!(
remote_branches
.iter()
.any(|b| b == &leaf || b.contains("us-leaf")),
"Expected leaf on remote: {:?}",
remote_branches
);
assert!(
remote_branches
.iter()
.any(|b| b == &parent || b.contains("us-parent")),
"Expected parent branch to remain on remote after pre-push: {:?}",
remote_branches
);
}
#[test]
fn test_submit_no_pr_still_pushes_full_current_stack() {
let repo = TestRepo::new_with_remote();
configure_submit_remote(&repo);
repo.run_stax(&["bc", "stack-parent"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent");
repo.commit("Parent commit");
repo.run_stax(&["bc", "stack-middle"]);
let middle = repo.current_branch();
repo.create_file("middle.txt", "middle");
repo.commit("Middle commit");
repo.run_stax(&["bc", "stack-leaf"]);
let leaf = repo.current_branch();
repo.create_file("leaf.txt", "leaf");
repo.commit("Leaf commit");
repo.run_stax(&["checkout", &middle]);
let output = repo.run_stax(&["submit", "--no-pr", "--yes"]);
assert!(
output.status.success(),
"submit failed: {}",
TestRepo::stderr(&output)
);
let remote_branches = list_remote_heads(&repo);
assert!(
remote_branches
.iter()
.any(|b| b == &parent || b.contains("stack-parent")),
"Expected parent on remote: {:?}",
remote_branches
);
assert!(
remote_branches
.iter()
.any(|b| b == &middle || b.contains("stack-middle")),
"Expected middle on remote: {:?}",
remote_branches
);
assert!(
remote_branches
.iter()
.any(|b| b == &leaf || b.contains("stack-leaf")),
"Expected leaf on remote: {:?}",
remote_branches
);
}
#[test]
fn test_branch_submit_on_trunk_fails_with_actionable_message() {
let repo = TestRepo::new_with_remote();
configure_submit_remote(&repo);
let output = repo.run_stax(&["branch", "submit", "--no-pr", "--yes"]);
assert!(
!output.status.success(),
"branch submit on trunk should fail"
);
let combined = format!(
"{}\n{}",
TestRepo::stdout(&output),
TestRepo::stderr(&output)
);
assert!(
combined.contains("Cannot submit trunk") && combined.contains("stax submit"),
"Expected actionable trunk failure message, got: {}",
combined
);
}
#[test]
fn test_branch_submit_fails_when_parent_not_synced() {
let repo = TestRepo::new_with_remote();
configure_submit_remote(&repo);
repo.run_stax(&["bc", "sync-parent"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent");
repo.commit("Parent commit");
repo.git(&["push", "-u", "origin", &parent]);
repo.run_stax(&["bc", "sync-child"]);
let child = repo.current_branch();
repo.create_file("child.txt", "child");
repo.commit("Child commit");
repo.run_stax(&["checkout", &parent]);
repo.create_file("parent-local-only.txt", "local only");
repo.commit("Parent local-only commit");
repo.run_stax(&["checkout", &child]);
let output = repo.run_stax(&["branch", "submit", "--no-pr", "--yes"]);
assert!(
!output.status.success(),
"Expected scoped submit safety failure"
);
let combined = format!(
"{}\n{}",
TestRepo::stdout(&output),
TestRepo::stderr(&output)
);
assert!(
combined.contains("downstack submit") || combined.contains("stax submit"),
"Expected actionable message with ancestor scope suggestion, got: {}",
combined
);
}
#[test]
fn test_sync_without_remote_fails_gracefully() {
let repo = TestRepo::new();
let output = repo.run_stax(&["sync", "--force"]);
let _ = output;
}
#[test]
fn test_doctor_with_remote() {
let repo = TestRepo::new_with_remote();
let output = repo.run_stax(&["doctor"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("origin") || stdout.contains("remote") || stdout.contains("Remote"),
"Expected remote info in doctor output"
);
}
#[test]
fn test_status_shows_remote_indicator() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-remote"]);
let branch_name = repo.current_branch();
repo.create_file("f.txt", "content");
repo.commit("Feature");
repo.git(&["push", "-u", "origin", &branch_name]);
let output = repo.run_stax(&["status", "--json"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).unwrap();
let branches = json["branches"].as_array().unwrap();
let feature = branches
.iter()
.find(|b| b["name"].as_str().unwrap_or("").contains("feature-remote"))
.expect("Should find feature-remote");
assert!(
feature["has_remote"].as_bool().unwrap_or(false),
"Expected has_remote to be true for pushed branch. Branch info: {:?}",
feature
);
}
#[test]
fn test_force_push_after_amend() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-amend"]);
let branch_name = repo.current_branch();
repo.create_file("f.txt", "original");
repo.commit("Original commit");
repo.git(&["push", "-u", "origin", &branch_name]);
let sha_before = repo.head_sha();
repo.create_file("f.txt", "amended");
repo.run_stax(&["modify"]);
let sha_after = repo.head_sha();
assert_ne!(sha_before, sha_after, "SHA should change after amend");
let output = repo.git(&["push", "-f", "origin", &branch_name]);
assert!(
output.status.success(),
"Failed to force push: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[cfg(test)]
#[test]
fn test_rename_with_push_flag() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "old-remote-name"]);
let old_branch = repo.current_branch();
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &old_branch]);
let remote_branches = repo.list_remote_branches();
assert!(
remote_branches
.iter()
.any(|b| b.contains("old-remote-name")),
"Expected old-remote-name on remote before rename"
);
let output = repo.run_stax(&["rename", "new-remote-name", "--push"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let new_branch = repo.current_branch();
assert!(
new_branch.contains("new-remote-name"),
"Expected new-remote-name, got: {}",
new_branch
);
let remote_branches = repo.list_remote_branches();
assert!(
!remote_branches
.iter()
.any(|b| b.contains("old-remote-name")),
"Expected old-remote-name to be deleted from remote"
);
assert!(
remote_branches
.iter()
.any(|b| b.contains("new-remote-name")),
"Expected new-remote-name on remote"
);
}
#[test]
fn test_rename_without_push_flag_no_remote_change() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-no-push"]);
let old_branch = repo.current_branch();
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &old_branch]);
let output = repo.run_stax(&["rename", "renamed-no-push"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert!(repo.current_branch().contains("renamed-no-push"));
let remote_branches = repo.list_remote_branches();
assert!(
remote_branches
.iter()
.any(|b| b.contains("feature-no-push")),
"Expected old remote branch to still exist without --push flag"
);
}
#[test]
fn test_rename_push_help_shows_flag() {
let repo = TestRepo::new();
let output = repo.run_stax(&["rename", "--help"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("--push") || stdout.contains("-p"),
"Expected --push flag in help: {}",
stdout
);
}
#[test]
fn test_ll_command_runs() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-ll"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let output = repo.run_stax(&["ll"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("feature-ll"),
"Expected feature-ll in output: {}",
stdout
);
assert!(
stdout.contains("main"),
"Expected main in output: {}",
stdout
);
}
#[test]
fn test_ll_shows_pr_urls() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-with-pr"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &branch_name]);
let output = repo.run_stax(&["ll"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(stdout.contains("feature-with-pr") || stdout.contains(&branch_name));
}
#[test]
fn test_ll_json_output() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-ll-json"]);
let output = repo.run_stax(&["ll", "--json"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).expect("Invalid JSON output");
assert!(json["branches"].is_array());
}
#[test]
fn test_ll_compact_output() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-ll-compact"]);
let output = repo.run_stax(&["ll", "--compact"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(stdout.contains("feature-ll-compact"));
assert!(stdout.contains('\t')); }
#[test]
fn test_status_all_shows_all_stacks() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "stack-a-feature"]);
repo.create_file("a.txt", "content a");
repo.commit("Stack A commit");
repo.run_stax(&["t"]);
repo.run_stax(&["bc", "stack-b-feature"]);
repo.create_file("b.txt", "content b");
repo.commit("Stack B commit");
let output = repo.run_stax(&["status", "--current"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("stack-b-feature"),
"Should show current stack"
);
let output = repo.run_stax(&["status"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("stack-a-feature"),
"Should show stack A by default: {}",
stdout
);
assert!(
stdout.contains("stack-b-feature"),
"Should show stack B by default: {}",
stdout
);
}
#[test]
fn test_status_all_json_output() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "stack-1"]);
repo.run_stax(&["t"]);
repo.run_stax(&["bc", "stack-2"]);
let output = repo.run_stax(&["status", "--json"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).expect("Invalid JSON");
let branches = json["branches"].as_array().unwrap();
assert!(
branches
.iter()
.any(|b| b["name"].as_str().unwrap_or("").contains("stack-1")),
"Expected stack-1 in output"
);
assert!(
branches
.iter()
.any(|b| b["name"].as_str().unwrap_or("").contains("stack-2")),
"Expected stack-2 in output"
);
}
#[test]
fn test_status_without_all_shows_current_stack_only() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "current-stack-branch"]);
repo.create_file("current.txt", "content");
repo.commit("Current stack commit");
repo.run_stax(&["t"]);
repo.run_stax(&["bc", "other-stack-branch"]);
let first_branch = repo.find_branch_containing("current-stack-branch").unwrap();
repo.run_stax(&["checkout", &first_branch]);
let output = repo.run_stax(&["status", "--json"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).expect("Invalid JSON");
let branches = json["branches"].as_array().unwrap();
assert!(
branches.iter().any(|b| b["name"]
.as_str()
.unwrap_or("")
.contains("current-stack-branch")),
"Expected current-stack-branch in default output: {:?}",
branches
);
}
#[test]
fn test_status_shows_empty_branch_commits() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-with-commits"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
repo.run_stax(&["bc", "empty-child"]);
let output = repo.run_stax(&["status", "--json"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
let json: Value = serde_json::from_str(&stdout).expect("Invalid JSON");
let branches = json["branches"].as_array().unwrap();
assert!(
branches.iter().any(|b| b["name"]
.as_str()
.unwrap_or("")
.contains("feature-with-commits")),
"Expected feature-with-commits in status"
);
assert!(
branches
.iter()
.any(|b| b["name"].as_str().unwrap_or("").contains("empty-child")),
"Expected empty-child in status (even though empty)"
);
let empty_branch = branches
.iter()
.find(|b| b["name"].as_str().unwrap_or("").contains("empty-child"));
if let Some(eb) = empty_branch {
let ahead = eb["ahead"].as_i64().unwrap_or(-1);
assert_eq!(ahead, 0, "Empty branch should have 0 commits ahead");
}
}
#[test]
fn test_push_empty_branch_manually() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "parent-branch"]);
let parent_name = repo.current_branch();
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
repo.run_stax(&["bc", "empty-branch"]);
let empty_name = repo.current_branch();
let output1 = repo.git(&["push", "-u", "origin", &parent_name]);
assert!(output1.status.success(), "Failed to push parent");
let output2 = repo.git(&["push", "-u", "origin", &empty_name]);
assert!(output2.status.success(), "Failed to push empty branch");
let remote_branches = repo.list_remote_branches();
assert!(
remote_branches
.iter()
.any(|b| b.contains("parent-branch") || b == &parent_name),
"Expected parent-branch on remote"
);
assert!(
remote_branches
.iter()
.any(|b| b.contains("empty-branch") || b == &empty_name),
"Expected empty-branch on remote (even though empty)"
);
}
#[test]
fn test_submit_help_shows_no_pr_flag() {
let repo = TestRepo::new();
let output = repo.run_stax(&["submit", "--help"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(stdout.contains("--no-pr"), "Expected --no-pr flag in help");
assert!(stdout.contains("--yes"), "Expected --yes flag in help");
}
#[test]
fn test_restack_creates_backup_refs() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-backup"]);
let feature_branch = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.run_stax(&["t"]);
repo.create_file("main-update.txt", "main update");
repo.commit("Main update");
repo.run_stax(&["checkout", &feature_branch]);
let sha_before = repo.head_sha();
let output = repo.run_stax(&["restack", "--quiet"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let git_dir = repo.path().join(".git");
let stax_ops_dir = git_dir.join("stax").join("ops");
assert!(
stax_ops_dir.exists(),
"Expected .git/stax/ops directory to exist"
);
let ops: Vec<_> = std::fs::read_dir(&stax_ops_dir)
.expect("Failed to read stax ops dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect();
assert!(!ops.is_empty(), "Expected at least one operation receipt");
let receipt_path = ops[0].path();
let receipt_content = std::fs::read_to_string(&receipt_path).expect("Failed to read receipt");
let receipt: serde_json::Value =
serde_json::from_str(&receipt_content).expect("Invalid JSON receipt");
assert_eq!(receipt["kind"], "restack");
assert_eq!(receipt["status"], "success");
assert!(receipt["local_refs"].is_array());
let local_refs = receipt["local_refs"].as_array().unwrap();
let feature_ref = local_refs.iter().find(|r| {
r["branch"]
.as_str()
.unwrap_or("")
.contains("feature-backup")
});
assert!(
feature_ref.is_some(),
"Expected feature branch in local_refs"
);
if let Some(ref_entry) = feature_ref {
assert!(
ref_entry["oid_before"].is_string(),
"Expected oid_before to be recorded"
);
assert_eq!(ref_entry["oid_before"].as_str().unwrap(), sha_before);
}
}
#[test]
fn test_undo_restores_branch() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-undo"]);
let feature_branch = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
let sha_before = repo.head_sha();
repo.run_stax(&["t"]);
repo.create_file("main-update.txt", "main update");
repo.commit("Main update");
repo.run_stax(&["checkout", &feature_branch]);
let output = repo.run_stax(&["restack", "--quiet"]);
assert!(
output.status.success(),
"Restack failed: {}",
TestRepo::stderr(&output)
);
let sha_after_restack = repo.head_sha();
assert_ne!(
sha_before, sha_after_restack,
"SHA should change after restack"
);
let output = repo.run_stax(&["undo", "--yes"]);
assert!(
output.status.success(),
"Undo failed: {}",
TestRepo::stderr(&output)
);
let sha_after_undo = repo.head_sha();
assert_eq!(
sha_before, sha_after_undo,
"SHA should be restored after undo"
);
}
#[test]
fn test_undo_no_operations() {
let repo = TestRepo::new();
let output = repo.run_stax(&["undo"]);
assert!(
!output.status.success(),
"Expected undo to fail with no operations"
);
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("No operations") || stderr.contains("no operations"),
"Expected 'no operations' error, got: {}",
stderr
);
}
#[test]
fn test_redo_after_undo() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-redo"]);
let feature_branch = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
let sha_original = repo.head_sha();
repo.run_stax(&["t"]);
repo.create_file("main-update.txt", "main update");
repo.commit("Main update");
repo.run_stax(&["checkout", &feature_branch]);
let output = repo.run_stax(&["restack", "--quiet"]);
assert!(output.status.success());
let sha_after_restack = repo.head_sha();
let output = repo.run_stax(&["undo", "--yes"]);
assert!(output.status.success());
assert_eq!(repo.head_sha(), sha_original);
let output = repo.run_stax(&["redo", "--yes"]);
assert!(
output.status.success(),
"Redo failed: {}",
TestRepo::stderr(&output)
);
assert_eq!(repo.head_sha(), sha_after_restack);
}
#[test]
fn test_multiple_restacks_multiple_undos() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let feature1 = repo.current_branch();
repo.create_file("f1.txt", "feature 1");
repo.commit("Feature 1");
repo.run_stax(&["bc", "feature-2"]);
let _feature2 = repo.current_branch();
repo.create_file("f2.txt", "feature 2");
repo.commit("Feature 2");
let _sha_f2_original = repo.head_sha();
repo.run_stax(&["checkout", &feature1]);
let sha_f1_original = repo.head_sha();
repo.run_stax(&["t"]);
repo.create_file("main.txt", "main update");
repo.commit("Main update");
repo.run_stax(&["checkout", &feature1]);
let output = repo.run_stax(&["restack", "--quiet"]);
assert!(output.status.success());
let sha_f1_after_restack = repo.head_sha();
assert_ne!(sha_f1_original, sha_f1_after_restack);
let output = repo.run_stax(&["undo", "--yes"]);
assert!(output.status.success());
assert_eq!(repo.head_sha(), sha_f1_original);
}
#[test]
fn test_upstack_restack_creates_receipt() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let feature1 = repo.current_branch();
repo.create_file("f1.txt", "f1");
repo.commit("Feature 1");
repo.run_stax(&["bc", "feature-2"]);
repo.create_file("f2.txt", "f2");
repo.commit("Feature 2");
repo.run_stax(&["checkout", &feature1]);
repo.create_file("f1-update.txt", "f1 update");
repo.commit("Feature 1 update");
let output = repo.run_stax(&["upstack", "restack"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let git_dir = repo.path().join(".git");
let stax_ops_dir = git_dir.join("stax").join("ops");
let ops: Vec<_> = std::fs::read_dir(&stax_ops_dir)
.expect("Failed to read stax ops dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect();
let upstack_receipt = ops.iter().find(|op| {
let content = std::fs::read_to_string(op.path()).unwrap_or_default();
content.contains("upstack_restack")
});
assert!(
upstack_receipt.is_some(),
"Expected upstack_restack receipt"
);
}
#[test]
fn test_submit_requires_valid_remote_url() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-submit"]);
let feature_branch = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &feature_branch]);
let output = repo.run_stax(&["submit", "--no-pr", "--yes"]);
assert!(
!output.status.success(),
"Submit should fail with local bare repo"
);
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("Unsupported") || stderr.contains("remote"),
"Expected error about unsupported remote, got: {}",
stderr
);
}
#[test]
fn test_sync_restack_creates_receipt() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-sync"]);
let feature_branch = repo.current_branch();
repo.create_file("feature.txt", "feature");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &feature_branch]);
repo.simulate_remote_commit("remote.txt", "content", "Remote update");
let output = repo.run_stax(&["sync", "--restack", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let git_dir = repo.path().join(".git");
let stax_ops_dir = git_dir.join("stax").join("ops");
if stax_ops_dir.exists() {
let ops: Vec<_> = std::fs::read_dir(&stax_ops_dir)
.expect("Failed to read stax ops dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
.collect();
let _sync_receipt = ops.iter().find(|op| {
let content = std::fs::read_to_string(op.path()).unwrap_or_default();
content.contains("sync_restack")
});
if !ops.is_empty() {
assert!(ops.iter().all(|op| op
.path()
.extension()
.map(|e| e == "json")
.unwrap_or(false)));
}
}
}
#[test]
fn test_undo_with_dirty_working_tree() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-dirty"]);
let feature_branch = repo.current_branch();
repo.create_file("feature.txt", "feature");
repo.commit("Feature commit");
repo.run_stax(&["t"]);
repo.create_file("main.txt", "main");
repo.commit("Main update");
repo.run_stax(&["checkout", &feature_branch]);
repo.run_stax(&["restack", "--quiet"]);
repo.create_file("dirty.txt", "uncommitted changes");
let output = repo.run_stax(&["undo", "--quiet"]);
assert!(!output.status.success() || TestRepo::stderr(&output).contains("dirty"));
}
#[test]
fn test_sync_detects_branch_with_deleted_remote() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-deleted-remote"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &branch_name]);
let remote_branches = repo.list_remote_branches();
assert!(
remote_branches
.iter()
.any(|b| b.contains("feature-deleted-remote")),
"Expected branch on remote before deletion"
);
repo.git(&["push", "origin", "--delete", &branch_name]);
let remote_branches = repo.list_remote_branches();
assert!(
!remote_branches
.iter()
.any(|b| b.contains("feature-deleted-remote")),
"Expected branch to be deleted from remote"
);
repo.run_stax(&["t"]);
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("merged")
|| stdout.contains("feature-deleted-remote")
|| stdout.contains("deleted"),
"Expected sync to detect deleted remote branch, got: {}",
stdout
);
}
#[test]
fn test_sync_does_not_delete_untracked_upstream_gone_by_default() {
let repo = TestRepo::new_with_remote();
repo.git(&["checkout", "-b", "manual-upstream-gone"]);
repo.create_file("manual.txt", "manual branch content");
repo.commit("Manual branch commit");
repo.git(&["push", "-u", "origin", "manual-upstream-gone"]);
repo.git(&["checkout", "main"]);
repo.git(&["push", "origin", "--delete", "manual-upstream-gone"]);
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let branches = repo.list_branches();
assert!(
branches.iter().any(|b| b == "manual-upstream-gone"),
"Expected untracked upstream-gone branch to remain without --delete-upstream-gone"
);
}
#[test]
fn test_sync_delete_upstream_gone_deletes_untracked_local_branch() {
let repo = TestRepo::new_with_remote();
repo.git(&["checkout", "-b", "manual-upstream-gone"]);
repo.create_file("manual.txt", "manual branch content");
repo.commit("Manual branch commit");
repo.git(&["push", "-u", "origin", "manual-upstream-gone"]);
repo.git(&["checkout", "main"]);
repo.git(&["push", "origin", "--delete", "manual-upstream-gone"]);
let output = repo.run_stax(&["sync", "--force", "--delete-upstream-gone"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let branches = repo.list_branches();
assert!(
!branches.iter().any(|b| b == "manual-upstream-gone"),
"Expected --delete-upstream-gone to delete the stale local branch"
);
}
#[test]
fn test_sync_detects_branch_with_empty_diff_against_trunk() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-empty-diff"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &branch_name]);
repo.merge_branch_on_remote(&branch_name);
repo.run_stax(&["t"]);
repo.git(&["pull", "origin", "main"]);
let diff_output = repo.git(&["diff", "--quiet", "main", &branch_name]);
assert!(
diff_output.status.success(),
"Expected empty diff between main and feature branch after merge"
);
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("merged")
|| stdout.contains("feature-empty-diff")
|| stdout.contains("deleted"),
"Expected sync to detect branch with empty diff, got: {}",
stdout
);
}
#[test]
fn test_sync_on_merged_branch_checkouts_parent() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-checkout-parent"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &branch_name]);
repo.git(&["push", "origin", "--delete", &branch_name]);
assert!(repo.current_branch().contains("feature-checkout-parent"));
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let _stdout = TestRepo::stdout(&output);
let current = repo.current_branch();
if !repo
.list_branches()
.iter()
.any(|b| b.contains("feature-checkout-parent"))
{
assert_eq!(current, "main", "Should be on main after branch deletion");
}
}
#[test]
fn test_sync_on_merged_branch_with_missing_parent_falls_back_to_trunk() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-parent"]);
let parent_branch = repo.current_branch();
repo.create_file("parent.txt", "parent content");
repo.commit("Parent commit");
let push_parent = repo.git(&["push", "-u", "origin", &parent_branch]);
assert!(
push_parent.status.success(),
"Failed to push parent branch: {}",
TestRepo::stderr(&push_parent)
);
repo.run_stax(&["bc", "feature-child"]);
let child_branch = repo.current_branch();
repo.create_file("child.txt", "child content");
repo.commit("Child commit");
let push_child = repo.git(&["push", "-u", "origin", &child_branch]);
assert!(
push_child.status.success(),
"Failed to push child branch: {}",
TestRepo::stderr(&push_child)
);
let delete_parent_local = repo.git(&["branch", "-D", &parent_branch]);
assert!(
delete_parent_local.status.success(),
"Failed to delete local parent branch: {}",
TestRepo::stderr(&delete_parent_local)
);
let delete_parent_remote = repo.git(&["push", "origin", "--delete", &parent_branch]);
assert!(
delete_parent_remote.status.success(),
"Failed to delete remote parent branch: {}",
TestRepo::stderr(&delete_parent_remote)
);
repo.merge_branch_on_remote(&child_branch);
assert_eq!(repo.current_branch(), child_branch);
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
assert_eq!(
repo.current_branch(),
"main",
"Expected sync to fallback to trunk when parent branch is missing"
);
assert!(
!repo.list_branches().iter().any(|b| b == &child_branch),
"Expected merged child branch to be deleted"
);
}
#[test]
fn test_sync_pulls_parent_after_checkout() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-pull-parent"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &branch_name]);
repo.simulate_remote_commit("remote-update.txt", "remote content", "Remote update");
repo.git(&["push", "origin", "--delete", &branch_name]);
assert!(repo.current_branch().contains("feature-pull-parent"));
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let current = repo.current_branch();
if current == "main" {
assert!(
repo.path().join("remote-update.txt").exists(),
"Expected remote-update.txt after sync pulled main"
);
}
}
#[test]
fn test_sync_with_stacked_branches_detects_merged_child() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-1"]);
let feature1 = repo.current_branch();
repo.create_file("f1.txt", "feature 1");
repo.commit("Feature 1");
repo.git(&["push", "-u", "origin", &feature1]);
repo.run_stax(&["bc", "feature-2"]);
let feature2 = repo.current_branch();
repo.create_file("f2.txt", "feature 2");
repo.commit("Feature 2");
repo.git(&["push", "-u", "origin", &feature2]);
repo.git(&["push", "origin", "--delete", &feature2]);
repo.run_stax(&["checkout", &feature1]);
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("merged") || stdout.contains("feature-2") || stdout.contains("deleted"),
"Expected sync to detect feature-2 as merged, got: {}",
stdout
);
}
#[test]
fn test_sync_preserves_branch_with_remote() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-with-remote"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &branch_name]);
repo.run_stax(&["t"]);
let output = repo.run_stax(&["sync", "--force"]);
assert!(output.status.success());
let branches = repo.list_branches();
assert!(
branches.iter().any(|b| b.contains("feature-with-remote")),
"Expected feature-with-remote to still exist (has remote)"
);
}
#[test]
fn test_sync_updates_trunk_after_branch_deletion_checkout() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-trunk-update-order"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature content");
repo.commit("Feature commit");
repo.git(&["push", "-u", "origin", &branch_name]);
repo.merge_branch_on_remote(&branch_name);
repo.simulate_remote_commit(
"remote-main-update.txt",
"content from remote",
"Remote main update after merge",
);
assert!(repo.current_branch().contains("feature-trunk-update-order"));
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
!stdout.contains("failed (may need manual update)"),
"Trunk update should not fail when we end up on trunk after branch deletion. Got:\n{}",
stdout
);
assert!(
stdout.contains("Update main"),
"Expected trunk update message. Got:\n{}",
stdout
);
assert_eq!(
repo.current_branch(),
"main",
"Should be on main after sync deletes the feature branch"
);
assert!(
repo.path().join("remote-main-update.txt").exists(),
"Expected main to have the remote update after sync"
);
}
#[test]
fn test_sync_trunk_update_order_with_diverged_main() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-diverged-main"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature work");
repo.commit("Feature work");
repo.git(&["push", "-u", "origin", &branch_name]);
repo.merge_branch_on_remote(&branch_name);
repo.simulate_remote_commit("update1.txt", "update 1", "Remote update 1");
repo.simulate_remote_commit("update2.txt", "update 2", "Remote update 2");
assert!(repo.current_branch().contains("feature-diverged-main"));
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
!stdout.contains("failed"),
"Should not see any failed messages. Got:\n{}",
stdout
);
assert_eq!(repo.current_branch(), "main");
assert!(repo.path().join("update1.txt").exists());
assert!(repo.path().join("update2.txt").exists());
}
#[test]
fn test_sync_trunk_update_when_not_on_merged_branch() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "active-feature"]);
let branch_name = repo.current_branch();
repo.create_file("active.txt", "active work");
repo.commit("Active work");
repo.git(&["push", "-u", "origin", &branch_name]);
repo.simulate_remote_commit("main-update.txt", "main update", "Main update");
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
assert!(repo.current_branch().contains("active-feature"));
repo.git(&["checkout", "main"]);
assert!(
repo.path().join("main-update.txt").exists(),
"Main should have been updated via fetch refspec"
);
}
#[test]
fn test_sync_detects_merged_branch_when_local_trunk_diverged() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-merged-diverged-trunk"]);
let branch_name = repo.current_branch();
repo.create_file("feature.txt", "feature work");
repo.commit("Feature work");
repo.git(&["push", "-u", "origin", &branch_name]);
repo.merge_branch_on_remote(&branch_name);
repo.run_stax(&["t"]);
repo.create_file("local-main-only.txt", "local commit");
repo.commit("Local main only commit");
repo.run_stax(&["checkout", &branch_name]);
assert!(repo
.current_branch()
.contains("feature-merged-diverged-trunk"));
let output = repo.run_stax(&["sync", "--force"]);
assert!(
output.status.success(),
"Sync failed: {}",
TestRepo::stderr(&output)
);
let branches = repo.list_branches();
assert!(
!branches
.iter()
.any(|b| b.contains("feature-merged-diverged-trunk")),
"Expected merged branch to be deleted even with diverged local trunk"
);
}
#[test]
fn test_sync_restack_handles_squash_merged_middle_branch() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "middle-squash-parent"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent 1\n");
repo.commit("Parent commit 1");
repo.create_file("parent.txt", "parent 1\nparent 2\n");
repo.commit("Parent commit 2");
repo.git(&["push", "-u", "origin", &parent]);
repo.run_stax(&["bc", "middle-squash-child"]);
let child = repo.current_branch();
repo.create_file("child.txt", "child change\n");
repo.commit("Child commit");
repo.git(&["push", "-u", "origin", &child]);
let remote_path = repo.remote_path().expect("No remote configured");
let clone_dir = test_tempdir();
let run_remote_git = |args: &[&str]| {
let output = hermetic_git_command()
.args(args)
.current_dir(clone_dir.path())
.output()
.expect("Failed to run git in remote clone");
assert!(
output.status.success(),
"git {:?} failed\nstdout: {}\nstderr: {}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
};
run_remote_git(&["clone", remote_path.to_str().unwrap(), "."]);
run_remote_git(&["checkout", "-B", "main", "origin/main"]);
run_remote_git(&["config", "user.email", "merger@test.com"]);
run_remote_git(&["config", "user.name", "Merger"]);
run_remote_git(&["fetch", "origin", &parent]);
run_remote_git(&["merge", "--squash", &format!("origin/{}", parent)]);
run_remote_git(&["commit", "-m", "Squash merge parent"]);
run_remote_git(&["push", "origin", "main"]);
run_remote_git(&["push", "origin", "--delete", &parent]);
repo.run_stax(&["checkout", &child]);
let output = repo.run_stax(&["sync", "--restack", "--force"]);
assert!(
output.status.success(),
"sync --restack failed\nstdout: {}\nstderr: {}",
TestRepo::stdout(&output),
TestRepo::stderr(&output)
);
assert!(
!TestRepo::stdout(&output).contains("conflict"),
"Expected provenance-aware restack to avoid conflict for child-only commit.\nstdout: {}",
TestRepo::stdout(&output)
);
let branches = repo.list_branches();
assert!(
!branches.iter().any(|b| b == &parent),
"Expected merged parent branch to be deleted"
);
let count_output = repo.git(&["rev-list", "--count", &format!("main..{}", child)]);
assert!(count_output.status.success());
let unique_commits = String::from_utf8_lossy(&count_output.stdout)
.trim()
.to_string();
assert_eq!(
unique_commits, "1",
"Expected child to keep only novel commits after provenance-aware restack"
);
}
#[test]
fn test_merge_help() {
let repo = TestRepo::new();
let output = repo.run_stax(&["merge", "--help"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(stdout.contains("--all"), "Expected --all flag in help");
assert!(
stdout.contains("--dry-run"),
"Expected --dry-run flag in help"
);
assert!(
stdout.contains("--method"),
"Expected --method flag in help"
);
assert!(
stdout.contains("--no-delete"),
"Expected --no-delete flag in help"
);
assert!(
stdout.contains("--no-sync"),
"Expected --no-sync flag in help"
);
assert!(
stdout.contains("--no-wait"),
"Expected --no-wait flag in help"
);
assert!(
stdout.contains("--timeout"),
"Expected --timeout flag in help"
);
assert!(stdout.contains("--yes"), "Expected --yes flag in help");
assert!(stdout.contains("--quiet"), "Expected --quiet flag in help");
}
#[test]
fn test_merge_on_trunk_shows_error() {
let repo = TestRepo::new();
let output = repo.run_stax(&["status"]);
assert!(output.status.success());
assert_eq!(repo.current_branch(), "main");
let output = repo.run_stax(&["merge"]);
let stdout = TestRepo::stdout(&output);
let stderr = TestRepo::stderr(&output);
let combined = format!("{}{}", stdout, stderr);
assert!(
combined.contains("trunk") || combined.contains("Checkout"),
"Expected message about being on trunk, got: {}",
combined
);
}
#[test]
fn test_merge_on_untracked_branch_shows_error() {
let repo = TestRepo::new();
repo.run_stax(&["status"]);
repo.git(&["checkout", "-b", "untracked-branch"]);
repo.create_file("test.txt", "content");
repo.commit("Untracked commit");
let output = repo.run_stax(&["merge"]);
let stdout = TestRepo::stdout(&output);
let stderr = TestRepo::stderr(&output);
let combined = format!("{}{}", stdout, stderr);
assert!(
combined.contains("not tracked") || combined.contains("track"),
"Expected message about untracked branch, got: {}",
combined
);
}
#[test]
fn test_merge_without_pr_shows_error() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-no-pr"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let output = repo.run_stax(&["merge", "--yes"]);
let stdout = TestRepo::stdout(&output);
let stderr = TestRepo::stderr(&output);
let combined = format!("{}{}", stdout, stderr);
assert!(
combined.contains("PR") || combined.contains("submit"),
"Expected message about missing PR, got: {}",
combined
);
}
#[test]
fn test_merge_dry_run_shows_plan_without_merging() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-dry-run"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let output = repo.run_stax(&["merge", "--dry-run"]);
let stdout = TestRepo::stdout(&output);
let stderr = TestRepo::stderr(&output);
let combined = format!("{}{}", stdout, stderr);
assert!(
combined.contains("dry") || combined.contains("PR") || combined.contains("plan"),
"Expected dry-run output or PR error, got: {}",
combined
);
let branches = repo.list_branches();
assert!(
branches.iter().any(|b| b.contains("feature-dry-run")),
"Branch should still exist after dry-run"
);
}
#[test]
fn test_merge_scope_single_branch() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "single-feature"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let output = repo.run_stax(&["status"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("single-feature"),
"Expected branch in status"
);
}
#[test]
fn test_merge_scope_stacked_branches() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-a"]);
repo.create_file("a.txt", "content a");
repo.commit("Feature A");
repo.run_stax(&["bc", "feature-b"]);
repo.create_file("b.txt", "content b");
repo.commit("Feature B");
repo.run_stax(&["bc", "feature-c"]);
repo.create_file("c.txt", "content c");
repo.commit("Feature C");
assert!(repo.current_branch().contains("feature-c"));
let output = repo.run_stax(&["status"]);
assert!(output.status.success());
let stdout = TestRepo::stdout(&output);
assert!(stdout.contains("feature-a"), "Expected feature-a in status");
assert!(stdout.contains("feature-b"), "Expected feature-b in status");
assert!(stdout.contains("feature-c"), "Expected feature-c in status");
}
#[test]
fn test_merge_from_middle_of_stack() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "stack-a"]);
repo.create_file("a.txt", "content a");
repo.commit("Feature A");
repo.run_stax(&["bc", "stack-b"]);
repo.create_file("b.txt", "content b");
repo.commit("Feature B");
let branch_b = repo.current_branch();
repo.run_stax(&["bc", "stack-c"]);
repo.create_file("c.txt", "content c");
repo.commit("Feature C");
repo.run_stax(&["checkout", &branch_b]);
assert!(repo.current_branch().contains("stack-b"));
let output = repo.run_stax(&["merge", "--dry-run"]);
let stdout = TestRepo::stdout(&output);
let stderr = TestRepo::stderr(&output);
let combined = format!("{}{}", stdout, stderr);
assert!(
combined.contains("PR") || combined.contains("stack") || combined.contains("merge"),
"Expected merge-related output, got: {}",
combined
);
}
#[test]
fn test_merge_all_flag() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "all-a"]);
repo.create_file("a.txt", "content");
repo.commit("A");
repo.run_stax(&["bc", "all-b"]);
repo.create_file("b.txt", "content");
repo.commit("B");
repo.run_stax(&["checkout", "all-a"]);
let output = repo.run_stax(&["merge", "--all", "--dry-run"]);
let stdout = TestRepo::stdout(&output);
let stderr = TestRepo::stderr(&output);
let combined = format!("{}{}", stdout, stderr);
assert!(
combined.contains("PR") || combined.contains("merge") || combined.contains("all"),
"Expected output about merging, got: {}",
combined
);
}
#[test]
fn test_merge_method_options() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "method-test"]);
repo.create_file("test.txt", "content");
repo.commit("Test");
let output = repo.run_stax(&["merge", "--method", "squash", "--dry-run"]);
let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
assert!(
!combined.contains("Invalid merge method"),
"squash should be a valid method"
);
let output = repo.run_stax(&["merge", "--method", "merge", "--dry-run"]);
let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
assert!(
!combined.contains("Invalid merge method"),
"merge should be a valid method"
);
let output = repo.run_stax(&["merge", "--method", "rebase", "--dry-run"]);
let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
assert!(
!combined.contains("Invalid merge method"),
"rebase should be a valid method"
);
}
#[test]
fn test_merge_invalid_method_defaults_to_squash() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "invalid-method"]);
repo.create_file("test.txt", "content");
repo.commit("Test");
let output = repo.run_stax(&["merge", "--method", "invalid", "--dry-run"]);
let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
assert!(
!combined.is_empty(),
"Should produce some output even with invalid method"
);
}
#[test]
fn test_merge_preserves_unrelated_branches() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "stack1-a"]);
repo.create_file("s1a.txt", "content");
repo.commit("Stack 1 A");
repo.run_stax(&["t"]);
repo.run_stax(&["bc", "stack2-a"]);
repo.create_file("s2a.txt", "content");
repo.commit("Stack 2 A");
let branches = repo.list_branches();
assert!(branches.iter().any(|b| b.contains("stack1")));
assert!(branches.iter().any(|b| b.contains("stack2")));
let output = repo.run_stax(&["merge", "--dry-run"]);
let _combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
let branches = repo.list_branches();
assert!(
branches.iter().any(|b| b.contains("stack1")),
"stack1 branch should be preserved"
);
assert!(
branches.iter().any(|b| b.contains("stack2")),
"stack2 branch should be preserved"
);
}
#[test]
fn test_merge_quiet_flag() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "quiet-test"]);
repo.create_file("test.txt", "content");
repo.commit("Test");
let output = repo.run_stax(&["merge", "--quiet", "--dry-run"]);
let stdout = TestRepo::stdout(&output);
let stderr = TestRepo::stderr(&output);
let combined = format!("{}{}", stdout, stderr);
assert!(
combined.len() < 5000,
"Quiet mode should not produce excessive output"
);
}
#[test]
fn test_merge_timeout_option() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "timeout-test"]);
repo.create_file("test.txt", "content");
repo.commit("Test");
let output = repo.run_stax(&["merge", "--timeout", "5", "--dry-run"]);
let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
assert!(
!combined.contains("error") || combined.contains("PR"),
"Timeout option should be accepted"
);
}
#[test]
fn test_merge_no_wait_flag() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "no-wait-test"]);
repo.create_file("test.txt", "content");
repo.commit("Test");
let output = repo.run_stax(&["merge", "--no-wait", "--dry-run"]);
let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
assert!(
!combined.contains("unexpected argument"),
"--no-wait should be a valid flag"
);
}
#[test]
fn test_merge_no_delete_flag() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "no-delete-test"]);
repo.create_file("test.txt", "content");
repo.commit("Test");
let output = repo.run_stax(&["merge", "--no-delete", "--dry-run"]);
let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
assert!(
!combined.contains("unexpected argument"),
"--no-delete should be a valid flag"
);
}
#[test]
fn test_merge_yes_flag_skips_confirmation() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "yes-test"]);
repo.create_file("test.txt", "content");
repo.commit("Test");
let output = repo.run_stax(&["merge", "--yes", "--dry-run"]);
let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
assert!(
!combined.contains("unexpected argument"),
"--yes should be a valid flag"
);
}
#[test]
fn test_merge_combined_flags() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "combined-test"]);
repo.create_file("test.txt", "content");
repo.commit("Test");
let output = repo.run_stax(&[
"merge",
"--all",
"--method",
"squash",
"--no-delete",
"--no-sync",
"--no-wait",
"--timeout",
"10",
"--yes",
"--quiet",
"--dry-run",
]);
let combined = format!("{}{}", TestRepo::stdout(&output), TestRepo::stderr(&output));
assert!(
!combined.contains("unexpected argument"),
"All flags should be accepted together"
);
}
mod github_mock_tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use wiremock::matchers::{method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn write_test_config(home: &Path, api_base_url: &str) {
write_test_config_with_submit(home, api_base_url, None);
}
fn write_test_config_with_submit(home: &Path, api_base_url: &str, stack_links: Option<&str>) {
let config_dir = home.join(".config").join("stax");
std::fs::create_dir_all(&config_dir).expect("Failed to create config dir");
let config_path = config_dir.join("config.toml");
let mut config = format!("[remote]\napi_base_url = \"{}\"\n", api_base_url);
if let Some(mode) = stack_links {
config.push_str(&format!("\n[submit]\nstack_links = \"{}\"\n", mode));
}
fs::write(&config_path, config).expect("Failed to write config");
}
fn ensure_empty_gitconfig(home: &Path) -> std::path::PathBuf {
let path = home.join("gitconfig");
if !path.exists() {
fs::write(&path, "").expect("Failed to write empty gitconfig");
}
path
}
fn git_with_env(repo: &TestRepo, home: &Path, args: &[&str]) -> Output {
let gitconfig = ensure_empty_gitconfig(home);
hermetic_git_command()
.args(args)
.current_dir(repo.path())
.env("HOME", home)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", &gitconfig)
.output()
.expect("Failed to run git command")
}
fn setup_fake_github_remote(repo: &TestRepo, home: &Path) -> TempDir {
let remote_root = super::test_tempdir();
let remote_repo = remote_root.path().join("test").join("repo.git");
if let Some(parent) = remote_repo.parent() {
std::fs::create_dir_all(parent).expect("Failed to create remote parent dirs");
}
std::fs::create_dir_all(&remote_repo).expect("Failed to create remote repo dir");
hermetic_git_command()
.args(["init", "--bare"])
.current_dir(&remote_repo)
.output()
.expect("Failed to init bare remote repo");
let add_remote = git_with_env(
repo,
home,
&[
"remote",
"add",
"origin",
"https://github.com/test/repo.git",
],
);
assert!(
add_remote.status.success(),
"Failed to add origin: {}",
TestRepo::stderr(&add_remote)
);
let file_base = format!("file://{}/", remote_root.path().display());
let set_instead_of = git_with_env(
repo,
home,
&[
"config",
&format!("url.{}.insteadOf", file_base),
"https://github.com/",
],
);
assert!(
set_instead_of.status.success(),
"Failed to set insteadOf: {}",
TestRepo::stderr(&set_instead_of)
);
let push = git_with_env(repo, home, &["push", "-u", "origin", "main"]);
assert!(
push.status.success(),
"Failed to push to fake remote: {}",
TestRepo::stderr(&push)
);
remote_root
}
fn find_request_index(
requests: &[wiremock::Request],
method_name: &str,
path_name: &str,
) -> usize {
requests
.iter()
.position(|request| {
request.method.as_str() == method_name && request.url.path() == path_name
})
.unwrap_or_else(|| panic!("Did not find request {} {}", method_name, path_name))
}
fn issue_comment_fixture(id: u64, body: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"node_id": format!("IC_test_{}", id),
"url": format!("https://api.github.com/repos/test/repo/issues/comments/{}", id),
"html_url": format!("https://github.com/test/repo/pull/42#issuecomment-{}", id),
"issue_url": "https://api.github.com/repos/test/repo/issues/42",
"body": body,
"user": {
"login": "stax",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://avatars.githubusercontent.com/u/1?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/stax",
"html_url": "https://github.com/stax",
"followers_url": "https://api.github.com/users/stax/followers",
"following_url": "https://api.github.com/users/stax/following{/other_user}",
"gists_url": "https://api.github.com/users/stax/gists{/gist_id}",
"starred_url": "https://api.github.com/users/stax/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/stax/subscriptions",
"organizations_url": "https://api.github.com/users/stax/orgs",
"repos_url": "https://api.github.com/users/stax/repos",
"events_url": "https://api.github.com/users/stax/events{/privacy}",
"received_events_url": "https://api.github.com/users/stax/received_events",
"type": "User",
"site_admin": false
},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
})
}
fn squash_merge_branch_on_fake_remote(remote_root: &TempDir, branch: &str) {
let remote_repo = remote_root.path().join("test").join("repo.git");
let clone_dir = super::test_tempdir();
let run_remote_git = |args: &[&str]| {
let output = hermetic_git_command()
.args(args)
.current_dir(clone_dir.path())
.output()
.expect("Failed to run git in fake remote clone");
assert!(
output.status.success(),
"git {:?} failed\nstdout: {}\nstderr: {}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
};
run_remote_git(&["clone", remote_repo.to_str().unwrap(), "."]);
run_remote_git(&["checkout", "-B", "main", "origin/main"]);
run_remote_git(&["config", "user.email", "merger@test.com"]);
run_remote_git(&["config", "user.name", "Merger"]);
run_remote_git(&["fetch", "origin", branch]);
run_remote_git(&["merge", "--squash", &format!("origin/{}", branch)]);
run_remote_git(&["commit", "-m", &format!("Squash merge {}", branch)]);
run_remote_git(&["push", "origin", "main"]);
run_remote_git(&["push", "origin", "--delete", branch]);
}
fn run_stax_with_env(repo: &TestRepo, home: &Path, args: &[&str]) -> Output {
let gitconfig = ensure_empty_gitconfig(home);
Command::new(stax_bin())
.args(args)
.current_dir(repo.path())
.env("HOME", home)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", &gitconfig)
.env("STAX_GITHUB_TOKEN", "mock-token")
.env("STAX_DISABLE_UPDATE_CHECK", "1")
.output()
.expect("Failed to execute stax")
}
fn setup_branch_with_remote(home: &Path, branch: &str) -> TestRepo {
let repo = TestRepo::new();
let _remote_root = setup_fake_github_remote(&repo, home);
let output = run_stax_with_env(&repo, home, &["bc", branch]);
assert!(
output.status.success(),
"Failed to create branch {}: {}",
branch,
TestRepo::stderr(&output)
);
repo.create_file("feature.txt", &format!("content for {}\n", branch));
repo.commit(&format!("Add {}", branch));
let push = git_with_env(&repo, home, &["push", "-u", "origin", branch]);
assert!(
push.status.success(),
"Failed to push branch {}: {}",
branch,
TestRepo::stderr(&push)
);
repo
}
async fn setup_mock_github() -> (TestRepo, MockServer) {
let mock_server = MockServer::start().await;
let repo = TestRepo::new_with_remote();
std::env::set_var("STAX_GITHUB_TOKEN", "mock-token");
(repo, mock_server)
}
#[tokio::test]
async fn test_mock_server_setup() {
let mock_server = MockServer::start().await;
assert!(!mock_server.uri().is_empty());
}
#[tokio::test]
async fn test_submit_with_mock_pr_creation() {
let (repo, mock_server) = setup_mock_github().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/.*/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path_regex(r"/repos/.*/pulls"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"number": 1,
"state": "open",
"title": "Test PR",
"draft": false,
"html_url": "https://github.com/test/repo/pull/1"
})))
.mount(&mock_server)
.await;
repo.run_stax(&["bc", "feature-pr"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
assert!(
mock_server.received_requests().await.is_none()
|| mock_server.received_requests().await.unwrap().is_empty()
);
}
#[tokio::test]
async fn test_submit_persists_pr_info_for_existing_pr() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"head": { "ref": "feature-branch", "sha": "aaaa", "label": "test:feature-branch" },
"base": { "ref": "main", "sha": "bbbb" }
}
])))
.mount(&mock_server)
.await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let _remote_root = setup_fake_github_remote(&repo, home.path());
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_env(&repo, home.path(), &["bc", "feature-branch"]);
assert!(
output.status.success(),
"Failed to create branch: {}",
TestRepo::stderr(&output)
);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let branch = repo.current_branch();
let output = run_stax_with_env(&repo, home.path(), &["submit", "--no-pr", "--yes"]);
assert!(
output.status.success(),
"Submit failed: {}",
TestRepo::stderr(&output)
);
let metadata_ref = format!("refs/branch-metadata/{}", branch);
let output = repo.git(&["show", &metadata_ref]);
assert!(
output.status.success(),
"Failed to read metadata: {}",
TestRepo::stderr(&output)
);
let metadata = TestRepo::stdout(&output);
assert!(
metadata.contains("\"number\":42"),
"Expected PR number in metadata, got: {}",
metadata
);
}
#[tokio::test]
async fn test_submit_does_not_persist_pr_info_for_fork() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/99",
"id": 99,
"number": 99,
"state": "open",
"draft": false,
"head": { "ref": "feature-branch", "sha": "aaaa", "label": "fork:feature-branch" },
"base": { "ref": "main", "sha": "bbbb" }
}
])))
.mount(&mock_server)
.await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let _remote_root = setup_fake_github_remote(&repo, home.path());
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_env(&repo, home.path(), &["bc", "feature-branch"]);
assert!(
output.status.success(),
"Failed to create branch: {}",
TestRepo::stderr(&output)
);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let branch = repo.current_branch();
let output = run_stax_with_env(&repo, home.path(), &["submit", "--no-pr", "--yes"]);
assert!(
output.status.success(),
"Submit failed: {}",
TestRepo::stderr(&output)
);
let metadata_ref = format!("refs/branch-metadata/{}", branch);
let output = repo.git(&["show", &metadata_ref]);
assert!(
output.status.success(),
"Failed to read metadata: {}",
TestRepo::stderr(&output)
);
let metadata = TestRepo::stdout(&output);
assert!(
!metadata.contains("\"number\":99"),
"Expected PR number not to be persisted for fork, got: {}",
metadata
);
}
#[tokio::test]
async fn test_submit_default_comment_mode_updates_comment_and_removes_body_block() {
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
write_test_config(home.path(), &mock_server.uri());
let repo = setup_branch_with_remote(home.path(), "feature-comment");
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"head": { "ref": "feature-comment", "sha": "aaaa", "label": "test:feature-comment" },
"base": { "ref": "main", "sha": "bbbb" }
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/issues/42/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
issue_comment_fixture(901, "<!-- stax-stack-comment -->\nold")
])))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/issues/comments/901"))
.respond_with(
ResponseTemplate::new(200).set_body_json(issue_comment_fixture(
901,
"<!-- stax-stack-comment -->\nupdated",
)),
)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"body": "## Summary\n\nhello\n\n<!-- stax-stack-links:start -->\nold\n<!-- stax-stack-links:end -->",
"head": { "ref": "feature-comment", "sha": "aaaa", "label": "test:feature-comment" },
"base": { "ref": "main", "sha": "bbbb" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"head": { "ref": "feature-comment", "sha": "aaaa", "label": "test:feature-comment" },
"base": { "ref": "main", "sha": "bbbb" }
})))
.mount(&mock_server)
.await;
let output = run_stax_with_env(&repo, home.path(), &["submit", "--yes", "--no-prompt"]);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let requests = mock_server.received_requests().await.unwrap();
assert!(requests.iter().any(|request| {
request.method.as_str() == "PATCH"
&& request.url.path() == "/repos/test/repo/issues/comments/901"
}));
let body_patch = requests
.iter()
.find(|request| {
request.method.as_str() == "PATCH"
&& request.url.path() == "/repos/test/repo/pulls/42"
})
.expect("missing body patch");
let payload: serde_json::Value = serde_json::from_slice(&body_patch.body).unwrap();
assert_eq!(payload["body"], "## Summary\n\nhello");
}
#[tokio::test]
async fn test_submit_body_mode_removes_comment_and_writes_body_block() {
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
write_test_config_with_submit(home.path(), &mock_server.uri(), Some("body"));
let repo = setup_branch_with_remote(home.path(), "feature-body");
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"head": { "ref": "feature-body", "sha": "aaaa", "label": "test:feature-body" },
"base": { "ref": "main", "sha": "bbbb" }
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/issues/42/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
issue_comment_fixture(901, "<!-- stax-stack-comment -->\nold")
])))
.mount(&mock_server)
.await;
Mock::given(method("DELETE"))
.and(path("/repos/test/repo/issues/comments/901"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"body": "## Summary\n\nhello",
"head": { "ref": "feature-body", "sha": "aaaa", "label": "test:feature-body" },
"base": { "ref": "main", "sha": "bbbb" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"head": { "ref": "feature-body", "sha": "aaaa", "label": "test:feature-body" },
"base": { "ref": "main", "sha": "bbbb" }
})))
.mount(&mock_server)
.await;
let output = run_stax_with_env(&repo, home.path(), &["submit", "--yes", "--no-prompt"]);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let requests = mock_server.received_requests().await.unwrap();
assert!(requests.iter().any(|request| {
request.method.as_str() == "DELETE"
&& request.url.path() == "/repos/test/repo/issues/comments/901"
}));
let body_patch = requests
.iter()
.find(|request| {
request.method.as_str() == "PATCH"
&& request.url.path() == "/repos/test/repo/pulls/42"
})
.expect("missing body patch");
let payload: serde_json::Value = serde_json::from_slice(&body_patch.body).unwrap();
let body = payload["body"].as_str().unwrap();
assert!(body.starts_with("## Summary\n\nhello"));
assert!(body.contains("<!-- stax-stack-links:start -->"));
assert!(body.contains("## Stack Links"));
}
#[tokio::test]
async fn test_submit_both_mode_updates_comment_and_body() {
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
write_test_config_with_submit(home.path(), &mock_server.uri(), Some("both"));
let repo = setup_branch_with_remote(home.path(), "feature-both");
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"head": { "ref": "feature-both", "sha": "aaaa", "label": "test:feature-both" },
"base": { "ref": "main", "sha": "bbbb" }
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/issues/42/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
issue_comment_fixture(901, "<!-- stax-stack-comment -->\nold")
])))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/issues/comments/901"))
.respond_with(
ResponseTemplate::new(200).set_body_json(issue_comment_fixture(
901,
"<!-- stax-stack-comment -->\nupdated",
)),
)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"body": "## Summary\n\nhello",
"head": { "ref": "feature-both", "sha": "aaaa", "label": "test:feature-both" },
"base": { "ref": "main", "sha": "bbbb" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"head": { "ref": "feature-both", "sha": "aaaa", "label": "test:feature-both" },
"base": { "ref": "main", "sha": "bbbb" }
})))
.mount(&mock_server)
.await;
let output = run_stax_with_env(&repo, home.path(), &["submit", "--yes", "--no-prompt"]);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let requests = mock_server.received_requests().await.unwrap();
assert!(requests.iter().any(|request| {
request.method.as_str() == "PATCH"
&& request.url.path() == "/repos/test/repo/issues/comments/901"
}));
let body_patch = requests
.iter()
.find(|request| {
request.method.as_str() == "PATCH"
&& request.url.path() == "/repos/test/repo/pulls/42"
})
.expect("missing body patch");
let payload: serde_json::Value = serde_json::from_slice(&body_patch.body).unwrap();
assert!(payload["body"]
.as_str()
.unwrap()
.contains("<!-- stax-stack-links:start -->"));
}
#[tokio::test]
async fn test_submit_off_mode_removes_comment_and_body_block() {
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
write_test_config_with_submit(home.path(), &mock_server.uri(), Some("off"));
let repo = setup_branch_with_remote(home.path(), "feature-off");
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"head": { "ref": "feature-off", "sha": "aaaa", "label": "test:feature-off" },
"base": { "ref": "main", "sha": "bbbb" }
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/issues/42/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
issue_comment_fixture(901, "<!-- stax-stack-comment -->\nold")
])))
.mount(&mock_server)
.await;
Mock::given(method("DELETE"))
.and(path("/repos/test/repo/issues/comments/901"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"body": "## Summary\n\nhello\n\n<!-- stax-stack-links:start -->\nold\n<!-- stax-stack-links:end -->",
"head": { "ref": "feature-off", "sha": "aaaa", "label": "test:feature-off" },
"base": { "ref": "main", "sha": "bbbb" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/42",
"id": 42,
"number": 42,
"state": "open",
"draft": false,
"head": { "ref": "feature-off", "sha": "aaaa", "label": "test:feature-off" },
"base": { "ref": "main", "sha": "bbbb" }
})))
.mount(&mock_server)
.await;
let output = run_stax_with_env(&repo, home.path(), &["submit", "--yes", "--no-prompt"]);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let requests = mock_server.received_requests().await.unwrap();
assert!(requests.iter().any(|request| {
request.method.as_str() == "DELETE"
&& request.url.path() == "/repos/test/repo/issues/comments/901"
}));
let body_patch = requests
.iter()
.find(|request| {
request.method.as_str() == "PATCH"
&& request.url.path() == "/repos/test/repo/pulls/42"
})
.expect("missing body patch");
let payload: serde_json::Value = serde_json::from_slice(&body_patch.body).unwrap();
assert_eq!(payload["body"], "## Summary\n\nhello");
}
#[tokio::test]
async fn test_merge_already_merged_pr_still_rebases_next_branch_and_reparents_metadata() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/101",
"id": 101,
"number": 101,
"state": "open",
"draft": false,
"head": { "ref": "merge-a", "sha": "sha-a", "label": "test:merge-a" },
"base": { "ref": "main", "sha": "main-sha" }
},
{
"url": "https://api.github.com/repos/test/repo/pulls/102",
"id": 102,
"number": 102,
"state": "open",
"draft": false,
"head": { "ref": "merge-b", "sha": "sha-b", "label": "test:merge-b" },
"base": { "ref": "merge-a", "sha": "sha-a" }
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/101"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/101",
"id": 101,
"number": 101,
"state": "closed",
"draft": false,
"merged_at": "2024-01-01T00:00:00Z",
"mergeable": true,
"mergeable_state": "clean",
"head": { "ref": "merge-a", "sha": "sha-a", "label": "test:merge-a" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/102"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/102",
"id": 102,
"number": 102,
"state": "open",
"draft": false,
"merged_at": null,
"mergeable": true,
"mergeable_state": "clean",
"head": { "ref": "merge-b", "sha": "sha-b", "label": "test:merge-b" },
"base": { "ref": "merge-a", "sha": "sha-a" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/102"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/102",
"id": 102,
"number": 102,
"state": "open",
"draft": false,
"head": { "ref": "merge-b", "sha": "sha-b", "label": "test:merge-b" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/repos/test/repo/pulls/102/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sha": "merge-commit",
"merged": true,
"message": "Pull Request successfully merged"
})))
.mount(&mock_server)
.await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let remote_root = setup_fake_github_remote(&repo, home.path());
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_env(&repo, home.path(), &["bc", "merge-a"]);
assert!(
output.status.success(),
"Failed to create merge-a: {}",
TestRepo::stderr(&output)
);
let branch_a = repo.current_branch();
repo.create_file("parent.txt", "parent 1\n");
repo.commit("Parent commit 1");
repo.create_file("parent.txt", "parent 1\nparent 2\n");
repo.commit("Parent commit 2");
let push_a = git_with_env(&repo, home.path(), &["push", "-u", "origin", &branch_a]);
assert!(
push_a.status.success(),
"Failed to push merge-a: {}",
TestRepo::stderr(&push_a)
);
let output = run_stax_with_env(&repo, home.path(), &["bc", "merge-b"]);
assert!(
output.status.success(),
"Failed to create merge-b: {}",
TestRepo::stderr(&output)
);
let branch_b = repo.current_branch();
repo.create_file("b.txt", "b");
repo.commit("B");
let push_b = git_with_env(&repo, home.path(), &["push", "-u", "origin", &branch_b]);
assert!(
push_b.status.success(),
"Failed to push merge-b: {}",
TestRepo::stderr(&push_b)
);
squash_merge_branch_on_fake_remote(&remote_root, &branch_a);
let merge_output = run_stax_with_env(
&repo,
home.path(),
&["merge", "--yes", "--no-wait", "--no-delete", "--no-sync"],
);
assert!(
merge_output.status.success(),
"Merge failed: {}\n{}",
TestRepo::stderr(&merge_output),
TestRepo::stdout(&merge_output)
);
let metadata_ref = format!("refs/branch-metadata/{}", branch_b);
let metadata_output = repo.git(&["show", &metadata_ref]);
assert!(
metadata_output.status.success(),
"Failed to read branch_b metadata: {}",
TestRepo::stderr(&metadata_output)
);
let metadata = TestRepo::stdout(&metadata_output);
assert!(
metadata.contains("\"parentBranchName\":\"main\""),
"Expected merge-b to be reparented to trunk, metadata was: {}",
metadata
);
let merge_stdout = TestRepo::stdout(&merge_output);
let merge_stderr = TestRepo::stderr(&merge_output);
let merge_combined = format!("{}{}", merge_stdout, merge_stderr);
assert!(
merge_stdout.contains("Already merged"),
"Expected merge output to include already-merged path. Output:\n{}",
merge_stdout
);
assert!(
!merge_combined.contains("Rebase conflict"),
"Expected provenance-aware rebase to avoid conflicts. Output:\n{}",
merge_combined
);
let unique_count = git_with_env(
&repo,
home.path(),
&["rev-list", "--count", &format!("origin/main..{}", branch_b)],
);
assert!(
unique_count.status.success(),
"Failed to count unique commits for {}: {}",
branch_b,
TestRepo::stderr(&unique_count)
);
assert_eq!(
TestRepo::stdout(&unique_count).trim(),
"1",
"Expected descendant branch to keep only novel commits after squash-merge restack"
);
}
#[tokio::test]
async fn test_merge_retargets_next_pr_before_merging_parent_pr() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/101",
"id": 101,
"number": 101,
"state": "open",
"draft": false,
"head": { "ref": "merge-a", "sha": "sha-a", "label": "test:merge-a" },
"base": { "ref": "main", "sha": "main-sha" }
},
{
"url": "https://api.github.com/repos/test/repo/pulls/102",
"id": 102,
"number": 102,
"state": "open",
"draft": false,
"head": { "ref": "merge-b", "sha": "sha-b", "label": "test:merge-b" },
"base": { "ref": "merge-a", "sha": "sha-a" }
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/101"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/101",
"id": 101,
"number": 101,
"state": "open",
"draft": false,
"merged_at": null,
"mergeable": true,
"mergeable_state": "clean",
"head": { "ref": "merge-a", "sha": "sha-a", "label": "test:merge-a" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/102"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/102",
"id": 102,
"number": 102,
"state": "open",
"draft": false,
"merged_at": null,
"mergeable": true,
"mergeable_state": "clean",
"head": { "ref": "merge-b", "sha": "sha-b", "label": "test:merge-b" },
"base": { "ref": "merge-a", "sha": "sha-a" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/102"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/102",
"id": 102,
"number": 102,
"state": "open",
"draft": false,
"head": { "ref": "merge-b", "sha": "sha-b", "label": "test:merge-b" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/repos/test/repo/pulls/101/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sha": "merge-a-commit",
"merged": true,
"message": "Pull Request successfully merged"
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/repos/test/repo/pulls/102/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sha": "merge-b-commit",
"merged": true,
"message": "Pull Request successfully merged"
})))
.mount(&mock_server)
.await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let _remote_root = setup_fake_github_remote(&repo, home.path());
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_env(&repo, home.path(), &["bc", "merge-a"]);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let branch_a = repo.current_branch();
repo.create_file("parent.txt", "parent\n");
repo.commit("Parent commit");
let push_a = git_with_env(&repo, home.path(), &["push", "-u", "origin", &branch_a]);
assert!(push_a.status.success(), "{}", TestRepo::stderr(&push_a));
let output = run_stax_with_env(&repo, home.path(), &["bc", "merge-b"]);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let branch_b = repo.current_branch();
repo.create_file("child.txt", "child\n");
repo.commit("Child commit");
let push_b = git_with_env(&repo, home.path(), &["push", "-u", "origin", &branch_b]);
assert!(push_b.status.success(), "{}", TestRepo::stderr(&push_b));
let merge_output = run_stax_with_env(
&repo,
home.path(),
&["merge", "--yes", "--no-wait", "--no-delete", "--no-sync"],
);
assert!(
merge_output.status.success(),
"Merge failed: {}\n{}",
TestRepo::stderr(&merge_output),
TestRepo::stdout(&merge_output)
);
let requests = mock_server
.received_requests()
.await
.expect("request recording enabled");
let patch_idx = find_request_index(&requests, "PATCH", "/repos/test/repo/pulls/102");
let merge_idx = find_request_index(&requests, "PUT", "/repos/test/repo/pulls/101/merge");
assert!(
patch_idx < merge_idx,
"Expected dependent PR retarget before parent merge, requests were: {:?}",
requests
.iter()
.map(|request| format!("{} {}", request.method, request.url.path()))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_merge_when_ready_already_merged_pr_still_rebases_next_branch_and_reparents_metadata(
) {
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let remote_root = setup_fake_github_remote(&repo, home.path());
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_env(&repo, home.path(), &["bc", "mwr-a"]);
assert!(
output.status.success(),
"Failed to create mwr-a: {}",
TestRepo::stderr(&output)
);
let branch_a = repo.current_branch();
repo.create_file("parent.txt", "parent 1\n");
repo.commit("Parent commit 1");
repo.create_file("parent.txt", "parent 1\nparent 2\n");
repo.commit("Parent commit 2");
let push_a = git_with_env(&repo, home.path(), &["push", "-u", "origin", &branch_a]);
assert!(
push_a.status.success(),
"Failed to push mwr-a: {}",
TestRepo::stderr(&push_a)
);
let output = run_stax_with_env(&repo, home.path(), &["bc", "mwr-b"]);
assert!(
output.status.success(),
"Failed to create mwr-b: {}",
TestRepo::stderr(&output)
);
let branch_b = repo.current_branch();
repo.create_file("b.txt", "b");
repo.commit("B");
let push_b = git_with_env(&repo, home.path(), &["push", "-u", "origin", &branch_b]);
assert!(
push_b.status.success(),
"Failed to push mwr-b: {}",
TestRepo::stderr(&push_b)
);
squash_merge_branch_on_fake_remote(&remote_root, &branch_a);
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/201",
"id": 201,
"number": 201,
"state": "open",
"draft": false,
"head": { "ref": branch_a, "sha": "sha-a", "label": "test:mwr-a" },
"base": { "ref": "main", "sha": "main-sha" }
},
{
"url": "https://api.github.com/repos/test/repo/pulls/202",
"id": 202,
"number": 202,
"state": "open",
"draft": false,
"head": { "ref": branch_b, "sha": "sha-b", "label": "test:mwr-b" },
"base": { "ref": branch_a, "sha": "sha-a" }
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/201"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/201",
"id": 201,
"number": 201,
"state": "closed",
"draft": false,
"merged_at": "2024-01-01T00:00:00Z",
"mergeable": true,
"mergeable_state": "clean",
"head": { "ref": branch_a, "sha": "sha-a", "label": "test:mwr-a" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/202"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/202",
"id": 202,
"number": 202,
"state": "open",
"draft": false,
"merged_at": null,
"mergeable": true,
"mergeable_state": "clean",
"head": { "ref": branch_b, "sha": "sha-b", "label": "test:mwr-b" },
"base": { "ref": branch_a, "sha": "sha-a" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/202"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/202",
"id": 202,
"number": 202,
"state": "open",
"draft": false,
"head": { "ref": branch_b, "sha": "sha-b", "label": "test:mwr-b" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/repos/test/repo/pulls/202/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sha": "merge-commit",
"merged": true,
"message": "Pull Request successfully merged"
})))
.mount(&mock_server)
.await;
let merge_output = run_stax_with_env(
&repo,
home.path(),
&[
"merge",
"--when-ready",
"--yes",
"--no-delete",
"--timeout",
"1",
"--interval",
"1",
],
);
assert!(
merge_output.status.success(),
"Merge-when-ready failed: {}\n{}",
TestRepo::stderr(&merge_output),
TestRepo::stdout(&merge_output)
);
let metadata_ref = format!("refs/branch-metadata/{}", branch_b);
let metadata_output = repo.git(&["show", &metadata_ref]);
assert!(
metadata_output.status.success(),
"Failed to read branch_b metadata: {}",
TestRepo::stderr(&metadata_output)
);
let metadata = TestRepo::stdout(&metadata_output);
assert!(
metadata.contains("\"parentBranchName\":\"main\""),
"Expected mwr-b to be reparented to trunk, metadata was: {}",
metadata
);
let merge_stdout = TestRepo::stdout(&merge_output);
let merge_stderr = TestRepo::stderr(&merge_output);
let merge_combined = format!("{}{}", merge_stdout, merge_stderr);
assert!(
merge_stdout.contains("Already merged"),
"Expected merge-when-ready output to include already-merged path. Output:\n{}",
merge_stdout
);
assert!(
!merge_combined.contains("Rebase conflict"),
"Expected provenance-aware rebase to avoid conflicts. Output:\n{}",
merge_combined
);
let unique_count = git_with_env(
&repo,
home.path(),
&["rev-list", "--count", &format!("origin/main..{}", branch_b)],
);
assert!(
unique_count.status.success(),
"Failed to count unique commits for {}: {}",
branch_b,
TestRepo::stderr(&unique_count)
);
assert_eq!(
TestRepo::stdout(&unique_count).trim(),
"1",
"Expected descendant branch to keep only novel commits after squash-merge restack"
);
}
#[tokio::test]
async fn test_merge_when_ready_retargets_next_pr_before_merging_parent_pr() {
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let _remote_root = setup_fake_github_remote(&repo, home.path());
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_env(&repo, home.path(), &["bc", "mwr-a"]);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let branch_a = repo.current_branch();
repo.create_file("parent.txt", "parent\n");
repo.commit("Parent commit");
let push_a = git_with_env(&repo, home.path(), &["push", "-u", "origin", &branch_a]);
assert!(push_a.status.success(), "{}", TestRepo::stderr(&push_a));
let output = run_stax_with_env(&repo, home.path(), &["bc", "mwr-b"]);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let branch_b = repo.current_branch();
repo.create_file("child.txt", "child\n");
repo.commit("Child commit");
let push_b = git_with_env(&repo, home.path(), &["push", "-u", "origin", &branch_b]);
assert!(push_b.status.success(), "{}", TestRepo::stderr(&push_b));
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"url": "https://api.github.com/repos/test/repo/pulls/201",
"id": 201,
"number": 201,
"state": "open",
"draft": false,
"head": { "ref": branch_a, "sha": "sha-a", "label": "test:mwr-a" },
"base": { "ref": "main", "sha": "main-sha" }
},
{
"url": "https://api.github.com/repos/test/repo/pulls/202",
"id": 202,
"number": 202,
"state": "open",
"draft": false,
"head": { "ref": branch_b, "sha": "sha-b", "label": "test:mwr-b" },
"base": { "ref": branch_a, "sha": "sha-a" }
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/201"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/201",
"id": 201,
"number": 201,
"state": "open",
"draft": false,
"merged_at": null,
"mergeable": true,
"mergeable_state": "clean",
"head": { "ref": branch_a, "sha": "sha-a", "label": "test:mwr-a" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/202"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/202",
"id": 202,
"number": 202,
"state": "open",
"draft": false,
"merged_at": null,
"mergeable": true,
"mergeable_state": "clean",
"head": { "ref": branch_b, "sha": "sha-b", "label": "test:mwr-b" },
"base": { "ref": branch_a, "sha": "sha-a" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/202"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/202",
"id": 202,
"number": 202,
"state": "open",
"draft": false,
"head": { "ref": "mwr-b", "sha": "sha-b", "label": "test:mwr-b" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/repos/test/repo/pulls/201/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sha": "merge-a-commit",
"merged": true,
"message": "Pull Request successfully merged"
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/repos/test/repo/pulls/202/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sha": "merge-b-commit",
"merged": true,
"message": "Pull Request successfully merged"
})))
.mount(&mock_server)
.await;
let merge_output = run_stax_with_env(
&repo,
home.path(),
&[
"merge",
"--when-ready",
"--yes",
"--no-delete",
"--timeout",
"1",
"--interval",
"1",
"--no-sync",
],
);
assert!(
merge_output.status.success(),
"Merge-when-ready failed: {}\n{}",
TestRepo::stderr(&merge_output),
TestRepo::stdout(&merge_output)
);
let requests = mock_server
.received_requests()
.await
.expect("request recording enabled");
let patch_idx = find_request_index(&requests, "PATCH", "/repos/test/repo/pulls/202");
let merge_idx = find_request_index(&requests, "PUT", "/repos/test/repo/pulls/201/merge");
assert!(
patch_idx < merge_idx,
"Expected dependent PR retarget before parent merge, requests were: {:?}",
requests
.iter()
.map(|request| format!("{} {}", request.method, request.url.path()))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_github_api_mock_responses() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/git/refs/heads"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{"ref": "refs/heads/main", "object": {"sha": "abc123"}}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"number": 42,
"state": "open",
"title": "Existing PR",
"draft": false,
"head": {"ref": "feature-branch"}
}
])))
.mount(&mock_server)
.await;
let client = reqwest::Client::new();
let refs_response = client
.get(format!(
"{}/repos/test/repo/git/refs/heads",
mock_server.uri()
))
.send()
.await
.unwrap();
assert_eq!(refs_response.status(), 200);
let prs_response = client
.get(format!("{}/repos/test/repo/pulls", mock_server.uri()))
.send()
.await
.unwrap();
assert_eq!(prs_response.status(), 200);
let prs: Vec<serde_json::Value> = prs_response.json().await.unwrap();
assert_eq!(prs.len(), 1);
assert_eq!(prs[0]["number"], 42);
}
}