//! Tests for squash-merge orchestration — basic cases. use super::*; use std::process::Command; fn init_git_repo(repo: &std::path::Path) { use crate::git_test_support::git_ok; git_ok( Command::new("git") .args(["init"]) .current_dir(repo) .output(), "git init", ); git_ok( Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(repo) .output(), "git config user.email", ); git_ok( Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(repo) .output(), "git config user.name", ); git_ok( Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(repo) .output(), "git commit", ); } #[tokio::test] async fn squash_merge_uses_merge_queue_no_conflict_markers_on_master() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create a file that will be conflicted on master. fs::write(repo.join("shared.txt"), "line 1\nline 2\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "initial shared file"]) .current_dir(repo) .output() .unwrap(); // Create a feature branch that modifies the file. Command::new("git") .args(["checkout", "-b", "feature/story-conflict_test"]) .current_dir(repo) .output() .unwrap(); fs::write( repo.join("shared.txt"), "line 1\nline 2\nfeature addition\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "feature: add line"]) .current_dir(repo) .output() .unwrap(); // Switch to master and make a conflicting change. Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("shared.txt"), "line 1\nline 2\nmaster addition\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "master: add line"]) .current_dir(repo) .output() .unwrap(); // Run the squash merge. let result = run_squash_merge(repo, "feature/story-conflict_test", "conflict_test").unwrap(); // Master should NEVER contain conflict markers, regardless of outcome. let master_content = fs::read_to_string(repo.join("shared.txt")).unwrap(); assert!( !master_content.contains("<<<<<<<"), "master must never contain conflict markers, got:\n{master_content}" ); assert!( !master_content.contains(">>>>>>>"), "master must never contain conflict markers, got:\n{master_content}" ); // The merge should have had conflicts (returned as Conflict variant). assert!( matches!(result, super::MergeResult::Conflict { .. }), "should detect conflicts; got: {result:?}" ); // Verify no leftover merge-queue branch. let branches = Command::new("git") .args(["branch", "--list", "merge-queue/*"]) .current_dir(repo) .output() .unwrap(); let branch_list = String::from_utf8_lossy(&branches.stdout); assert!( branch_list.trim().is_empty(), "merge-queue branch should be cleaned up, got: {branch_list}" ); // Verify no leftover merge workspace directory. assert!( !repo.join(".huskies/merge_workspace").exists(), "merge workspace should be cleaned up" ); } #[tokio::test] async fn squash_merge_clean_merge_succeeds() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create feature branch with a new file. Command::new("git") .args(["checkout", "-b", "feature/story-clean_test"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("new_file.txt"), "new content").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add new file"]) .current_dir(repo) .output() .unwrap(); // Switch back to master. Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); let result = run_squash_merge(repo, "feature/story-clean_test", "clean_test").unwrap(); assert!( matches!( result, super::MergeResult::Success { conflicts_resolved: false, .. } ), "clean merge should succeed without conflicts; got: {result:?}" ); assert!( repo.join("new_file.txt").exists(), "merged file should exist on master" ); } #[tokio::test] async fn squash_merge_succeeds_on_main_based_repo_with_base_branch_unset() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); // Repo whose default branch is `main` — no `master` branch exists at all, // and no `.huskies/project.toml` sets `base_branch`. run_squash_merge must // auto-detect `main` instead of assuming `master` (bug 1176). Command::new("git") .args(["init", "-b", "main"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["config", "user.email", "test@test.com"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["config", "user.name", "Test"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "--allow-empty", "-m", "init"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "-b", "feature/story-main_test"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("new_file.txt"), "new content").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add new file"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "main"]) .current_dir(repo) .output() .unwrap(); let result = run_squash_merge(repo, "feature/story-main_test", "main_test").unwrap(); assert!( matches!( result, super::MergeResult::Success { conflicts_resolved: false, .. } ), "clean merge should succeed on a main-based repo; got: {result:?}" ); assert!( repo.join("new_file.txt").exists(), "merged file should exist on main" ); } #[tokio::test] async fn squash_merge_nonexistent_branch_fails() { use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); let result = run_squash_merge(repo, "feature/story-nope", "nope").unwrap(); assert!( !matches!(result, super::MergeResult::Success { .. }), "merge of nonexistent branch should fail; got: {result:?}" ); } #[tokio::test] async fn squash_merge_succeeds_when_master_diverges() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create an initial file on master. fs::write(repo.join("base.txt"), "base content\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "initial"]) .current_dir(repo) .output() .unwrap(); // Create a feature branch with a new file (clean merge, no conflicts). Command::new("git") .args(["checkout", "-b", "feature/story-diverge_test"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("feature.txt"), "feature content\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "feature: add file"]) .current_dir(repo) .output() .unwrap(); // Switch back to master and simulate a filesystem watcher commit // (e.g. a pipeline file move) that advances master beyond the point // where the merge-queue branch will be created. Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); let sk_dir = repo.join(".huskies/work/4_merge"); fs::create_dir_all(&sk_dir).unwrap(); fs::write(sk_dir.join("diverge_test.md"), "---\nname: test\n---\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "huskies: queue diverge_test for merge"]) .current_dir(repo) .output() .unwrap(); // Run the squash merge. With the old fast-forward approach, this // would fail because master diverged. With cherry-pick, it succeeds. let result = run_squash_merge(repo, "feature/story-diverge_test", "diverge_test").unwrap(); assert!( matches!(result, super::MergeResult::Success { .. }), "squash merge should succeed despite diverged master: {:?}", result ); // Verify the feature file landed on master. assert!( repo.join("feature.txt").exists(), "feature file should be on master after cherry-pick" ); let feature_content = fs::read_to_string(repo.join("feature.txt")).unwrap(); assert_eq!(feature_content, "feature content\n"); // Verify the watcher commit's file is still present. assert!( sk_dir.join("diverge_test.md").exists(), "watcher-committed file should still be on master" ); // Verify cleanup: no merge-queue branch, no merge workspace. let branches = Command::new("git") .args(["branch", "--list", "merge-queue/*"]) .current_dir(repo) .output() .unwrap(); let branch_list = String::from_utf8_lossy(&branches.stdout); assert!( branch_list.trim().is_empty(), "merge-queue branch should be cleaned up, got: {branch_list}" ); assert!( !repo.join(".huskies/merge_workspace").exists(), "merge workspace should be cleaned up" ); } #[tokio::test] async fn squash_merge_empty_diff_fails() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create a file on master. fs::write(repo.join("code.txt"), "content\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add code"]) .current_dir(repo) .output() .unwrap(); // Create a feature branch with NO additional changes (empty diff). Command::new("git") .args(["checkout", "-b", "feature/story-empty_test"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); let result = run_squash_merge(repo, "feature/story-empty_test", "empty_test"); // Bug 226 / 675: a zero-commit branch must not be treated as success. // The pre-flight check (bug 675) returns Err for zero commits ahead; // the older code path returned Ok(SquashMergeResult { success: false }). // Either form is a failure — just not success. match result { Ok(r) => assert!( !matches!(r, super::MergeResult::Success { .. }), "empty diff merge must fail, not silently succeed: {:?}", r ), Err(e) => assert!( e.contains("no commits to merge") || e.contains("nothing to commit"), "unexpected error: {e}" ), } // Cleanup should still happen (no workspace was created for the Err path). assert!( !repo.join(".huskies/merge_workspace").exists(), "merge workspace should be cleaned up" ); } /// Bug 777: a second `run_squash_merge` call after a successful first one must /// return `success: true` (idempotent) so the caller advances the story to /// `5_done` rather than overwriting that state with `merge_failure`. The /// pre-flight `ahead == 0` check still catches truly empty feature branches. #[tokio::test] async fn idempotent_retry_after_successful_merge_returns_success() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Master has an initial file. fs::write(repo.join("base.txt"), "base\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "base"]) .current_dir(repo) .output() .unwrap(); // Feature branch adds a new file. Command::new("git") .args(["checkout", "-b", "feature/story-777_idem"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("feat.txt"), "feature\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add feat"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); // First merge: should succeed and land feature's content on master. let r1 = run_squash_merge(repo, "feature/story-777_idem", "777_idem") .expect("first merge produces Ok"); // The merge may fail gates in test env (no script/test); only require that // the squash applied SOMETHING (cargo gates env-dependent). if matches!(r1, super::MergeResult::Success { .. }) { // Second merge of the SAME branch: must report success (idempotent), // not merge_failure. Feature branch's content is already on master so // the squash produces "nothing to commit" — bug 777 makes this success. let r2 = run_squash_merge(repo, "feature/story-777_idem", "777_idem") .expect("second merge produces Ok"); assert!( matches!( r2, super::MergeResult::Success { conflicts_resolved: false, .. } ), "idempotent retry must return Success without conflicts: {r2:?}" ); } }