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("STAX_SHELL_INTEGRATION")
.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", "-a", "-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_marks_branches_checked_out_in_linked_worktrees() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
let branch_name = repo.current_branch();
repo.run_stax(&["t"]);
let worktree_path = repo.path().join("feature-1-wt");
let git_output = repo.git(&[
"worktree",
"add",
worktree_path.to_str().expect("utf8 worktree path"),
&branch_name,
]);
assert!(
git_output.status.success(),
"git worktree add failed: {}",
String::from_utf8_lossy(&git_output.stderr)
);
let output = repo.run_stax(&["status"]);
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 in status output");
assert!(
line.contains("↳"),
"Expected linked worktree glyph in status output line: {}",
line
);
let json_output = repo.run_stax(&["status", "--json"]);
assert!(
json_output.status.success(),
"Failed: {}",
TestRepo::stderr(&json_output)
);
let json: Value =
serde_json::from_str(&TestRepo::stdout(&json_output)).expect("Invalid JSON output");
let branch = json["branches"]
.as_array()
.expect("branches array")
.iter()
.find(|entry| entry["name"] == branch_name)
.expect("branch entry");
assert_eq!(branch["linked_worktree"], "feature-1-wt");
}
#[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_ls() {
let repo = TestRepo::new();
let output1 = repo.run_stax(&["status"]);
let output2 = repo.run_stax(&["ls"]);
assert!(output1.status.success());
assert!(output2.status.success());
}
#[test]
fn test_stack_alias_s() {
let repo = TestRepo::new();
let output = repo.run_stax(&["s", "--help"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(combined.contains("submit"));
assert!(combined.contains("restack"));
}
#[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_checkout_routes_to_existing_worktree_with_duplicate_leaf_name() {
let repo = TestRepo::new();
let routed_branch = "feature-route";
let sibling_branch = "other-route";
let routed_worktree = repo.path().join("lanes/a/WayveCode");
let sibling_worktree = repo.path().join("lanes/b/WayveCode");
let create_routed_branch = repo.git(&["branch", routed_branch]);
assert!(
create_routed_branch.status.success(),
"Failed to create routed branch: {}",
TestRepo::stderr(&create_routed_branch)
);
let routed_parent = routed_worktree
.parent()
.expect("routed worktree path should have a parent");
fs::create_dir_all(routed_parent).expect("Failed to create routed worktree parent dirs");
let routed_add = repo.git(&[
"worktree",
"add",
routed_worktree.to_str().expect("utf8 routed worktree path"),
routed_branch,
]);
assert!(
routed_add.status.success(),
"Failed to add routed worktree: {}",
TestRepo::stderr(&routed_add)
);
let create_sibling_branch = repo.git(&["branch", sibling_branch]);
assert!(
create_sibling_branch.status.success(),
"Failed to create sibling branch: {}",
TestRepo::stderr(&create_sibling_branch)
);
let sibling_parent = sibling_worktree
.parent()
.expect("sibling worktree path should have a parent");
fs::create_dir_all(sibling_parent).expect("Failed to create sibling worktree parent dirs");
let sibling_add = repo.git(&[
"worktree",
"add",
sibling_worktree
.to_str()
.expect("utf8 sibling worktree path"),
sibling_branch,
]);
assert!(
sibling_add.status.success(),
"Failed to add sibling worktree: {}",
TestRepo::stderr(&sibling_add)
);
let output = repo.run_stax(&["checkout", routed_branch]);
assert!(
output.status.success(),
"Checkout routing failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
let stderr = TestRepo::stderr(&output);
assert!(
stdout.contains("routing there instead"),
"Expected routed checkout message, got: {}",
stdout
);
assert!(
!stderr.contains("Multiple worktrees match"),
"Expected checkout to avoid ambiguous worktree lookup, got: {}",
stderr
);
assert_eq!(
repo.current_branch(),
"main",
"Routing to an existing worktree should not switch the current checkout"
);
}
#[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_reparent_restack_rewrites_onto_new_parent() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.create_file("feature1.txt", "one");
repo.commit("Commit feature 1");
repo.run_stax(&["bc", "feature-2"]);
let feature2 = repo.current_branch();
repo.create_file("feature2.txt", "two");
repo.commit("Commit feature 2");
assert!(repo.path().join("feature1.txt").exists());
assert!(repo.path().join("feature2.txt").exists());
repo.run_stax(&["t"]);
let output = repo.run_stax(&[
"branch",
"reparent",
"--branch",
&feature2,
"--parent",
"main",
"--restack",
]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
assert_eq!(repo.current_branch(), "main");
let co = repo.git(&["checkout", &feature2]);
assert!(co.status.success(), "checkout feature2: {:?}", co);
assert!(
!repo.path().join("feature1.txt").exists(),
"expected feature-2 without feature-1 file after reparent --restack"
);
assert!(repo.path().join("feature2.txt").exists());
}
#[test]
fn test_branch_reparent_without_restack_keeps_middle_ancestor_files() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-1"]);
repo.create_file("feature1.txt", "one");
repo.commit("Commit feature 1");
repo.run_stax(&["bc", "feature-2"]);
let feature2 = repo.current_branch();
repo.create_file("feature2.txt", "two");
repo.commit("Commit feature 2");
repo.run_stax(&["t"]);
let output = repo.run_stax(&[
"branch", "reparent", "--branch", &feature2, "--parent", "main",
]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("metadata only") || stdout.contains("Reparent updated stax"),
"expected guidance about metadata-only reparent, got: {}",
stdout
);
let co = repo.git(&["checkout", &feature2]);
assert!(co.status.success());
assert!(
repo.path().join("feature1.txt").exists(),
"without --restack, branch should still include ancestor commits from the middle branch"
);
assert!(repo.path().join("feature2.txt").exists());
}
#[test]
fn test_branch_reparent_restack_requires_existing_metadata() {
let repo = TestRepo::new();
repo.git(&["checkout", "-b", "raw-branch"]);
repo.create_file("only.txt", "x");
repo.commit("raw commit");
let output = repo.run_stax(&["branch", "reparent", "-p", "main", "--restack"]);
assert!(
!output.status.success(),
"expected failure without metadata"
);
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("--restack") || stderr.contains("metadata"),
"expected metadata hint, got: {}",
stderr
);
}
#[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", "-a"]);
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", "-a", "-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", "-a"]);
assert!(output.status.success());
}
#[test]
fn test_modify_on_fresh_branch_creates_first_commit_with_message() {
let repo = TestRepo::new();
hermetic_git_command()
.args(["config", "user.name", "Parent Author"])
.current_dir(repo.path())
.output()
.expect("Failed to set parent author");
hermetic_git_command()
.args(["config", "user.email", "parent@example.com"])
.current_dir(repo.path())
.output()
.expect("Failed to set parent email");
repo.create_file("shared.txt", "parent change");
repo.commit("Parent commit");
hermetic_git_command()
.args(["config", "user.name", "Test User"])
.current_dir(repo.path())
.output()
.expect("Failed to restore test author");
hermetic_git_command()
.args(["config", "user.email", "test@test.com"])
.current_dir(repo.path())
.output()
.expect("Failed to restore test email");
repo.run_stax(&["bc", "feature-first-commit"]);
let head_before = repo.head_sha();
repo.create_file("feature.txt", "new branch work");
let output = repo.run_stax(&["modify", "-a", "-m", "Feature commit"]);
assert!(
output.status.success(),
"modify should create the first branch commit: {}",
TestRepo::stderr(&output)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("Committed"),
"expected commit confirmation, got: {}",
stdout
);
let log_output = repo.git(&["log", "-1", "--format=%s%n%an <%ae>"]);
let log = String::from_utf8_lossy(&log_output.stdout);
let mut lines = log.lines();
assert_eq!(lines.next(), Some("Feature commit"));
assert_eq!(lines.next(), Some("Test User <test@test.com>"));
let head_after = repo.head_sha();
assert_ne!(
head_after, head_before,
"modify should create a new branch-local commit on a fresh branch"
);
let count_output = repo.git(&["rev-list", "--count", "main..HEAD"]);
assert_eq!(
String::from_utf8_lossy(&count_output.stdout).trim(),
"1",
"expected exactly one branch-local commit after the first modify"
);
repo.git(&["checkout", "main"]);
assert_eq!(repo.head_sha(), head_before, "main should remain untouched");
}
#[test]
fn test_modify_on_fresh_branch_without_message_guides_user() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-no-message"]);
let head_before = repo.head_sha();
repo.create_file("feature.txt", "new branch work");
let output = repo.run_stax(&["modify", "-a"]);
assert!(
!output.status.success(),
"modify without -m should fail on a fresh branch"
);
let stderr = TestRepo::stderr(&output);
assert!(
stderr.contains("has nothing to amend") && stderr.contains("Re-run with `-m <message>`"),
"expected guidance for creating the first commit, got: {}",
stderr
);
assert_eq!(
repo.head_sha(),
head_before,
"modify without -m should not rewrite the parent commit on a fresh branch"
);
}
#[test]
fn test_modify_on_fresh_branch_still_creates_commit_after_parent_moves() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "feature-parent-moved"]);
let feature_branch = repo.current_branch();
let shared_base = repo.head_sha();
repo.git(&["checkout", "main"]);
repo.create_file("main.txt", "main advanced");
repo.commit("Main advanced");
let main_after = repo.head_sha();
assert_ne!(main_after, shared_base, "main should have advanced");
repo.git(&["checkout", &feature_branch]);
assert_eq!(
repo.head_sha(),
shared_base,
"fresh branch should still point at the original parent boundary"
);
repo.create_file("feature.txt", "feature work");
let output = repo.run_stax(&["modify", "-a", "-m", "Feature commit"]);
assert!(
output.status.success(),
"modify should still create the first branch commit after parent moves: {}",
TestRepo::stderr(&output)
);
let feature_after = repo.head_sha();
assert_ne!(
feature_after, shared_base,
"expected a new branch-local commit after modify"
);
repo.git(&["checkout", "main"]);
assert_eq!(
repo.head_sha(),
main_after,
"modify on the child branch must not rewrite the advanced parent branch"
);
}
#[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: Value =
serde_json::from_str(&TestRepo::stdout(&metadata_output)).expect("Invalid JSON metadata");
assert_eq!(
metadata["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_squash_merged_parent_after_trunk_advances() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "restack-parent-advanced"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent 1\n");
repo.commit("Parent commit 1");
repo.run_stax(&["bc", "restack-child-advanced"]);
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.create_file("main-later.txt", "later trunk work\n");
repo.commit("Later trunk commit");
repo.run_stax(&["checkout", &child]);
let output = repo.run_stax(&["restack", "--quiet"]);
assert!(
output.status.success(),
"restack failed after trunk advanced\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: Value =
serde_json::from_str(&TestRepo::stdout(&metadata_output)).expect("Invalid JSON metadata");
assert_eq!(
metadata["parentBranchName"], "main",
"Expected child to be reparented to trunk after squash merge, 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 even when trunk advanced"
);
}
#[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: Value =
serde_json::from_str(&TestRepo::stdout(&metadata_output)).expect("Invalid JSON metadata");
assert_eq!(
metadata["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: Value =
serde_json::from_str(&TestRepo::stdout(&metadata_output)).expect("Invalid JSON metadata");
assert_eq!(
metadata["parentBranchName"], "main",
"Expected cleanup to reparent child before deleting parent, metadata was: {}",
metadata
);
}
#[test]
fn test_restack_cleanup_only_considers_stax_tracked_branches() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "tracked-branch"]);
repo.create_file("tracked.txt", "tracked");
repo.commit("Tracked commit");
repo.git(&["checkout", "main"]);
repo.git(&["checkout", "-b", "untracked-merged"]);
repo.create_file("untracked.txt", "untracked");
repo.git(&["add", "."]);
repo.git(&["commit", "-m", "Untracked commit"]);
repo.git(&["checkout", "main"]);
repo.git(&[
"merge",
"--no-ff",
"untracked-merged",
"-m",
"Merge untracked",
]);
repo.create_file("main-update.txt", "main update");
repo.commit("Main update");
repo.run_stax(&["checkout", "tracked-branch"]);
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 == "untracked-merged"),
"Cleanup should not touch branches that are not tracked by stax"
);
}
#[test]
fn test_restack_cleanup_excludes_checked_out_branch() {
let repo = TestRepo::new();
repo.run_stax(&["bc", "merged-branch"]);
let merged = repo.current_branch();
repo.create_file("merged.txt", "merged");
repo.commit("Merged commit");
repo.run_stax(&["bc", "child-branch"]);
repo.create_file("child.txt", "child");
repo.commit("Child commit");
repo.run_stax(&["t"]);
repo.git(&["merge", "--no-ff", &merged, "-m", "Merge merged-branch"]);
repo.run_stax(&["bc", "trigger-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", &merged]);
assert_eq!(repo.current_branch(), merged);
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 == &merged),
"Cleanup should not delete the currently checked-out branch even if it is merged"
);
}
#[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_stop_here_skips_descendants() {
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");
let feature_2 = repo.current_branch();
repo.run_stax(&["bc", "feature-3"]);
repo.create_file("f3.txt", "content");
repo.commit("Feature 3");
let feature_3 = repo.current_branch();
repo.run_stax(&["t"]);
repo.create_file("main.txt", "main");
repo.commit("Main update");
repo.run_stax(&["checkout", &feature_2]);
let feature_3_before = repo.get_commit_sha(&feature_3);
let output = repo.run_stax(&["restack", "--stop-here", "--quiet"]);
assert!(
output.status.success(),
"Failed: {}",
TestRepo::stderr(&output)
);
let feature_3_after = repo.get_commit_sha(&feature_3);
assert_eq!(
feature_3_before, feature_3_after,
"Expected descendant branch to remain untouched by restack --stop-here"
);
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 feature_2_entry = branches
.iter()
.find(|b| b["name"].as_str().unwrap_or("") == feature_2.as_str())
.expect("Expected feature-2 in status");
let feature_3_entry = branches
.iter()
.find(|b| b["name"].as_str().unwrap_or("") == feature_3.as_str())
.expect("Expected feature-3 in status");
assert_eq!(feature_2_entry["needs_restack"], Value::Bool(false));
assert_eq!(feature_3_entry["needs_restack"], Value::Bool(true));
}
#[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 should exit non-zero on conflict\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 should exit non-zero on conflict\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)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("main +1 commit"),
"Expected trunk commit count in sync footer, got: {}",
stdout
);
assert!(
stdout.contains("+1 -0"),
"Expected trunk diff stats in sync footer, got: {}",
stdout
);
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)
);
let stdout = TestRepo::stdout(&output);
assert!(
stdout.contains("main +1 commit"),
"Expected trunk commit count in sync footer, got: {}",
stdout
);
assert!(
stdout.contains("restacked 1"),
"Expected restack count in sync footer, got: {}",
stdout
);
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", "-a"]);
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_does_not_treat_closed_unmerged_pr_as_merged() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-closed-pr"]);
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 main_sha = repo.get_commit_sha("main");
let metadata = serde_json::json!({
"parentBranchName": "main",
"parentBranchRevision": main_sha,
"prInfo": {
"number": 198,
"state": "CLOSED",
"isDraft": false
}
});
let metadata_json = metadata.to_string();
let mut hash_cmd = hermetic_git_command();
let metadata_oid_output = hash_cmd
.args(["hash-object", "-w", "--stdin"])
.current_dir(repo.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child
.stdin
.as_mut()
.expect("stdin")
.write_all(metadata_json.as_bytes())?;
child.wait_with_output()
})
.expect("Failed to write metadata blob");
assert!(
metadata_oid_output.status.success(),
"Failed to hash metadata: {}",
TestRepo::stderr(&metadata_oid_output)
);
let metadata_oid = TestRepo::stdout(&metadata_oid_output).trim().to_string();
let metadata_ref = format!("refs/branch-metadata/{}", branch_name);
let update_ref = repo.git(&["update-ref", &metadata_ref, &metadata_oid]);
assert!(
update_ref.status.success(),
"Failed to update metadata ref: {}",
TestRepo::stderr(&update_ref)
);
repo.run_stax(&["t"]);
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 == &branch_name),
"Expected closed-but-unmerged PR branch to remain after sync"
);
}
#[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_delete_upstream_gone_reparents_tracked_children() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "parent-200"]);
let parent_branch = repo.current_branch();
repo.create_file("parent.txt", "parent content");
repo.commit("Parent commit");
repo.git(&["push", "-u", "origin", &parent_branch]);
repo.run_stax(&["bc", "child-200"]);
let child_branch = repo.current_branch();
repo.create_file("child.txt", "child content");
repo.commit("Child commit");
repo.git(&["push", "-u", "origin", &child_branch]);
repo.git(&["checkout", "main"]);
repo.git(&["push", "origin", "--delete", &parent_branch]);
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.contains(&parent_branch),
"Expected parent to be deleted, still have: {:?}",
branches
);
assert!(
branches.contains(&child_branch),
"Expected child to survive, got: {:?}",
branches
);
let status = repo.run_stax(&["status", "--json"]);
let json: serde_json::Value =
serde_json::from_str(&TestRepo::stdout(&status)).expect("valid JSON");
let child_entry = json["branches"]
.as_array()
.expect("branches array")
.iter()
.find(|b| b["name"].as_str() == Some(&child_branch))
.expect("child in status");
let new_parent = child_entry["parent"]
.as_str()
.expect("child has a parent field");
assert_ne!(
new_parent, parent_branch,
"Child still points at the deleted parent"
);
assert_eq!(
new_parent, "main",
"Expected child to be reparented to main, got: {}",
new_parent
);
}
#[test]
fn test_sync_delete_upstream_gone_reparents_across_multiple_doomed_ancestors() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "grand-200"]);
let grand = repo.current_branch();
repo.create_file("grand.txt", "grand");
repo.commit("grand");
repo.git(&["push", "-u", "origin", &grand]);
repo.run_stax(&["bc", "mid-200"]);
let mid = repo.current_branch();
repo.create_file("mid.txt", "mid");
repo.commit("mid");
repo.git(&["push", "-u", "origin", &mid]);
repo.run_stax(&["bc", "leaf-200"]);
let leaf = repo.current_branch();
repo.create_file("leaf.txt", "leaf");
repo.commit("leaf");
repo.git(&["push", "-u", "origin", &leaf]);
repo.git(&["checkout", "main"]);
repo.git(&["push", "origin", "--delete", &grand]);
repo.git(&["push", "origin", "--delete", &mid]);
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.contains(&grand),
"grand should be deleted, got: {:?}",
branches
);
assert!(
!branches.contains(&mid),
"mid should be deleted, got: {:?}",
branches
);
assert!(
branches.contains(&leaf),
"leaf should survive, got: {:?}",
branches
);
let status = repo.run_stax(&["status", "--json"]);
let json: serde_json::Value =
serde_json::from_str(&TestRepo::stdout(&status)).expect("valid JSON");
let leaf_entry = json["branches"]
.as_array()
.expect("branches array")
.iter()
.find(|b| b["name"].as_str() == Some(&leaf))
.expect("leaf in status");
let new_parent = leaf_entry["parent"]
.as_str()
.expect("leaf has a parent field");
assert_ne!(new_parent, mid, "leaf still points at deleted mid");
assert_ne!(new_parent, grand, "leaf still points at deleted grand");
assert_eq!(
new_parent, "main",
"Expected leaf to be reparented to main, got: {}",
new_parent
);
}
#[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!(
stdout.contains("cleaned 1 merged"),
"Expected merged cleanup count in sync footer. 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_sync_restack_handles_squash_merged_parent_after_trunk_advances() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "sync-squash-parent"]);
let parent = repo.current_branch();
repo.create_file("parent.txt", "parent 1\n");
repo.commit("Parent commit 1");
repo.git(&["push", "-u", "origin", &parent]);
repo.run_stax(&["bc", "sync-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"]);
std::fs::write(clone_dir.path().join("later.txt"), "later trunk work\n").unwrap();
run_remote_git(&["add", "later.txt"]);
run_remote_git(&["commit", "-m", "Later trunk commit"]);
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 after trunk advanced\nstdout: {}\nstderr: {}",
TestRepo::stdout(&output),
TestRepo::stderr(&output)
);
assert!(
!TestRepo::stdout(&output).contains("conflict"),
"Expected no conflict after provenance-aware sync restack.\nstdout: {}",
TestRepo::stdout(&output)
);
let branches = repo.list_branches();
assert!(
!branches.iter().any(|b| b == &parent),
"Expected merged parent branch to be deleted"
);
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: Value =
serde_json::from_str(&TestRepo::stdout(&metadata_output)).expect("Invalid JSON metadata");
assert_eq!(
metadata["parentBranchName"], "main",
"Expected child 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 keep only novel commits after sync restack with advanced trunk"
);
}
#[test]
fn test_sync_restack_restacks_full_chain_after_squash_merge() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "chain-a"]);
let branch_a = repo.current_branch();
repo.create_file("a.txt", "a content\n");
repo.commit("Commit A");
repo.git(&["push", "-u", "origin", &branch_a]);
repo.run_stax(&["bc", "chain-b"]);
let branch_b = repo.current_branch();
repo.create_file("b.txt", "b content\n");
repo.commit("Commit B");
repo.git(&["push", "-u", "origin", &branch_b]);
repo.run_stax(&["bc", "chain-c"]);
let branch_c = repo.current_branch();
repo.create_file("c.txt", "c content\n");
repo.commit("Commit C");
repo.git(&["push", "-u", "origin", &branch_c]);
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", &branch_a]);
run_remote_git(&["merge", "--squash", &format!("origin/{}", branch_a)]);
run_remote_git(&["commit", "-m", "Squash merge A"]);
run_remote_git(&["push", "origin", "main"]);
run_remote_git(&["push", "origin", "--delete", &branch_a]);
repo.run_stax(&["checkout", &branch_c]);
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 no conflict during sync --restack.\nstdout: {}",
TestRepo::stdout(&output)
);
let branches = repo.list_branches();
assert!(
!branches.iter().any(|b| b == &branch_a),
"Expected merged branch_a to be deleted"
);
let count_b = repo.git(&["rev-list", "--count", &format!("main..{}", branch_b)]);
assert!(count_b.status.success());
assert_eq!(
String::from_utf8_lossy(&count_b.stdout).trim(),
"1",
"Expected branch_b to have 1 unique commit after restack onto main"
);
let count_c = repo.git(&[
"rev-list",
"--count",
&format!("{}..{}", branch_b, branch_c),
]);
assert!(count_c.status.success());
assert_eq!(
String::from_utf8_lossy(&count_c.stdout).trim(),
"1",
"Expected branch_c to have 1 unique commit after restack onto branch_b (issue #118)"
);
let count_c_main = repo.git(&["rev-list", "--count", &format!("main..{}", branch_c)]);
assert!(count_c_main.status.success());
assert_eq!(
String::from_utf8_lossy(&count_c_main.stdout).trim(),
"2",
"Expected branch_c to have 2 unique commits relative to main after full restack"
);
}
#[test]
fn test_sync_restack_no_ghost_commits_after_two_step_squash_merge() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "ghost-parent"]);
let branch_a = repo.current_branch();
repo.create_file("a1.txt", "a1\n");
repo.commit("A commit 1");
repo.create_file("a2.txt", "a2\n");
repo.commit("A commit 2");
repo.create_file("a3.txt", "a3\n");
repo.commit("A commit 3");
repo.git(&["push", "-u", "origin", &branch_a]);
repo.run_stax(&["bc", "ghost-child"]);
let branch_b = repo.current_branch();
repo.create_file("b1.txt", "b1\n");
repo.commit("B commit 1");
repo.git(&["push", "-u", "origin", &branch_b]);
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", &branch_a]);
run_remote_git(&["merge", "--squash", &format!("origin/{}", branch_a)]);
run_remote_git(&["commit", "-m", "Squash merge A (3 commits)"]);
run_remote_git(&["push", "origin", "main"]);
run_remote_git(&["push", "origin", "--delete", &branch_a]);
repo.run_stax(&["checkout", &branch_b]);
let output1 = repo.run_stax(&["sync", "--restack", "--force"]);
assert!(
output1.status.success(),
"First sync --restack failed\nstdout: {}\nstderr: {}",
TestRepo::stdout(&output1),
TestRepo::stderr(&output1)
);
let output2 = repo.run_stax(&["sync", "--restack", "--force"]);
assert!(
output2.status.success(),
"Second sync --restack failed\nstdout: {}\nstderr: {}",
TestRepo::stdout(&output2),
TestRepo::stderr(&output2)
);
let branches = repo.list_branches();
assert!(
!branches.iter().any(|b| b == &branch_a),
"Expected merged branch_a to be deleted, branches: {:?}",
branches
);
let count_b = repo.git(&["rev-list", "--count", &format!("main..{}", branch_b)]);
assert!(count_b.status.success());
let unique_commits = String::from_utf8_lossy(&count_b.stdout).trim().to_string();
assert_eq!(
unique_commits, "1",
"Expected branch_b to have 1 unique commit (no ghost commits from A), got {} (issue #120)",
unique_commits
);
}
#[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");
assert!(
stdout.contains("--remote"),
"Expected --remote flag in help"
);
}
#[test]
fn test_merge_remote_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", "--remote"]);
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_remote_on_untracked_branch_shows_error() {
let repo = TestRepo::new();
repo.run_stax(&["status"]);
repo.git(&["checkout", "-b", "untracked-remote"]);
repo.create_file("test.txt", "content");
repo.commit("Untracked commit");
let output = repo.run_stax(&["merge", "--remote"]);
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_remote_without_pr_shows_error() {
let repo = TestRepo::new_with_remote();
repo.run_stax(&["bc", "feature-remote-no-pr"]);
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
let output = repo.run_stax(&["merge", "--remote", "--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_remote_conflicts_with_when_ready() {
let repo = TestRepo::new();
let output = repo.run_stax(&["merge", "--remote", "--when-ready"]);
let stderr = TestRepo::stderr(&output);
assert!(
!output.status.success(),
"Expected non-success for conflicting flags"
);
assert!(
stderr.contains("cannot be used with") || stderr.contains("conflicts with"),
"Expected clap conflict error, got: {}",
stderr
);
}
#[test]
fn test_merge_remote_conflicts_with_dry_run() {
let repo = TestRepo::new();
let output = repo.run_stax(&["merge", "--remote", "--dry-run"]);
let stderr = TestRepo::stderr(&output);
assert!(
!output.status.success(),
"Expected non-success for conflicting flags"
);
assert!(
stderr.contains("cannot be used with") || stderr.contains("conflicts with"),
"Expected clap conflict error, got: {}",
stderr
);
}
#[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 forge_mock_tests {
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use wiremock::matchers::{method, path, path_regex, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn ensure_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
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 git_in_dir_with_env(cwd: &Path, home: &Path, args: &[&str]) -> Output {
let gitconfig = ensure_empty_gitconfig(home);
hermetic_git_command()
.args(args)
.current_dir(cwd)
.env("HOME", home)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", &gitconfig)
.output()
.expect("Failed to run git command in custom cwd")
}
fn setup_fake_github_remote(repo: &TestRepo, home: &Path) -> TempDir {
setup_fake_remote(
repo,
home,
"https://github.com/test/repo.git",
"https://github.com/",
)
}
fn setup_fake_remote(
repo: &TestRepo,
home: &Path,
remote_url: &str,
remote_base: &str,
) -> 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", remote_url]);
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),
remote_base,
],
);
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 find_body_update<'a>(
requests: &'a [wiremock::Request],
method_name: &str,
path_name: &str,
json_key: &str,
) -> &'a wiremock::Request {
requests
.iter()
.find(|request| {
request.method.as_str() == method_name
&& request.url.path() == path_name
&& serde_json::from_slice::<serde_json::Value>(&request.body)
.ok()
.and_then(|v| v.get(json_key).cloned())
.is_some()
})
.unwrap_or_else(|| {
panic!(
"Did not find {} request to {} with '{}' in payload",
method_name, path_name, json_key
)
})
}
fn find_body_patch<'a>(
requests: &'a [wiremock::Request],
path_name: &str,
) -> &'a wiremock::Request {
find_body_update(requests, "PATCH", path_name, "body")
}
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 gitlab_mr_fixture(
iid: u64,
title: &str,
source_branch: &str,
target_branch: &str,
state: &str,
description: &str,
sha: &str,
pipeline_status: Option<&str>,
) -> serde_json::Value {
let mut mr = serde_json::json!({
"iid": iid,
"title": title,
"state": state,
"draft": false,
"source_branch": source_branch,
"target_branch": target_branch,
"description": description,
"merge_status": "can_be_merged",
"detailed_merge_status": "mergeable",
"web_url": format!("https://gitlab.com/test/repo/-/merge_requests/{}", iid),
"sha": sha
});
if let Some(status) = pipeline_status {
mr["head_pipeline"] = serde_json::json!({ "status": status });
}
mr
}
fn gitlab_note_fixture(id: u64, body: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"body": body,
"created_at": "2024-01-01T00:00:00Z",
"author": { "username": "stax" }
})
}
fn gitea_pull_fixture(
number: u64,
title: &str,
head_branch: &str,
base_branch: &str,
state: &str,
body: &str,
merged: bool,
head_sha: &str,
) -> serde_json::Value {
serde_json::json!({
"number": number,
"state": state,
"title": title,
"body": body,
"draft": false,
"mergeable": true,
"mergeable_state": "clean",
"merged": merged,
"head": {
"ref": head_branch,
"sha": head_sha,
"label": format!("test:{}", head_branch)
},
"base": {
"ref": base_branch,
"sha": format!("{}-sha", base_branch),
"label": format!("test:{}", base_branch)
}
})
}
fn gitea_comment_fixture(id: u64, body: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"body": body,
"created_at": "2024-01-01T00:00:00Z",
"user": { "login": "stax" }
})
}
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 {
run_stax_with_token_env(repo, home, "STAX_GITHUB_TOKEN", args)
}
fn run_stax_in_dir_with_env(cwd: &Path, home: &Path, args: &[&str]) -> Output {
run_stax_in_dir_with_token_env(cwd, home, "STAX_GITHUB_TOKEN", args)
}
fn run_stax_with_token_env(
repo: &TestRepo,
home: &Path,
token_env: &str,
args: &[&str],
) -> Output {
let gitconfig = ensure_empty_gitconfig(home);
let mut command = Command::new(stax_bin());
command
.args(args)
.current_dir(repo.path())
.env("HOME", home)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", &gitconfig)
.env(token_env, "mock-token")
.env("STAX_DISABLE_UPDATE_CHECK", "1");
command.output().expect("Failed to execute stax")
}
fn run_stax_in_dir_with_token_env(
cwd: &Path,
home: &Path,
token_env: &str,
args: &[&str],
) -> Output {
let gitconfig = ensure_empty_gitconfig(home);
let mut command = Command::new(stax_bin());
command
.args(args)
.current_dir(cwd)
.env("HOME", home)
.env("GIT_CONFIG_GLOBAL", &gitconfig)
.env("GIT_CONFIG_SYSTEM", &gitconfig)
.env(token_env, "mock-token")
.env("STAX_DISABLE_UPDATE_CHECK", "1");
command
.output()
.expect("Failed to execute stax in custom cwd")
}
fn worktree_path(repo: &TestRepo, home: &Path, lane_name: &str) -> PathBuf {
let output = run_stax_with_env(repo, home, &["wt", "path", lane_name]);
assert!(
output.status.success(),
"Failed to resolve worktree path: {}",
TestRepo::stderr(&output)
);
PathBuf::from(TestRepo::stdout(&output).trim())
}
fn git_current_branch(cwd: &Path, home: &Path) -> String {
let output = git_in_dir_with_env(cwd, home, &["rev-parse", "--abbrev-ref", "HEAD"]);
assert!(
output.status.success(),
"Failed to read current branch: {}",
TestRepo::stderr(&output)
);
TestRepo::stdout(&output).trim().to_string()
}
fn setup_branch_with_remote(home: &Path, branch: &str) -> TestRepo {
setup_branch_with_forge_remote(
home,
branch,
"https://github.com/test/repo.git",
"https://github.com/",
"STAX_GITHUB_TOKEN",
)
}
fn setup_branch_with_forge_remote(
home: &Path,
branch: &str,
remote_url: &str,
remote_base: &str,
token_env: &str,
) -> TestRepo {
let repo = TestRepo::new();
let _remote_root = setup_fake_remote(&repo, home, remote_url, remote_base);
let output = run_stax_with_token_env(&repo, home, token_env, &["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) {
ensure_crypto_provider();
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() {
ensure_crypto_provider();
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()
);
}
#[test]
fn test_managed_lane_branch_submit_no_pr_pushes_lane_branch_only() {
let home = super::test_tempdir();
let repo = TestRepo::new();
let remote_root = setup_fake_github_remote(&repo, home.path());
let output = run_stax_with_env(&repo, home.path(), &["bc", "sibling-scope"]);
assert!(
output.status.success(),
"Failed to create sibling branch: {}",
TestRepo::stderr(&output)
);
repo.create_file("sibling.txt", "sibling\n");
repo.commit("Sibling commit");
let output = run_stax_with_env(&repo, home.path(), &["t"]);
assert!(
output.status.success(),
"Failed to return to trunk: {}",
TestRepo::stderr(&output)
);
let output = run_stax_with_env(
&repo,
home.path(),
&["wt", "c", "lane-submit", "--no-verify"],
);
assert!(
output.status.success(),
"Failed to create lane: {}",
TestRepo::stderr(&output)
);
let lane_path = worktree_path(&repo, home.path(), "lane-submit");
fs::write(lane_path.join("lane.txt"), "lane\n").expect("Failed to write lane file");
let add = git_in_dir_with_env(&lane_path, home.path(), &["add", "-A"]);
assert!(add.status.success(), "{}", TestRepo::stderr(&add));
let commit = git_in_dir_with_env(&lane_path, home.path(), &["commit", "-m", "Lane commit"]);
assert!(commit.status.success(), "{}", TestRepo::stderr(&commit));
let lane_branch = git_current_branch(&lane_path, home.path());
let output = run_stax_in_dir_with_env(&lane_path, home.path(), &["bs", "--no-pr", "--yes"]);
assert!(
output.status.success(),
"Lane branch submit failed: {}",
TestRepo::stderr(&output)
);
let remote_repo = remote_root.path().join("test").join("repo.git");
let heads = hermetic_git_command()
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads"])
.current_dir(remote_repo)
.output()
.expect("Failed to list remote heads");
assert!(heads.status.success(), "{}", TestRepo::stderr(&heads));
let remote_heads = TestRepo::stdout(&heads);
assert!(
remote_heads.lines().any(|head| head == lane_branch),
"Expected lane branch on remote, got:\n{}",
remote_heads
);
assert!(
!remote_heads
.lines()
.any(|head| head.contains("sibling-scope")),
"Sibling branch should not be submitted by lane-scoped `bs`, got:\n{}",
remote_heads
);
}
#[tokio::test]
async fn test_managed_lane_branch_submit_creates_pr_on_mock_github() {
ensure_crypto_provider();
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 = TestRepo::new();
let _remote_root = setup_fake_github_remote(&repo, home.path());
let output = run_stax_with_env(&repo, home.path(), &["wt", "c", "lane-pr", "--no-verify"]);
assert!(
output.status.success(),
"Failed to create lane: {}",
TestRepo::stderr(&output)
);
let lane_path = worktree_path(&repo, home.path(), "lane-pr");
fs::write(lane_path.join("lane.txt"), "lane\n").expect("Failed to write lane file");
let add = git_in_dir_with_env(&lane_path, home.path(), &["add", "-A"]);
assert!(add.status.success(), "{}", TestRepo::stderr(&add));
let commit =
git_in_dir_with_env(&lane_path, home.path(), &["commit", "-m", "Lane PR commit"]);
assert!(commit.status.success(), "{}", TestRepo::stderr(&commit));
let lane_branch = git_current_branch(&lane_path, home.path());
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/77",
"id": 77,
"number": 77,
"state": "open",
"draft": true,
"body": "",
"head": { "ref": lane_branch.clone(), "sha": "aaaa", "label": format!("test:{}", lane_branch) },
"base": { "ref": "main", "sha": "bbbb" },
"html_url": "https://github.com/test/repo/pull/77"
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/issues/77/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/77"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/77",
"id": 77,
"number": 77,
"state": "open",
"draft": true,
"body": "",
"head": { "ref": lane_branch.clone(), "sha": "aaaa", "label": format!("test:{}", lane_branch) },
"base": { "ref": "main", "sha": "bbbb" }
})))
.mount(&mock_server)
.await;
let output =
run_stax_in_dir_with_env(&lane_path, home.path(), &["bs", "--yes", "--no-prompt"]);
assert!(
output.status.success(),
"Lane branch PR submit failed: {}",
TestRepo::stderr(&output)
);
let requests = mock_server.received_requests().await.unwrap();
let pr_create = requests
.iter()
.find(|request| {
request.method.as_str() == "POST" && request.url.path() == "/repos/test/repo/pulls"
})
.expect("missing PR create request");
let payload: serde_json::Value = serde_json::from_slice(&pr_create.body).unwrap();
assert_eq!(payload["head"], lane_branch);
assert_eq!(payload["base"], "main");
assert_eq!(payload["draft"], true);
let metadata_ref = format!("refs/branch-metadata/{}", lane_branch);
let output = repo.git(&["show", &metadata_ref]);
assert!(
output.status.success(),
"Failed to read lane metadata: {}",
TestRepo::stderr(&output)
);
let metadata = TestRepo::stdout(&output);
assert!(
metadata.contains("\"number\":77"),
"Expected lane PR number in metadata, got: {}",
metadata
);
}
#[tokio::test]
async fn test_submit_persists_pr_info_for_existing_pr() {
ensure_crypto_provider();
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() {
ensure_crypto_provider();
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() {
ensure_crypto_provider();
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 = find_body_patch(&requests, "/repos/test/repo/pulls/42");
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() {
ensure_crypto_provider();
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 = find_body_patch(&requests, "/repos/test/repo/pulls/42");
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() {
ensure_crypto_provider();
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 = find_body_patch(&requests, "/repos/test/repo/pulls/42");
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() {
ensure_crypto_provider();
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 = find_body_patch(&requests, "/repos/test/repo/pulls/42");
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() {
ensure_crypto_provider();
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: Value = serde_json::from_str(&TestRepo::stdout(&metadata_output))
.expect("Invalid JSON metadata");
assert_eq!(
metadata["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_after_merging_parent_pr() {
ensure_crypto_provider();
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 after 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(
) {
ensure_crypto_provider();
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: Value = serde_json::from_str(&TestRepo::stdout(&metadata_output))
.expect("Invalid JSON metadata");
assert_eq!(
metadata["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_after_merging_parent_pr() {
ensure_crypto_provider();
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 after parent merge, requests were: {:?}",
requests
.iter()
.map(|request| format!("{} {}", request.method, request.url.path()))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_merge_remote_retargets_and_updates_branch_after_merging() {
ensure_crypto_provider();
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", "mremote-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", "mremote-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/301",
"id": 301,
"number": 301,
"state": "open",
"draft": false,
"head": { "ref": branch_a, "sha": "sha-a", "label": "test:mremote-a" },
"base": { "ref": "main", "sha": "main-sha" }
},
{
"url": "https://api.github.com/repos/test/repo/pulls/302",
"id": 302,
"number": 302,
"state": "open",
"draft": false,
"head": { "ref": branch_b, "sha": "sha-b", "label": "test:mremote-b" },
"base": { "ref": branch_a, "sha": "sha-a" }
}
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/301"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/301",
"id": 301,
"number": 301,
"state": "open",
"draft": false,
"merged_at": null,
"mergeable": true,
"mergeable_state": "clean",
"title": "p",
"head": { "ref": branch_a, "sha": "sha-a", "label": "test:mremote-a" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/302"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/302",
"id": 302,
"number": 302,
"state": "open",
"draft": false,
"merged_at": null,
"mergeable": true,
"mergeable_state": "clean",
"title": "c",
"head": { "ref": branch_b, "sha": "sha-b", "label": "test:mremote-b" },
"base": { "ref": branch_a, "sha": "sha-a" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/302"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"url": "https://api.github.com/repos/test/repo/pulls/302",
"id": 302,
"number": 302,
"state": "open",
"draft": false,
"head": { "ref": branch_b, "sha": "sha-b", "label": "test:mremote-b" },
"base": { "ref": "main", "sha": "main-sha" }
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/repos/test/repo/pulls/301/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/302/update-branch"))
.respond_with(ResponseTemplate::new(202).set_body_json(serde_json::json!({
"message": "Updating pull request branch.",
"url": "https://api.github.com/repos/test/repo/pulls/302"
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/repos/test/repo/pulls/302/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",
"--remote",
"--yes",
"--no-delete",
"--timeout",
"1",
"--interval",
"1",
"--no-sync",
],
);
assert!(
merge_output.status.success(),
"merge --remote 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/302");
let merge1_idx = find_request_index(&requests, "PUT", "/repos/test/repo/pulls/301/merge");
let update_idx =
find_request_index(&requests, "PUT", "/repos/test/repo/pulls/302/update-branch");
let merge2_idx = find_request_index(&requests, "PUT", "/repos/test/repo/pulls/302/merge");
assert!(
patch_idx > merge1_idx,
"Expected dependent PR retarget after parent merge"
);
assert!(
update_idx > merge1_idx,
"Expected update-branch after parent merge"
);
assert!(
update_idx < merge2_idx,
"Expected update-branch before child merge"
);
}
#[tokio::test]
async fn test_github_api_mock_responses() {
ensure_crypto_provider();
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);
}
#[tokio::test]
async fn test_submit_gitlab_comment_mode_creates_merge_request_and_stack_note() {
ensure_crypto_provider();
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
write_test_config(home.path(), &mock_server.uri());
let repo = TestRepo::new();
let _remote_root = setup_fake_remote(
&repo,
home.path(),
"https://gitlab.com/test/repo.git",
"https://gitlab.com/",
);
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["bc", "feature-gitlab-comment"],
);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests"))
.and(query_param("state", "opened"))
.and(query_param("source_branch", "feature-gitlab-comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/projects/test%2Frepo/merge_requests"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"iid": 42,
"title": "Feature commit",
"state": "opened",
"draft": false,
"source_branch": "feature-gitlab-comment",
"target_branch": "main",
"description": "",
"merge_status": "can_be_merged",
"detailed_merge_status": "mergeable",
"web_url": "https://gitlab.com/test/repo/-/merge_requests/42",
"sha": "abc123"
})))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/projects/test%2Frepo/merge_requests/42/notes"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": 900,
"body": "<!-- stax-stack-comment -->\ncomment",
"created_at": "2024-01-01T00:00:00Z",
"author": { "username": "stax" }
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"iid": 42,
"title": "Feature commit",
"state": "opened",
"draft": false,
"source_branch": "feature-gitlab-comment",
"target_branch": "main",
"description": "",
"merge_status": "can_be_merged",
"detailed_merge_status": "mergeable",
"web_url": "https://gitlab.com/test/repo/-/merge_requests/42",
"sha": "abc123"
})))
.mount(&mock_server)
.await;
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["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() == "POST"
&& request.url.path() == "/projects/test%2Frepo/merge_requests"
}));
let note_request = requests
.iter()
.find(|request| {
request.method.as_str() == "POST"
&& request.url.path() == "/projects/test%2Frepo/merge_requests/42/notes"
})
.expect("missing GitLab stack note request");
let payload: serde_json::Value = serde_json::from_slice(¬e_request.body).unwrap();
assert!(payload["body"]
.as_str()
.unwrap()
.contains("<!-- stax-stack-comment -->"));
}
#[tokio::test]
async fn test_submit_gitlab_body_mode_updates_merge_request_body() {
ensure_crypto_provider();
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 = TestRepo::new();
let _remote_root = setup_fake_remote(
&repo,
home.path(),
"https://gitlab.com/test/repo.git",
"https://gitlab.com/",
);
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["bc", "feature-gitlab-body"],
);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests"))
.and(query_param("state", "opened"))
.and(query_param("source_branch", "feature-gitlab-body"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/projects/test%2Frepo/merge_requests"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"iid": 42,
"title": "Feature commit",
"state": "opened",
"draft": false,
"source_branch": "feature-gitlab-body",
"target_branch": "main",
"description": "## Summary\n\nhello",
"merge_status": "can_be_merged",
"detailed_merge_status": "mergeable",
"web_url": "https://gitlab.com/test/repo/-/merge_requests/42",
"sha": "abc123"
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/42/notes"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"iid": 42,
"title": "Feature commit",
"state": "opened",
"draft": false,
"source_branch": "feature-gitlab-body",
"target_branch": "main",
"description": "## Summary\n\nhello",
"merge_status": "can_be_merged",
"detailed_merge_status": "mergeable",
"web_url": "https://gitlab.com/test/repo/-/merge_requests/42",
"sha": "abc123"
})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"iid": 42,
"title": "Feature commit",
"state": "opened",
"draft": false,
"source_branch": "feature-gitlab-body",
"target_branch": "main",
"description": "## Summary\n\nhello",
"merge_status": "can_be_merged",
"detailed_merge_status": "mergeable",
"web_url": "https://gitlab.com/test/repo/-/merge_requests/42",
"sha": "abc123"
})))
.mount(&mock_server)
.await;
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["submit", "--yes", "--no-prompt"],
);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let requests = mock_server.received_requests().await.unwrap();
let body_update = requests
.iter()
.find(|request| {
request.method.as_str() == "PUT"
&& request.url.path() == "/projects/test%2Frepo/merge_requests/42"
})
.expect("missing GitLab body update request");
let payload: serde_json::Value = serde_json::from_slice(&body_update.body).unwrap();
assert!(payload["description"]
.as_str()
.unwrap()
.contains("<!-- stax-stack-links:start -->"));
}
#[tokio::test]
async fn test_submit_gitlab_both_mode_updates_stack_note_and_body() {
ensure_crypto_provider();
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_forge_remote(
home.path(),
"feature-gitlab-both",
"https://gitlab.com/test/repo.git",
"https://gitlab.com/",
"STAX_GITLAB_TOKEN",
);
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests"))
.and(query_param("state", "opened"))
.and(query_param("source_branch", "feature-gitlab-both"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitlab_mr_fixture(
42,
"Feature commit",
"feature-gitlab-both",
"main",
"opened",
"## Summary\n\nhello",
"abc123",
None,
)
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/42/notes"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitlab_note_fixture(900, "<!-- stax-stack-comment -->\nold")
])))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/42/notes/900"))
.respond_with(
ResponseTemplate::new(200).set_body_json(gitlab_note_fixture(
900,
"<!-- stax-stack-comment -->\nupdated",
)),
)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
42,
"Feature commit",
"feature-gitlab-both",
"main",
"opened",
"## Summary\n\nhello",
"abc123",
None,
)))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
42,
"Feature commit",
"feature-gitlab-both",
"main",
"opened",
"## Summary\n\nhello",
"abc123",
None,
)))
.mount(&mock_server)
.await;
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["submit", "--yes", "--no-prompt"],
);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let requests = mock_server.received_requests().await.unwrap();
let note_update = requests
.iter()
.find(|request| {
request.method.as_str() == "PUT"
&& request.url.path() == "/projects/test%2Frepo/merge_requests/42/notes/900"
})
.expect("missing GitLab note update request");
let note_payload: serde_json::Value = serde_json::from_slice(¬e_update.body).unwrap();
assert!(note_payload["body"]
.as_str()
.unwrap()
.contains("<!-- stax-stack-comment -->"));
let body_update = find_body_update(
&requests,
"PUT",
"/projects/test%2Frepo/merge_requests/42",
"description",
);
let body_payload: serde_json::Value = serde_json::from_slice(&body_update.body).unwrap();
assert!(body_payload["description"]
.as_str()
.unwrap()
.contains("<!-- stax-stack-links:start -->"));
}
#[tokio::test]
async fn test_submit_gitlab_off_mode_removes_stack_note_and_body_block() {
ensure_crypto_provider();
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_forge_remote(
home.path(),
"feature-gitlab-off",
"https://gitlab.com/test/repo.git",
"https://gitlab.com/",
"STAX_GITLAB_TOKEN",
);
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests"))
.and(query_param("state", "opened"))
.and(query_param("source_branch", "feature-gitlab-off"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitlab_mr_fixture(
42,
"Feature commit",
"feature-gitlab-off",
"main",
"opened",
"## Summary\n\nhello",
"abc123",
None,
)
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/42/notes"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitlab_note_fixture(900, "<!-- stax-stack-comment -->\nold")
])))
.mount(&mock_server)
.await;
Mock::given(method("DELETE"))
.and(path("/projects/test%2Frepo/merge_requests/42/notes/900"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
42,
"Feature commit",
"feature-gitlab-off",
"main",
"opened",
"## Summary\n\nhello\n\n<!-- stax-stack-links:start -->\nold\n<!-- stax-stack-links:end -->",
"abc123",
None,
)))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
42,
"Feature commit",
"feature-gitlab-off",
"main",
"opened",
"## Summary\n\nhello",
"abc123",
None,
)))
.mount(&mock_server)
.await;
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["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() == "/projects/test%2Frepo/merge_requests/42/notes/900"
}));
let body_update = find_body_update(
&requests,
"PUT",
"/projects/test%2Frepo/merge_requests/42",
"description",
);
let payload: serde_json::Value = serde_json::from_slice(&body_update.body).unwrap();
assert_eq!(payload["description"], "## Summary\n\nhello");
}
#[tokio::test]
async fn test_merge_gitlab_retargets_next_mr_after_merging_parent_mr() {
ensure_crypto_provider();
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let _remote_root = setup_fake_remote(
&repo,
home.path(),
"https://gitlab.com/test/repo.git",
"https://gitlab.com/",
);
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["bc", "gitlab-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_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["bc", "gitlab-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));
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests"))
.and(query_param("state", "opened"))
.and(query_param("source_branch", branch_a.as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitlab_mr_fixture(
101,
"Parent",
branch_a.as_str(),
"main",
"opened",
"",
"sha-a",
None
)
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests"))
.and(query_param("state", "opened"))
.and(query_param("source_branch", branch_b.as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitlab_mr_fixture(
102,
"Child",
branch_b.as_str(),
branch_a.as_str(),
"opened",
"",
"sha-b",
None,
)
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/101"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
101,
"Parent",
branch_a.as_str(),
"main",
"opened",
"",
"sha-a",
Some("success"),
)))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/102"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
102,
"Child",
branch_b.as_str(),
branch_a.as_str(),
"opened",
"",
"sha-b",
Some("success"),
)))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path_regex(
r"/projects/test%2Frepo/repository/commits/.*/statuses",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"name": "pipeline",
"status": "success",
"target_url": "https://ci.example.com/1",
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T00:01:00Z"
}
])))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/102"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
102,
"Child",
branch_b.as_str(),
"main",
"opened",
"",
"sha-b",
Some("success"),
)))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/101/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/102/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&mock_server)
.await;
let merge_output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["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 retarget_idx =
find_request_index(&requests, "PUT", "/projects/test%2Frepo/merge_requests/102");
let merge_idx = find_request_index(
&requests,
"PUT",
"/projects/test%2Frepo/merge_requests/101/merge",
);
assert!(retarget_idx > merge_idx);
let retarget = requests
.iter()
.find(|request| {
request.method.as_str() == "PUT"
&& request.url.path() == "/projects/test%2Frepo/merge_requests/102"
})
.expect("missing GitLab retarget request");
let payload: serde_json::Value = serde_json::from_slice(&retarget.body).unwrap();
assert_eq!(payload["target_branch"], "main");
assert!(requests.iter().any(|request| {
request.method.as_str() == "GET"
&& request.url.path().contains("/repository/commits/")
&& request.url.path().ends_with("/statuses")
}));
}
#[tokio::test]
async fn test_merge_when_ready_gitlab_retargets_next_mr_after_merging_parent_mr() {
ensure_crypto_provider();
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let _remote_root = setup_fake_remote(
&repo,
home.path(),
"https://gitlab.com/test/repo.git",
"https://gitlab.com/",
);
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["bc", "gitlab-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_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&["bc", "gitlab-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("/projects/test%2Frepo/merge_requests"))
.and(query_param("state", "opened"))
.and(query_param("source_branch", branch_a.as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitlab_mr_fixture(
201,
"Parent",
branch_a.as_str(),
"main",
"opened",
"",
"sha-a",
None
)
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests"))
.and(query_param("state", "opened"))
.and(query_param("source_branch", branch_b.as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitlab_mr_fixture(
202,
"Child",
branch_b.as_str(),
branch_a.as_str(),
"opened",
"",
"sha-b",
None,
)
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/201"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
201,
"Parent",
branch_a.as_str(),
"main",
"opened",
"",
"sha-a",
Some("success"),
)))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/projects/test%2Frepo/merge_requests/202"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
202,
"Child",
branch_b.as_str(),
branch_a.as_str(),
"opened",
"",
"sha-b",
Some("success"),
)))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path_regex(
r"/projects/test%2Frepo/repository/commits/.*/statuses",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"name": "pipeline",
"status": "success",
"target_url": "https://ci.example.com/1",
"started_at": "2024-01-01T00:00:00Z",
"finished_at": "2024-01-01T00:01:00Z"
}
])))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/202"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitlab_mr_fixture(
202,
"Child",
branch_b.as_str(),
"main",
"opened",
"",
"sha-b",
Some("success"),
)))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/201/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/projects/test%2Frepo/merge_requests/202/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&mock_server)
.await;
let merge_output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITLAB_TOKEN",
&[
"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 retarget_idx =
find_request_index(&requests, "PUT", "/projects/test%2Frepo/merge_requests/202");
let merge_idx = find_request_index(
&requests,
"PUT",
"/projects/test%2Frepo/merge_requests/201/merge",
);
assert!(retarget_idx > merge_idx);
}
#[tokio::test]
async fn test_submit_gitea_comment_mode_creates_pull_and_issue_comment() {
ensure_crypto_provider();
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
write_test_config(home.path(), &mock_server.uri());
let repo = TestRepo::new();
let _remote_root = setup_fake_remote(
&repo,
home.path(),
"https://gitea.example.com/test/repo.git",
"https://gitea.example.com/",
);
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["bc", "feature-gitea-comment"],
);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.and(query_param("state", "open"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"number": 42,
"state": "open",
"title": "Feature commit",
"body": "",
"draft": false,
"mergeable": true,
"mergeable_state": "clean",
"merged": false,
"head": { "ref": "feature-gitea-comment", "sha": "abc123", "label": "test:feature-gitea-comment" },
"base": { "ref": "main", "sha": "def456", "label": "test:main" }
})))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/repos/test/repo/issues/42/comments"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": 901,
"body": "<!-- stax-stack-comment -->\ncomment",
"created_at": "2024-01-01T00:00:00Z",
"user": { "login": "stax" }
})))
.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!({
"number": 42,
"state": "open",
"title": "Feature commit",
"body": "",
"draft": false,
"mergeable": true,
"mergeable_state": "clean",
"merged": false,
"head": { "ref": "feature-gitea-comment", "sha": "abc123", "label": "test:feature-gitea-comment" },
"base": { "ref": "main", "sha": "def456", "label": "test:main" }
})))
.mount(&mock_server)
.await;
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["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() == "POST" && request.url.path() == "/repos/test/repo/pulls"
}));
let comment_request = requests
.iter()
.find(|request| {
request.method.as_str() == "POST"
&& request.url.path() == "/repos/test/repo/issues/42/comments"
})
.expect("missing Gitea issue comment request");
let payload: serde_json::Value = serde_json::from_slice(&comment_request.body).unwrap();
assert!(payload["body"]
.as_str()
.unwrap()
.contains("<!-- stax-stack-comment -->"));
}
#[tokio::test]
async fn test_submit_gitea_body_mode_updates_pull_body() {
ensure_crypto_provider();
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 = TestRepo::new();
let _remote_root = setup_fake_remote(
&repo,
home.path(),
"https://gitea.example.com/test/repo.git",
"https://gitea.example.com/",
);
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["bc", "feature-gitea-body"],
);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
repo.create_file("feature.txt", "content");
repo.commit("Feature commit");
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.and(query_param("state", "open"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/repos/test/repo/pulls"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"number": 42,
"state": "open",
"title": "Feature commit",
"body": "## Summary\n\nhello",
"draft": false,
"mergeable": true,
"mergeable_state": "clean",
"merged": false,
"head": { "ref": "feature-gitea-body", "sha": "abc123", "label": "test:feature-gitea-body" },
"base": { "ref": "main", "sha": "def456", "label": "test:main" }
})))
.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!([])))
.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!({
"number": 42,
"state": "open",
"title": "Feature commit",
"body": "## Summary\n\nhello",
"draft": false,
"mergeable": true,
"mergeable_state": "clean",
"merged": false,
"head": { "ref": "feature-gitea-body", "sha": "abc123", "label": "test:feature-gitea-body" },
"base": { "ref": "main", "sha": "def456", "label": "test:main" }
})))
.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!({
"number": 42,
"state": "open",
"title": "Feature commit",
"body": "## Summary\n\nhello",
"draft": false,
"mergeable": true,
"mergeable_state": "clean",
"merged": false,
"head": { "ref": "feature-gitea-body", "sha": "abc123", "label": "test:feature-gitea-body" },
"base": { "ref": "main", "sha": "def456", "label": "test:main" }
})))
.mount(&mock_server)
.await;
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["submit", "--yes", "--no-prompt"],
);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let requests = mock_server.received_requests().await.unwrap();
let body_update = find_body_patch(&requests, "/repos/test/repo/pulls/42");
let payload: serde_json::Value = serde_json::from_slice(&body_update.body).unwrap();
assert!(payload["body"]
.as_str()
.unwrap()
.contains("<!-- stax-stack-links:start -->"));
}
#[tokio::test]
async fn test_submit_gitea_both_mode_updates_issue_comment_and_body() {
ensure_crypto_provider();
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_forge_remote(
home.path(),
"feature-gitea-both",
"https://gitea.example.com/test/repo.git",
"https://gitea.example.com/",
"STAX_GITEA_TOKEN",
);
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.and(query_param("state", "open"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitea_pull_fixture(
42,
"Feature commit",
"feature-gitea-both",
"main",
"open",
"## Summary\n\nhello",
false,
"abc123",
)
])))
.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!([
gitea_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(gitea_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(gitea_pull_fixture(
42,
"Feature commit",
"feature-gitea-both",
"main",
"open",
"## Summary\n\nhello",
false,
"abc123",
)))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitea_pull_fixture(
42,
"Feature commit",
"feature-gitea-both",
"main",
"open",
"## Summary\n\nhello",
false,
"abc123",
)))
.mount(&mock_server)
.await;
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["submit", "--yes", "--no-prompt"],
);
assert!(output.status.success(), "{}", TestRepo::stderr(&output));
let requests = mock_server.received_requests().await.unwrap();
let comment_update = requests
.iter()
.find(|request| {
request.method.as_str() == "PATCH"
&& request.url.path() == "/repos/test/repo/issues/comments/901"
})
.expect("missing Gitea issue comment update request");
let comment_payload: serde_json::Value =
serde_json::from_slice(&comment_update.body).unwrap();
assert!(comment_payload["body"]
.as_str()
.unwrap()
.contains("<!-- stax-stack-comment -->"));
let body_update = find_body_patch(&requests, "/repos/test/repo/pulls/42");
let body_payload: serde_json::Value = serde_json::from_slice(&body_update.body).unwrap();
assert!(body_payload["body"]
.as_str()
.unwrap()
.contains("<!-- stax-stack-links:start -->"));
}
#[tokio::test]
async fn test_submit_gitea_off_mode_removes_issue_comment_and_body_block() {
ensure_crypto_provider();
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_forge_remote(
home.path(),
"feature-gitea-off",
"https://gitea.example.com/test/repo.git",
"https://gitea.example.com/",
"STAX_GITEA_TOKEN",
);
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.and(query_param("state", "open"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitea_pull_fixture(
42,
"Feature commit",
"feature-gitea-off",
"main",
"open",
"## Summary\n\nhello",
false,
"abc123",
)
])))
.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!([
gitea_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(gitea_pull_fixture(
42,
"Feature commit",
"feature-gitea-off",
"main",
"open",
"## Summary\n\nhello\n\n<!-- stax-stack-links:start -->\nold\n<!-- stax-stack-links:end -->",
false,
"abc123",
)))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitea_pull_fixture(
42,
"Feature commit",
"feature-gitea-off",
"main",
"open",
"## Summary\n\nhello",
false,
"abc123",
)))
.mount(&mock_server)
.await;
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["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_update = find_body_patch(&requests, "/repos/test/repo/pulls/42");
let payload: serde_json::Value = serde_json::from_slice(&body_update.body).unwrap();
assert_eq!(payload["body"], "## Summary\n\nhello");
}
#[tokio::test]
async fn test_merge_gitea_retargets_next_pr_after_merging_parent_pr() {
ensure_crypto_provider();
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let _remote_root = setup_fake_remote(
&repo,
home.path(),
"https://gitea.example.com/test/repo.git",
"https://gitea.example.com/",
);
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["bc", "gitea-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_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["bc", "gitea-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));
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls"))
.and(query_param("state", "open"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitea_pull_fixture(
101,
"Parent",
branch_a.as_str(),
"main",
"open",
"",
false,
"sha-a"
),
gitea_pull_fixture(
102,
"Child",
branch_b.as_str(),
branch_a.as_str(),
"open",
"",
false,
"sha-b"
)
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/101"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitea_pull_fixture(
101,
"Parent",
branch_a.as_str(),
"main",
"open",
"",
false,
"sha-a",
)))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/102"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitea_pull_fixture(
102,
"Child",
branch_b.as_str(),
branch_a.as_str(),
"open",
"",
false,
"sha-b",
)))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test/repo/commits/.*/statuses"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"context": "ci",
"status": "success",
"target_url": "https://ci.example.com/1",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:01:00Z"
}
])))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/102"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitea_pull_fixture(
102,
"Child",
branch_b.as_str(),
"main",
"open",
"",
false,
"sha-b",
)))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/repos/test/repo/pulls/101/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/repos/test/repo/pulls/102/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&mock_server)
.await;
let merge_output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["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 retarget_idx = find_request_index(&requests, "PATCH", "/repos/test/repo/pulls/102");
let merge_idx = find_request_index(&requests, "POST", "/repos/test/repo/pulls/101/merge");
assert!(retarget_idx > merge_idx);
let retarget = requests
.iter()
.find(|request| {
request.method.as_str() == "PATCH"
&& request.url.path() == "/repos/test/repo/pulls/102"
})
.expect("missing Gitea retarget request");
let payload: serde_json::Value = serde_json::from_slice(&retarget.body).unwrap();
assert_eq!(payload["base"], "main");
assert!(requests.iter().any(|request| {
request.method.as_str() == "GET"
&& request.url.path().contains("/commits/")
&& request.url.path().ends_with("/statuses")
}));
}
#[tokio::test]
async fn test_merge_when_ready_gitea_retargets_next_pr_after_merging_parent_pr() {
ensure_crypto_provider();
let mock_server = MockServer::start().await;
let home = super::test_tempdir();
let repo = TestRepo::new();
let _remote_root = setup_fake_remote(
&repo,
home.path(),
"https://gitea.example.com/test/repo.git",
"https://gitea.example.com/",
);
write_test_config(home.path(), &mock_server.uri());
let output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["bc", "gitea-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_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&["bc", "gitea-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"))
.and(query_param("state", "open"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
gitea_pull_fixture(
201,
"Parent",
branch_a.as_str(),
"main",
"open",
"",
false,
"sha-a"
),
gitea_pull_fixture(
202,
"Child",
branch_b.as_str(),
branch_a.as_str(),
"open",
"",
false,
"sha-b"
)
])))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/201"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitea_pull_fixture(
201,
"Parent",
branch_a.as_str(),
"main",
"open",
"",
false,
"sha-a",
)))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/repos/test/repo/pulls/202"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitea_pull_fixture(
202,
"Child",
branch_b.as_str(),
branch_a.as_str(),
"open",
"",
false,
"sha-b",
)))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/repos/test/repo/commits/.*/statuses"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"context": "ci",
"status": "success",
"target_url": "https://ci.example.com/1",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:01:00Z"
}
])))
.mount(&mock_server)
.await;
Mock::given(method("PATCH"))
.and(path("/repos/test/repo/pulls/202"))
.respond_with(ResponseTemplate::new(200).set_body_json(gitea_pull_fixture(
202,
"Child",
branch_b.as_str(),
"main",
"open",
"",
false,
"sha-b",
)))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/repos/test/repo/pulls/201/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&mock_server)
.await;
Mock::given(method("POST"))
.and(path("/repos/test/repo/pulls/202/merge"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&mock_server)
.await;
let merge_output = run_stax_with_token_env(
&repo,
home.path(),
"STAX_GITEA_TOKEN",
&[
"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 retarget_idx = find_request_index(&requests, "PATCH", "/repos/test/repo/pulls/202");
let merge_idx = find_request_index(&requests, "POST", "/repos/test/repo/pulls/201/merge");
assert!(retarget_idx > merge_idx);
}
}