//! 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::{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 conflicts_resolved = false; let mut conflict_details: Option = None; if !merge.status.success() { all_output.push_str( "=== Conflicts detected — aborting merge. Use `start_agent mergemaster` \ to invoke LLM-driven conflict resolution. ===\n", ); let details = format!("Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}"); conflict_details = Some(details); cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: false, had_conflicts: true, conflicts_resolved, conflict_details, output: all_output, gates_passed: false, }); } let had_conflicts = 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() { if commit_stderr.contains("nothing to commit") || commit_stdout.contains("nothing to commit") { // Bug 777: "nothing to commit" after a clean squash means the feature // branch's content is already on `base_branch` (idempotent retry after // a previous successful merge). Return success so the caller advances // the story to `5_done` instead of overwriting that state with // `merge_failure`. The pre-flight `ahead == 0` check above already // catches genuinely empty feature branches, so reaching this point // implies "ahead > 0 but already merged". all_output .push_str("=== Nothing to commit — feature branch already merged into base ===\n"); cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); return Ok(SquashMergeResult { success: true, had_conflicts: false, conflicts_resolved: false, conflict_details: None, output: all_output, gates_passed: true, }); } 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;