//! Tests for squash-merge orchestration — advanced and regression 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_md_only_changes_fails() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create a feature branch that only moves a .huskies/ file. Command::new("git") .args(["checkout", "-b", "feature/story-md_only_test"]) .current_dir(repo) .output() .unwrap(); let sk_dir = repo.join(".huskies/work/2_current"); fs::create_dir_all(&sk_dir).unwrap(); fs::write(sk_dir.join("md_only_test.md"), "---\nname: Test\n---\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "move story file"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); let result = run_squash_merge(repo, "feature/story-md_only_test", "md_only_test").unwrap(); // The squash merge will commit the .huskies/ file, but should fail because // there are no code changes outside .huskies/. assert!( !matches!(result, super::MergeResult::Success { .. }), "merge with only .huskies/ changes must fail: {:?}", result ); // Cleanup should still happen. assert!( !repo.join(".huskies/merge_workspace").exists(), "merge workspace should be cleaned up" ); } #[tokio::test] async fn squash_merge_additive_conflict_both_additions_preserved() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Initial file with a shared base. fs::write(repo.join("module.rs"), "// module\npub fn existing() {}\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "initial module"]) .current_dir(repo) .output() .unwrap(); // Feature branch: appends feature_fn to the file. Command::new("git") .args(["checkout", "-b", "feature/story-238_additive"]) .current_dir(repo) .output() .unwrap(); fs::write( repo.join("module.rs"), "// module\npub fn existing() {}\npub fn feature_fn() {}\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add feature_fn"]) .current_dir(repo) .output() .unwrap(); // Simulate another branch already merged into master: appends master_fn. Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); fs::write( repo.join("module.rs"), "// module\npub fn existing() {}\npub fn master_fn() {}\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add master_fn (another branch merged)"]) .current_dir(repo) .output() .unwrap(); // Squash-merge the feature branch — conflicts because both appended to the same location. let result = run_squash_merge(repo, "feature/story-238_additive", "238_additive").unwrap(); // Deterministic merge does NOT auto-resolve conflicts — AC3 requires failure. assert!( matches!(result, super::MergeResult::Conflict { .. }), "additive conflict should produce Conflict variant; got: {result:?}" ); // Master must not have been modified (merge aborted). let content = fs::read_to_string(repo.join("module.rs")).unwrap(); assert!( !content.contains("<<<<<<<"), "master must not contain conflict markers" ); assert!( !content.contains(">>>>>>>"), "master must not contain conflict markers" ); // Cleanup: no leftover merge-queue branch or workspace. let branches = Command::new("git") .args(["branch", "--list", "merge-queue/*"]) .current_dir(repo) .output() .unwrap(); assert!( String::from_utf8_lossy(&branches.stdout).trim().is_empty(), "merge-queue branch must be cleaned up" ); assert!( !repo.join(".huskies/merge_workspace").exists(), "merge workspace must be cleaned up" ); } #[tokio::test] async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Add a script/test that always fails (quality gate). This must be on // master before the feature branch forks so it doesn't cause its own conflict. let script_dir = repo.join("script"); fs::create_dir_all(&script_dir).unwrap(); fs::write(script_dir.join("test"), "#!/bin/sh\nexit 1\n").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions( script_dir.join("test"), std::fs::Permissions::from_mode(0o755), ) .unwrap(); } fs::write(repo.join("code.txt"), "// base\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "initial with failing script/test"]) .current_dir(repo) .output() .unwrap(); // Feature branch: appends feature content (creates future conflict point). Command::new("git") .args(["checkout", "-b", "feature/story-238_gates_fail"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("code.txt"), "// base\nfeature_addition\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "feature addition"]) .current_dir(repo) .output() .unwrap(); // Master: append different content at same location (creates conflict). Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("code.txt"), "// base\nmaster_addition\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "master addition"]) .current_dir(repo) .output() .unwrap(); // Squash-merge: conflict detected → aborted immediately (no gate run). let result = run_squash_merge(repo, "feature/story-238_gates_fail", "238_gates_fail").unwrap(); // Merge is aborted at conflict detection; gates are never reached. assert!( matches!(result, super::MergeResult::Conflict { .. }), "conflicting merge must produce Conflict variant; got: {result:?}" ); assert!( !result.output().is_empty(), "output must contain conflict details" ); // Master must NOT have been updated (cherry-pick was blocked by gate failure). let content = fs::read_to_string(repo.join("code.txt")).unwrap(); assert!( !content.contains("<<<<<<<"), "master must not contain conflict markers" ); // master_addition was the last commit on master; feature_addition must NOT be there. assert!( !content.contains("feature_addition"), "feature code must not land on master when gates fail" ); // Cleanup must still happen. assert!( !repo.join(".huskies/merge_workspace").exists(), "merge workspace must be cleaned up even on gate failure" ); } #[tokio::test] async fn squash_merge_cleans_up_stale_workspace() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Create a feature branch with a file. Command::new("git") .args(["checkout", "-b", "feature/story-stale_test"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("stale.txt"), "content\n").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "feature: stale test"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); // Simulate a stale merge workspace left from a previous failed merge. let stale_ws = repo.join(".huskies/merge_workspace"); fs::create_dir_all(&stale_ws).unwrap(); fs::write(stale_ws.join("leftover.txt"), "stale").unwrap(); // Run the merge — it should clean up the stale workspace first. let result = run_squash_merge(repo, "feature/story-stale_test", "stale_test").unwrap(); assert!( matches!(result, super::MergeResult::Success { .. }), "merge should succeed after cleaning up stale workspace: {:?}", result ); assert!( !stale_ws.exists(), "stale merge workspace should be cleaned up" ); } #[cfg(unix)] #[test] fn squash_merge_runs_component_setup_from_project_toml() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Add a .huskies/project.toml with a component whose setup writes a // sentinel file so we can confirm the command ran. let sk_dir = repo.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write( sk_dir.join("project.toml"), "[[component]]\nname = \"sentinel\"\npath = \".\"\nsetup = [\"touch setup_ran.txt\"]\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add project.toml with component setup"]) .current_dir(repo) .output() .unwrap(); // Create feature branch with a change. Command::new("git") .args(["checkout", "-b", "feature/story-216_setup_test"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("feature.txt"), "change").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "feature work"]) .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-216_setup_test", "216_setup_test").unwrap(); // The output must mention component setup, proving the new code path ran. assert!( result.output().contains("component setup"), "merge output must mention component setup when project.toml has components, got:\n{}", result.output() ); // The sentinel command must appear in the output. assert!( result.output().contains("sentinel"), "merge output must name the component, got:\n{}", result.output() ); } /// AC6: the regen+commit step runs on `project_root` (master) only. /// After a successful merge where the source-map changes, `git log --name-only` /// shows a follow-up commit whose diff contains ONLY `.huskies/source-map.json`. #[tokio::test] async fn regen_commit_on_master_touches_only_source_map() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // Put a stale source-map.json on master so regen will produce a different result. let sk_dir = repo.join(".huskies"); fs::create_dir_all(&sk_dir).unwrap(); fs::write(sk_dir.join("source-map.json"), "{\"stale\": true}\n").unwrap(); // Add a tracked Rust file so the regenerator has something to index. fs::create_dir_all(repo.join("src")).unwrap(); fs::write( repo.join("src/lib.rs"), "//! Library.\n\n/// Says hello.\npub fn hello() {}\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "initial with stale source-map"]) .current_dir(repo) .output() .unwrap(); // Feature branch: add a new file. Command::new("git") .args(["checkout", "-b", "feature/story-1065_regen_test"]) .current_dir(repo) .output() .unwrap(); fs::write( repo.join("src/extra.rs"), "//! Extra.\n\n/// Extra fn.\npub fn extra() {}\n", ) .unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "add extra.rs"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); let result = run_squash_merge(repo, "feature/story-1065_regen_test", "1065_regen_test").unwrap(); assert!( matches!(result, super::MergeResult::Success { .. }), "clean merge must succeed; got: {result:?}" ); // Find the regen commit if one was created. let log_out = Command::new("git") .args(["log", "--oneline", "--name-only"]) .current_dir(repo) .output() .unwrap(); let log = String::from_utf8_lossy(&log_out.stdout); // If a regen commit exists, its diff must contain ONLY the source-map path. if log.contains("huskies: regen source-map.json") { // Extract files changed in the regen commit. let show_out = Command::new("git") .args(["show", "--name-only", "--format=", "HEAD"]) .current_dir(repo) .output() .unwrap(); let show = String::from_utf8_lossy(&show_out.stdout); // If HEAD is the regen commit, its files list must be exactly one entry. let head_msg = Command::new("git") .args(["log", "-1", "--format=%s"]) .current_dir(repo) .output() .unwrap(); let head_subject = String::from_utf8_lossy(&head_msg.stdout); if head_subject.trim() == "huskies: regen source-map.json" { let changed_files: Vec<&str> = show.lines().filter(|l| !l.is_empty()).collect(); assert_eq!( changed_files, vec![".huskies/source-map.json"], "regen commit must touch ONLY .huskies/source-map.json; got: {changed_files:?}" ); } } } #[cfg(unix)] #[test] fn squash_merge_succeeds_without_components_in_project_toml() { use std::fs; use tempfile::tempdir; let tmp = tempdir().unwrap(); let repo = tmp.path(); init_git_repo(repo); // No .huskies/project.toml — no component setup. fs::write(repo.join("file.txt"), "initial").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "initial commit"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "-b", "feature/story-216_no_components"]) .current_dir(repo) .output() .unwrap(); fs::write(repo.join("change.txt"), "change").unwrap(); Command::new("git") .args(["add", "."]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["commit", "-m", "feature"]) .current_dir(repo) .output() .unwrap(); Command::new("git") .args(["checkout", "master"]) .current_dir(repo) .output() .unwrap(); let result = run_squash_merge(repo, "feature/story-216_no_components", "216_no_components").unwrap(); // No pnpm or frontend references should appear in the output. assert!( !result.output().contains("pnpm"), "output must not mention pnpm, got:\n{}", result.output() ); assert!( !result.output().contains("frontend/"), "output must not mention frontend/, got:\n{}", result.output() ); }