diff --git a/server/src/agents/merge/squash.rs b/server/src/agents/merge/squash.rs deleted file mode 100644 index 8b70882d..00000000 --- a/server/src/agents/merge/squash.rs +++ /dev/null @@ -1,1346 +0,0 @@ -//! Squash-merge orchestration: rebase agent work onto master and run post-merge gates. - -#![allow(unused_imports, dead_code)] -use std::path::Path; -use std::process::Command; -use std::sync::Mutex; - -use super::super::gates::run_project_tests; -use super::conflicts::try_resolve_conflicts; -use super::{MergeReport, SquashMergeResult}; -use crate::config::ProjectConfig; - -/// Global lock ensuring only one squash-merge runs at a time. -/// -/// The merge pipeline uses a shared `.huskies/merge_workspace` directory and -/// temporary `merge-queue/{story_id}` branches. If two merges run concurrently, -/// the second call's initial cleanup destroys the first call's branch mid-flight, -/// causing `git cherry-pick merge-queue/…` to fail with "bad revision". -static MERGE_LOCK: Mutex<()> = Mutex::new(()); - -pub(crate) fn run_squash_merge( - project_root: &Path, - branch: &str, - story_id: &str, -) -> Result { - // Acquire the merge lock so concurrent calls don't clobber each other. - let _lock = MERGE_LOCK - .lock() - .map_err(|e| format!("Merge lock poisoned: {e}"))?; - - // ── Pre-flight: verify the branch has commits ahead of base ────────────── - // A zero-commit branch produces an empty squash and a silent "nothing to - // commit" failure. Catch it early with a grep-able error before any merge - // work starts. - let base_branch = crate::config::ProjectConfig::load(project_root) - .ok() - .and_then(|c| c.base_branch.clone()) - .unwrap_or_else(|| "master".to_string()); - - let ahead_out = Command::new("git") - .args(["rev-list", "--count", &format!("{base_branch}..{branch}")]) - .current_dir(project_root) - .output() - .map_err(|e| format!("Failed to count commits ahead: {e}"))?; - - if ahead_out.status.success() { - let ahead: u64 = String::from_utf8_lossy(&ahead_out.stdout) - .trim() - .parse() - .unwrap_or(1); // parse failure → don't false-positive; let merge proceed - if ahead == 0 { - return Err(format!( - "{story_id}: no commits to merge — feature branch '{branch}' \ - has 0 commits ahead of '{base_branch}'" - )); - } - } - - let mut all_output = String::new(); - let merge_branch = format!("merge-queue/{story_id}"); - let merge_wt_path = project_root.join(".huskies").join("merge_workspace"); - - // Ensure we start clean: remove any leftover merge workspace. - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - - // ── Create merge-queue branch at current HEAD ───────────────── - all_output.push_str(&format!( - "=== Creating merge-queue branch '{merge_branch}' ===\n" - )); - let create_branch = Command::new("git") - .args(["branch", &merge_branch]) - .current_dir(project_root) - .output() - .map_err(|e| format!("Failed to create merge-queue branch: {e}"))?; - if !create_branch.status.success() { - let stderr = String::from_utf8_lossy(&create_branch.stderr); - all_output.push_str(&format!("Branch creation failed: {stderr}\n")); - return Err(format!("Failed to create merge-queue branch: {stderr}")); - } - - // ── Create temporary worktree ───────────────────────────────── - all_output.push_str("=== Creating temporary merge worktree ===\n"); - let wt_str = merge_wt_path.to_string_lossy().to_string(); - let create_wt = Command::new("git") - .args(["worktree", "add", &wt_str, &merge_branch]) - .current_dir(project_root) - .output() - .map_err(|e| format!("Failed to create merge worktree: {e}"))?; - if !create_wt.status.success() { - let stderr = String::from_utf8_lossy(&create_wt.stderr); - all_output.push_str(&format!("Worktree creation failed: {stderr}\n")); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Err(format!("Failed to create merge worktree: {stderr}")); - } - - // ── Squash-merge in the temporary worktree ──────────────────── - all_output.push_str(&format!("=== git merge --squash {branch} ===\n")); - let merge = Command::new("git") - .args(["merge", "--squash", branch]) - .current_dir(&merge_wt_path) - .output() - .map_err(|e| format!("Failed to run git merge: {e}"))?; - - let merge_stdout = String::from_utf8_lossy(&merge.stdout).to_string(); - let merge_stderr = String::from_utf8_lossy(&merge.stderr).to_string(); - all_output.push_str(&merge_stdout); - all_output.push_str(&merge_stderr); - all_output.push('\n'); - - let mut had_conflicts = false; - let mut conflicts_resolved = false; - let mut conflict_details: Option = None; - - if !merge.status.success() { - had_conflicts = true; - all_output.push_str("=== Conflicts detected, attempting auto-resolution ===\n"); - - // Try to automatically resolve simple conflicts. - match try_resolve_conflicts(&merge_wt_path) { - Ok((resolved, resolution_log)) => { - all_output.push_str(&resolution_log); - if resolved { - conflicts_resolved = true; - all_output.push_str("=== All conflicts resolved automatically ===\n"); - } else { - // Could not resolve — abort, clean up, and report. - let details = format!( - "Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}\n{resolution_log}" - ); - conflict_details = Some(details); - all_output.push_str("=== Unresolvable conflicts, aborting merge ===\n"); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts: true, - conflicts_resolved: false, - conflict_details, - output: all_output, - gates_passed: false, - }); - } - } - Err(e) => { - all_output.push_str(&format!("Auto-resolution error: {e}\n")); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts: true, - conflicts_resolved: false, - conflict_details: Some(format!( - "Merge conflicts in branch '{branch}' (auto-resolution failed: {e}):\n{merge_stdout}{merge_stderr}" - )), - output: all_output, - gates_passed: false, - }); - } - } - } - - // ── Commit in the temporary worktree ────────────────────────── - all_output.push_str("=== git commit ===\n"); - let commit_msg = format!("huskies: merge {story_id}"); - let commit = Command::new("git") - .args(["commit", "-m", &commit_msg]) - .current_dir(&merge_wt_path) - .output() - .map_err(|e| format!("Failed to run git commit: {e}"))?; - - let commit_stdout = String::from_utf8_lossy(&commit.stdout).to_string(); - let commit_stderr = String::from_utf8_lossy(&commit.stderr).to_string(); - all_output.push_str(&commit_stdout); - all_output.push_str(&commit_stderr); - all_output.push('\n'); - - if !commit.status.success() { - // Bug 226: "nothing to commit" means the feature branch has no changes - // beyond what's already on master. This must NOT be treated as success - // — it means the code was never actually merged. - if commit_stderr.contains("nothing to commit") - || commit_stdout.contains("nothing to commit") - { - all_output.push_str( - "=== Nothing to commit — feature branch has no changes beyond master ===\n", - ); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts, - conflicts_resolved, - conflict_details: Some( - "Squash-merge resulted in an empty diff — the feature branch has no \ - code changes to merge into master." - .to_string(), - ), - output: all_output, - gates_passed: false, - }); - } - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts, - conflicts_resolved, - conflict_details, - output: all_output, - gates_passed: false, - }); - } - - // ── Bug 226: Verify the commit contains real code changes ───── - // If the merge only brought in .huskies/ files (pipeline file moves), - // there are no actual code changes to land on master. Abort. - { - let diff_check = Command::new("git") - .args(["diff", "--name-only", "HEAD~1..HEAD"]) - .current_dir(&merge_wt_path) - .output() - .map_err(|e| format!("Failed to check merge diff: {e}"))?; - let changed_files = String::from_utf8_lossy(&diff_check.stdout); - let has_code_changes = changed_files - .lines() - .any(|f| !f.starts_with(".huskies/work/")); - if !has_code_changes { - all_output.push_str( - "=== Merge commit contains only .huskies/ file moves, no code changes ===\n", - ); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts, - conflicts_resolved, - conflict_details: Some( - "Feature branch has no code changes outside .huskies/ — only \ - pipeline file moves were found." - .to_string(), - ), - output: all_output, - gates_passed: false, - }); - } - } - - // ── Run component setup from project.toml (same as worktree creation) ────────── - { - let config = ProjectConfig::load(&merge_wt_path).unwrap_or_default(); - if !config.component.is_empty() { - all_output.push_str("=== component setup (merge worktree) ===\n"); - } - for component in &config.component { - let cmd_dir = merge_wt_path.join(&component.path); - for cmd in &component.setup { - all_output.push_str(&format!("--- {}: {cmd} ---\n", component.name)); - match Command::new("sh") - .args(["-c", cmd]) - .current_dir(&cmd_dir) - .output() - { - Ok(out) => { - all_output.push_str(&String::from_utf8_lossy(&out.stdout)); - all_output.push_str(&String::from_utf8_lossy(&out.stderr)); - all_output.push('\n'); - if !out.status.success() { - all_output.push_str(&format!( - "=== setup warning: '{}' failed: {cmd} ===\n", - component.name - )); - } - } - Err(e) => { - all_output.push_str(&format!( - "=== setup warning: failed to run '{cmd}': {e} ===\n" - )); - } - } - } - } - } - - // ── Quality gates in merge workspace (BEFORE fast-forward) ──── - // Run gates in the merge worktree so that failures abort before master moves. - all_output.push_str("=== Running quality gates before fast-forward ===\n"); - match run_merge_quality_gates(&merge_wt_path) { - Ok((true, gate_out)) => { - all_output.push_str(&gate_out); - all_output.push('\n'); - all_output.push_str("=== Quality gates passed ===\n"); - } - Ok((false, gate_out)) => { - all_output.push_str(&gate_out); - all_output.push('\n'); - all_output.push_str( - "=== Quality gates FAILED — aborting fast-forward, master unchanged ===\n", - ); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts, - conflicts_resolved, - conflict_details, - output: all_output, - gates_passed: false, - }); - } - Err(e) => { - all_output.push_str(&format!("Gate check error: {e}\n")); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts, - conflicts_resolved, - conflict_details, - output: all_output, - gates_passed: false, - }); - } - } - - // ── Cherry-pick the squash commit onto master ────────────────── - // We cherry-pick instead of fast-forward so that concurrent filesystem - // watcher commits on master (e.g. pipeline file moves) don't block the - // merge. Cherry-pick applies the diff of the squash commit cleanly on - // top of master's current HEAD. - all_output.push_str(&format!( - "=== Cherry-picking squash commit from {merge_branch} onto master ===\n" - )); - let cp = Command::new("git") - .args(["cherry-pick", &merge_branch]) - .current_dir(project_root) - .output() - .map_err(|e| format!("Failed to cherry-pick merge-queue commit: {e}"))?; - - let cp_stdout = String::from_utf8_lossy(&cp.stdout).to_string(); - let cp_stderr = String::from_utf8_lossy(&cp.stderr).to_string(); - all_output.push_str(&cp_stdout); - all_output.push_str(&cp_stderr); - all_output.push('\n'); - - if !cp.status.success() { - // Abort the cherry-pick so master is left clean. - let _ = Command::new("git") - .args(["cherry-pick", "--abort"]) - .current_dir(project_root) - .output(); - all_output.push_str("=== Cherry-pick failed — aborting, master unchanged ===\n"); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts, - conflicts_resolved, - conflict_details: Some(format!( - "Cherry-pick of squash commit failed (conflict with master?):\n{cp_stderr}" - )), - output: all_output, - gates_passed: true, - }); - } - - // ── Verify code landed on the correct branch ────────────────── - // Guard against the cherry-pick silently landing on the wrong branch - // (e.g. a merge-queue branch from a concurrent merge). If the current - // branch is not the base branch, or the HEAD commit has no code diff, - // treat the merge as failed so the story stays in the merge stage. - let current_branch = Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(project_root) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default(); - - let base_branch = crate::config::ProjectConfig::load(project_root) - .ok() - .and_then(|c| c.base_branch.clone()) - .unwrap_or_else(|| "master".to_string()); - - if current_branch != base_branch { - all_output.push_str(&format!( - "=== VERIFICATION FAILED: expected branch '{base_branch}' but HEAD is on \ - '{current_branch}'. Cherry-pick landed on wrong branch. ===\n" - )); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts, - conflicts_resolved, - conflict_details: Some(format!( - "Cherry-pick landed on '{current_branch}' instead of '{base_branch}'" - )), - output: all_output, - gates_passed: true, - }); - } - - // Verify HEAD commit has actual code changes (not an empty cherry-pick). - // Exclude .huskies/work/ (pipeline file moves) but keep .huskies/project.toml - // and other config files which are legitimate deliverables. - let diff_stat = Command::new("git") - .args([ - "diff", - "--stat", - "HEAD~1..HEAD", - "--", - ".", - ":(exclude).huskies/work", - ]) - .current_dir(project_root) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default(); - - if diff_stat.is_empty() { - all_output.push_str( - "=== VERIFICATION FAILED: cherry-pick produced no code changes on master. ===\n", - ); - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - return Ok(SquashMergeResult { - success: false, - had_conflicts, - conflicts_resolved, - conflict_details: Some( - "Cherry-pick commit contains no code changes (empty diff)".to_string(), - ), - output: all_output, - gates_passed: true, - }); - } - - all_output.push_str(&format!( - "=== Verified: cherry-pick landed on '{base_branch}' with code changes ===\n" - )); - - // ── Clean up ────────────────────────────────────────────────── - cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); - all_output.push_str("=== Merge-queue cleanup complete ===\n"); - - Ok(SquashMergeResult { - success: true, - had_conflicts, - conflicts_resolved, - conflict_details, - output: all_output, - gates_passed: true, - }) -} - -pub(crate) fn cleanup_merge_workspace( - project_root: &Path, - merge_wt_path: &Path, - merge_branch: &str, -) { - let wt_str = merge_wt_path.to_string_lossy().to_string(); - let _ = Command::new("git") - .args(["worktree", "remove", "--force", &wt_str]) - .current_dir(project_root) - .output(); - // If the directory still exists (e.g. it was a plain directory from a - // previous failed run, not a registered git worktree), remove it so - // the next `git worktree add` can succeed. - if merge_wt_path.exists() { - let _ = std::fs::remove_dir_all(merge_wt_path); - } - let _ = Command::new("git") - .args(["branch", "-D", merge_branch]) - .current_dir(project_root) - .output(); -} - -fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String> { - let mut all_output = String::new(); - let mut all_passed = true; - - let script_test = project_root.join("script").join("test"); - - if script_test.exists() { - // Delegate entirely to script/test — it is the single source of truth - // for the full test suite (clippy, cargo tests, frontend builds, etc.). - let (success, output) = run_project_tests(project_root)?; - all_output.push_str(&output); - if !success { - all_passed = false; - } - return Ok((all_passed, all_output)); - } - - // No script/test — fall back to cargo gates for Rust projects. - let cargo_toml = project_root.join("Cargo.toml"); - if cargo_toml.exists() { - let clippy = Command::new("cargo") - .args(["clippy", "--all-targets", "--all-features"]) - .current_dir(project_root) - .output() - .map_err(|e| format!("Failed to run cargo clippy: {e}"))?; - - all_output.push_str("=== cargo clippy ===\n"); - let clippy_out = format!( - "{}{}", - String::from_utf8_lossy(&clippy.stdout), - String::from_utf8_lossy(&clippy.stderr) - ); - all_output.push_str(&clippy_out); - all_output.push('\n'); - - if !clippy.status.success() { - all_passed = false; - } - - let (test_success, test_out) = run_project_tests(project_root)?; - all_output.push_str(&test_out); - if !test_success { - all_passed = false; - } - } - - Ok((all_passed, all_output)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::process::Command; - - fn init_git_repo(repo: &std::path::Path) { - Command::new("git") - .args(["init"]) - .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(); - } - - #[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. - assert!(result.had_conflicts, "should detect conflicts"); - - // Conflicts should have been auto-resolved (both are simple additions). - if result.conflicts_resolved { - assert!(result.success, "auto-resolved merge should succeed"); - assert!( - master_content.contains("master addition"), - "master side should be present" - ); - assert!( - master_content.contains("feature addition"), - "feature side should be present" - ); - } - - // 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!(result.success, "clean merge should succeed"); - assert!( - !result.had_conflicts, - "clean merge should have no conflicts" - ); - assert!( - !result.conflicts_resolved, - "no conflicts means nothing to resolve" - ); - assert!( - repo.join("new_file.txt").exists(), - "merged file should exist on master" - ); - } - - #[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!(!result.success, "merge of nonexistent branch should fail"); - } - - #[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!( - result.success, - "squash merge should succeed despite diverged master: {}", - result.output - ); - assert!(!result.had_conflicts, "no conflicts expected"); - - // 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!( - !r.success, - "empty diff merge must fail, not silently succeed: {}", - r.output - ), - 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" - ); - } - - #[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!( - !result.success, - "merge with only .huskies/ changes must fail: {}", - result.output - ); - - // 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(); - - // Conflict must be detected and auto-resolved. - assert!(result.had_conflicts, "additive conflict should be detected"); - assert!( - result.conflicts_resolved, - "additive conflict must be auto-resolved; output:\n{}", - result.output - ); - - // Master must contain both additions without conflict markers. - 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" - ); - assert!( - content.contains("feature_fn"), - "feature branch addition must be preserved on master" - ); - assert!( - content.contains("master_fn"), - "master branch addition must be preserved on master" - ); - assert!( - content.contains("existing"), - "original function must be preserved" - ); - - // 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 → auto-resolved → quality gates run → fail. - let result = - run_squash_merge(repo, "feature/story-238_gates_fail", "238_gates_fail").unwrap(); - - assert!(result.had_conflicts, "conflict must be detected"); - assert!( - result.conflicts_resolved, - "additive conflict must be auto-resolved" - ); - assert!( - !result.gates_passed, - "quality gates must fail (script/test exits 1)" - ); - assert!( - !result.success, - "merge must be reported as failed when gates fail" - ); - assert!( - !result.output.is_empty(), - "output must contain gate failure 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!( - result.success, - "merge should succeed after cleaning up stale workspace: {}", - result.output - ); - 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 - ); - } - - #[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 - ); - } -} diff --git a/server/src/agents/merge/squash/mod.rs b/server/src/agents/merge/squash/mod.rs new file mode 100644 index 00000000..665044e9 --- /dev/null +++ b/server/src/agents/merge/squash/mod.rs @@ -0,0 +1,519 @@ +//! Squash-merge orchestration: rebase agent work onto master and run post-merge gates. + +#![allow(unused_imports, dead_code)] +use std::path::Path; +use std::process::Command; +use std::sync::Mutex; + +use super::super::gates::run_project_tests; +use super::conflicts::try_resolve_conflicts; +use super::{MergeReport, SquashMergeResult}; +use crate::config::ProjectConfig; + +/// Global lock ensuring only one squash-merge runs at a time. +/// +/// The merge pipeline uses a shared `.huskies/merge_workspace` directory and +/// temporary `merge-queue/{story_id}` branches. If two merges run concurrently, +/// the second call's initial cleanup destroys the first call's branch mid-flight, +/// causing `git cherry-pick merge-queue/…` to fail with "bad revision". +static MERGE_LOCK: Mutex<()> = Mutex::new(()); + +pub(crate) fn run_squash_merge( + project_root: &Path, + branch: &str, + story_id: &str, +) -> Result { + // Acquire the merge lock so concurrent calls don't clobber each other. + let _lock = MERGE_LOCK + .lock() + .map_err(|e| format!("Merge lock poisoned: {e}"))?; + + // ── Pre-flight: verify the branch has commits ahead of base ────────────── + // A zero-commit branch produces an empty squash and a silent "nothing to + // commit" failure. Catch it early with a grep-able error before any merge + // work starts. + let base_branch = crate::config::ProjectConfig::load(project_root) + .ok() + .and_then(|c| c.base_branch.clone()) + .unwrap_or_else(|| "master".to_string()); + + let ahead_out = Command::new("git") + .args(["rev-list", "--count", &format!("{base_branch}..{branch}")]) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to count commits ahead: {e}"))?; + + if ahead_out.status.success() { + let ahead: u64 = String::from_utf8_lossy(&ahead_out.stdout) + .trim() + .parse() + .unwrap_or(1); // parse failure → don't false-positive; let merge proceed + if ahead == 0 { + return Err(format!( + "{story_id}: no commits to merge — feature branch '{branch}' \ + has 0 commits ahead of '{base_branch}'" + )); + } + } + + let mut all_output = String::new(); + let merge_branch = format!("merge-queue/{story_id}"); + let merge_wt_path = project_root.join(".huskies").join("merge_workspace"); + + // Ensure we start clean: remove any leftover merge workspace. + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + + // ── Create merge-queue branch at current HEAD ───────────────── + all_output.push_str(&format!( + "=== Creating merge-queue branch '{merge_branch}' ===\n" + )); + let create_branch = Command::new("git") + .args(["branch", &merge_branch]) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to create merge-queue branch: {e}"))?; + if !create_branch.status.success() { + let stderr = String::from_utf8_lossy(&create_branch.stderr); + all_output.push_str(&format!("Branch creation failed: {stderr}\n")); + return Err(format!("Failed to create merge-queue branch: {stderr}")); + } + + // ── Create temporary worktree ───────────────────────────────── + all_output.push_str("=== Creating temporary merge worktree ===\n"); + let wt_str = merge_wt_path.to_string_lossy().to_string(); + let create_wt = Command::new("git") + .args(["worktree", "add", &wt_str, &merge_branch]) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to create merge worktree: {e}"))?; + if !create_wt.status.success() { + let stderr = String::from_utf8_lossy(&create_wt.stderr); + all_output.push_str(&format!("Worktree creation failed: {stderr}\n")); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Err(format!("Failed to create merge worktree: {stderr}")); + } + + // ── Squash-merge in the temporary worktree ──────────────────── + all_output.push_str(&format!("=== git merge --squash {branch} ===\n")); + let merge = Command::new("git") + .args(["merge", "--squash", branch]) + .current_dir(&merge_wt_path) + .output() + .map_err(|e| format!("Failed to run git merge: {e}"))?; + + let merge_stdout = String::from_utf8_lossy(&merge.stdout).to_string(); + let merge_stderr = String::from_utf8_lossy(&merge.stderr).to_string(); + all_output.push_str(&merge_stdout); + all_output.push_str(&merge_stderr); + all_output.push('\n'); + + let mut had_conflicts = false; + let mut conflicts_resolved = false; + let mut conflict_details: Option = None; + + if !merge.status.success() { + had_conflicts = true; + all_output.push_str("=== Conflicts detected, attempting auto-resolution ===\n"); + + // Try to automatically resolve simple conflicts. + match try_resolve_conflicts(&merge_wt_path) { + Ok((resolved, resolution_log)) => { + all_output.push_str(&resolution_log); + if resolved { + conflicts_resolved = true; + all_output.push_str("=== All conflicts resolved automatically ===\n"); + } else { + // Could not resolve — abort, clean up, and report. + let details = format!( + "Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}\n{resolution_log}" + ); + conflict_details = Some(details); + all_output.push_str("=== Unresolvable conflicts, aborting merge ===\n"); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts: true, + conflicts_resolved: false, + conflict_details, + output: all_output, + gates_passed: false, + }); + } + } + Err(e) => { + all_output.push_str(&format!("Auto-resolution error: {e}\n")); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts: true, + conflicts_resolved: false, + conflict_details: Some(format!( + "Merge conflicts in branch '{branch}' (auto-resolution failed: {e}):\n{merge_stdout}{merge_stderr}" + )), + output: all_output, + gates_passed: false, + }); + } + } + } + + // ── Commit in the temporary worktree ────────────────────────── + all_output.push_str("=== git commit ===\n"); + let commit_msg = format!("huskies: merge {story_id}"); + let commit = Command::new("git") + .args(["commit", "-m", &commit_msg]) + .current_dir(&merge_wt_path) + .output() + .map_err(|e| format!("Failed to run git commit: {e}"))?; + + let commit_stdout = String::from_utf8_lossy(&commit.stdout).to_string(); + let commit_stderr = String::from_utf8_lossy(&commit.stderr).to_string(); + all_output.push_str(&commit_stdout); + all_output.push_str(&commit_stderr); + all_output.push('\n'); + + if !commit.status.success() { + // Bug 226: "nothing to commit" means the feature branch has no changes + // beyond what's already on master. This must NOT be treated as success + // — it means the code was never actually merged. + if commit_stderr.contains("nothing to commit") + || commit_stdout.contains("nothing to commit") + { + all_output.push_str( + "=== Nothing to commit — feature branch has no changes beyond master ===\n", + ); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts, + conflicts_resolved, + conflict_details: Some( + "Squash-merge resulted in an empty diff — the feature branch has no \ + code changes to merge into master." + .to_string(), + ), + output: all_output, + gates_passed: false, + }); + } + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts, + conflicts_resolved, + conflict_details, + output: all_output, + gates_passed: false, + }); + } + + // ── Bug 226: Verify the commit contains real code changes ───── + // If the merge only brought in .huskies/ files (pipeline file moves), + // there are no actual code changes to land on master. Abort. + { + let diff_check = Command::new("git") + .args(["diff", "--name-only", "HEAD~1..HEAD"]) + .current_dir(&merge_wt_path) + .output() + .map_err(|e| format!("Failed to check merge diff: {e}"))?; + let changed_files = String::from_utf8_lossy(&diff_check.stdout); + let has_code_changes = changed_files + .lines() + .any(|f| !f.starts_with(".huskies/work/")); + if !has_code_changes { + all_output.push_str( + "=== Merge commit contains only .huskies/ file moves, no code changes ===\n", + ); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts, + conflicts_resolved, + conflict_details: Some( + "Feature branch has no code changes outside .huskies/ — only \ + pipeline file moves were found." + .to_string(), + ), + output: all_output, + gates_passed: false, + }); + } + } + + // ── Run component setup from project.toml (same as worktree creation) ────────── + { + let config = ProjectConfig::load(&merge_wt_path).unwrap_or_default(); + if !config.component.is_empty() { + all_output.push_str("=== component setup (merge worktree) ===\n"); + } + for component in &config.component { + let cmd_dir = merge_wt_path.join(&component.path); + for cmd in &component.setup { + all_output.push_str(&format!("--- {}: {cmd} ---\n", component.name)); + match Command::new("sh") + .args(["-c", cmd]) + .current_dir(&cmd_dir) + .output() + { + Ok(out) => { + all_output.push_str(&String::from_utf8_lossy(&out.stdout)); + all_output.push_str(&String::from_utf8_lossy(&out.stderr)); + all_output.push('\n'); + if !out.status.success() { + all_output.push_str(&format!( + "=== setup warning: '{}' failed: {cmd} ===\n", + component.name + )); + } + } + Err(e) => { + all_output.push_str(&format!( + "=== setup warning: failed to run '{cmd}': {e} ===\n" + )); + } + } + } + } + } + + // ── Quality gates in merge workspace (BEFORE fast-forward) ──── + // Run gates in the merge worktree so that failures abort before master moves. + all_output.push_str("=== Running quality gates before fast-forward ===\n"); + match run_merge_quality_gates(&merge_wt_path) { + Ok((true, gate_out)) => { + all_output.push_str(&gate_out); + all_output.push('\n'); + all_output.push_str("=== Quality gates passed ===\n"); + } + Ok((false, gate_out)) => { + all_output.push_str(&gate_out); + all_output.push('\n'); + all_output.push_str( + "=== Quality gates FAILED — aborting fast-forward, master unchanged ===\n", + ); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts, + conflicts_resolved, + conflict_details, + output: all_output, + gates_passed: false, + }); + } + Err(e) => { + all_output.push_str(&format!("Gate check error: {e}\n")); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts, + conflicts_resolved, + conflict_details, + output: all_output, + gates_passed: false, + }); + } + } + + // ── Cherry-pick the squash commit onto master ────────────────── + // We cherry-pick instead of fast-forward so that concurrent filesystem + // watcher commits on master (e.g. pipeline file moves) don't block the + // merge. Cherry-pick applies the diff of the squash commit cleanly on + // top of master's current HEAD. + all_output.push_str(&format!( + "=== Cherry-picking squash commit from {merge_branch} onto master ===\n" + )); + let cp = Command::new("git") + .args(["cherry-pick", &merge_branch]) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to cherry-pick merge-queue commit: {e}"))?; + + let cp_stdout = String::from_utf8_lossy(&cp.stdout).to_string(); + let cp_stderr = String::from_utf8_lossy(&cp.stderr).to_string(); + all_output.push_str(&cp_stdout); + all_output.push_str(&cp_stderr); + all_output.push('\n'); + + if !cp.status.success() { + // Abort the cherry-pick so master is left clean. + let _ = Command::new("git") + .args(["cherry-pick", "--abort"]) + .current_dir(project_root) + .output(); + all_output.push_str("=== Cherry-pick failed — aborting, master unchanged ===\n"); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts, + conflicts_resolved, + conflict_details: Some(format!( + "Cherry-pick of squash commit failed (conflict with master?):\n{cp_stderr}" + )), + output: all_output, + gates_passed: true, + }); + } + + // ── Verify code landed on the correct branch ────────────────── + // Guard against the cherry-pick silently landing on the wrong branch + // (e.g. a merge-queue branch from a concurrent merge). If the current + // branch is not the base branch, or the HEAD commit has no code diff, + // treat the merge as failed so the story stays in the merge stage. + let current_branch = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(project_root) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + + let base_branch = crate::config::ProjectConfig::load(project_root) + .ok() + .and_then(|c| c.base_branch.clone()) + .unwrap_or_else(|| "master".to_string()); + + if current_branch != base_branch { + all_output.push_str(&format!( + "=== VERIFICATION FAILED: expected branch '{base_branch}' but HEAD is on \ + '{current_branch}'. Cherry-pick landed on wrong branch. ===\n" + )); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts, + conflicts_resolved, + conflict_details: Some(format!( + "Cherry-pick landed on '{current_branch}' instead of '{base_branch}'" + )), + output: all_output, + gates_passed: true, + }); + } + + // Verify HEAD commit has actual code changes (not an empty cherry-pick). + // Exclude .huskies/work/ (pipeline file moves) but keep .huskies/project.toml + // and other config files which are legitimate deliverables. + let diff_stat = Command::new("git") + .args([ + "diff", + "--stat", + "HEAD~1..HEAD", + "--", + ".", + ":(exclude).huskies/work", + ]) + .current_dir(project_root) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + + if diff_stat.is_empty() { + all_output.push_str( + "=== VERIFICATION FAILED: cherry-pick produced no code changes on master. ===\n", + ); + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + return Ok(SquashMergeResult { + success: false, + had_conflicts, + conflicts_resolved, + conflict_details: Some( + "Cherry-pick commit contains no code changes (empty diff)".to_string(), + ), + output: all_output, + gates_passed: true, + }); + } + + all_output.push_str(&format!( + "=== Verified: cherry-pick landed on '{base_branch}' with code changes ===\n" + )); + + // ── Clean up ────────────────────────────────────────────────── + cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); + all_output.push_str("=== Merge-queue cleanup complete ===\n"); + + Ok(SquashMergeResult { + success: true, + had_conflicts, + conflicts_resolved, + conflict_details, + output: all_output, + gates_passed: true, + }) +} + +pub(crate) fn cleanup_merge_workspace( + project_root: &Path, + merge_wt_path: &Path, + merge_branch: &str, +) { + let wt_str = merge_wt_path.to_string_lossy().to_string(); + let _ = Command::new("git") + .args(["worktree", "remove", "--force", &wt_str]) + .current_dir(project_root) + .output(); + // If the directory still exists (e.g. it was a plain directory from a + // previous failed run, not a registered git worktree), remove it so + // the next `git worktree add` can succeed. + if merge_wt_path.exists() { + let _ = std::fs::remove_dir_all(merge_wt_path); + } + let _ = Command::new("git") + .args(["branch", "-D", merge_branch]) + .current_dir(project_root) + .output(); +} + +fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String> { + let mut all_output = String::new(); + let mut all_passed = true; + + let script_test = project_root.join("script").join("test"); + + if script_test.exists() { + // Delegate entirely to script/test — it is the single source of truth + // for the full test suite (clippy, cargo tests, frontend builds, etc.). + let (success, output) = run_project_tests(project_root)?; + all_output.push_str(&output); + if !success { + all_passed = false; + } + return Ok((all_passed, all_output)); + } + + // No script/test — fall back to cargo gates for Rust projects. + let cargo_toml = project_root.join("Cargo.toml"); + if cargo_toml.exists() { + let clippy = Command::new("cargo") + .args(["clippy", "--all-targets", "--all-features"]) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to run cargo clippy: {e}"))?; + + all_output.push_str("=== cargo clippy ===\n"); + let clippy_out = format!( + "{}{}", + String::from_utf8_lossy(&clippy.stdout), + String::from_utf8_lossy(&clippy.stderr) + ); + all_output.push_str(&clippy_out); + all_output.push('\n'); + + if !clippy.status.success() { + all_passed = false; + } + + let (test_success, test_out) = run_project_tests(project_root)?; + all_output.push_str(&test_out); + if !test_success { + all_passed = false; + } + } + + Ok((all_passed, all_output)) +} + +#[cfg(test)] +mod tests_advanced; +#[cfg(test)] +mod tests_basic; diff --git a/server/src/agents/merge/squash/tests_advanced.rs b/server/src/agents/merge/squash/tests_advanced.rs new file mode 100644 index 00000000..a2085a37 --- /dev/null +++ b/server/src/agents/merge/squash/tests_advanced.rs @@ -0,0 +1,487 @@ +use super::*; +use std::process::Command; + +fn init_git_repo(repo: &std::path::Path) { + Command::new("git") + .args(["init"]) + .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(); +} + +#[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!( + !result.success, + "merge with only .huskies/ changes must fail: {}", + result.output + ); + + // 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(); + + // Conflict must be detected and auto-resolved. + assert!(result.had_conflicts, "additive conflict should be detected"); + assert!( + result.conflicts_resolved, + "additive conflict must be auto-resolved; output:\n{}", + result.output + ); + + // Master must contain both additions without conflict markers. + 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" + ); + assert!( + content.contains("feature_fn"), + "feature branch addition must be preserved on master" + ); + assert!( + content.contains("master_fn"), + "master branch addition must be preserved on master" + ); + assert!( + content.contains("existing"), + "original function must be preserved" + ); + + // 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 → auto-resolved → quality gates run → fail. + let result = run_squash_merge(repo, "feature/story-238_gates_fail", "238_gates_fail").unwrap(); + + assert!(result.had_conflicts, "conflict must be detected"); + assert!( + result.conflicts_resolved, + "additive conflict must be auto-resolved" + ); + assert!( + !result.gates_passed, + "quality gates must fail (script/test exits 1)" + ); + assert!( + !result.success, + "merge must be reported as failed when gates fail" + ); + assert!( + !result.output.is_empty(), + "output must contain gate failure 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!( + result.success, + "merge should succeed after cleaning up stale workspace: {}", + result.output + ); + 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 + ); +} + +#[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 + ); +} diff --git a/server/src/agents/merge/squash/tests_basic.rs b/server/src/agents/merge/squash/tests_basic.rs new file mode 100644 index 00000000..6f54a7ed --- /dev/null +++ b/server/src/agents/merge/squash/tests_basic.rs @@ -0,0 +1,363 @@ +use super::*; +use std::process::Command; + +fn init_git_repo(repo: &std::path::Path) { + Command::new("git") + .args(["init"]) + .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(); +} + +#[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. + assert!(result.had_conflicts, "should detect conflicts"); + + // Conflicts should have been auto-resolved (both are simple additions). + if result.conflicts_resolved { + assert!(result.success, "auto-resolved merge should succeed"); + assert!( + master_content.contains("master addition"), + "master side should be present" + ); + assert!( + master_content.contains("feature addition"), + "feature side should be present" + ); + } + + // 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!(result.success, "clean merge should succeed"); + assert!( + !result.had_conflicts, + "clean merge should have no conflicts" + ); + assert!( + !result.conflicts_resolved, + "no conflicts means nothing to resolve" + ); + assert!( + repo.join("new_file.txt").exists(), + "merged file should exist on master" + ); +} + +#[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!(!result.success, "merge of nonexistent branch should fail"); +} + +#[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!( + result.success, + "squash merge should succeed despite diverged master: {}", + result.output + ); + assert!(!result.had_conflicts, "no conflicts expected"); + + // 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!( + !r.success, + "empty diff merge must fail, not silently succeed: {}", + r.output + ), + 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" + ); +}