diff --git a/server/src/agents/merge/squash.rs b/server/src/agents/merge/squash.rs index 526808d6..8b70882d 100644 --- a/server/src/agents/merge/squash.rs +++ b/server/src/agents/merge/squash.rs @@ -28,6 +28,34 @@ pub(crate) fn run_squash_merge( .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"); @@ -827,16 +855,25 @@ mod tests { .output() .unwrap(); - let result = run_squash_merge(repo, "feature/story-empty_test", "empty_test").unwrap(); + let result = run_squash_merge(repo, "feature/story-empty_test", "empty_test"); - // Bug 226: empty diff must NOT be treated as success. - assert!( - !result.success, - "empty diff merge must fail, not silently succeed: {}", - result.output - ); + // 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. + // 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" diff --git a/server/src/agents/pool/pipeline/merge.rs b/server/src/agents/pool/pipeline/merge.rs index d3900dd2..27f32c39 100644 --- a/server/src/agents/pool/pipeline/merge.rs +++ b/server/src/agents/pool/pipeline/merge.rs @@ -614,4 +614,165 @@ mod tests { MergeJobStatus::Running => panic!("should not still be running"), } } + + // ── bug 675: zero commits ahead must fail with "no commits to merge" ───── + + /// Regression test for bug 675: when the feature branch has zero commits + /// ahead of master the pipeline must fail with a clear "no commits to merge" + /// error and the story must remain in `4_merge` (not advance to `5_done`). + #[tokio::test] + async fn merge_agent_work_zero_commits_ahead_stays_in_merge_stage() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Feature branch is created at the same commit as master — zero commits ahead. + Command::new("git") + .args(["checkout", "-b", "feature/story-675_zero_commits"]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "master"]) + .current_dir(repo) + .output() + .unwrap(); + + // Place the story file in 4_merge so we can verify it stays there. + let merge_dir = repo.join(".huskies/work/4_merge"); + fs::create_dir_all(&merge_dir).unwrap(); + fs::write( + merge_dir.join("675_zero_commits.md"), + "---\nname: Zero commits test\n---\n", + ) + .unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "place story in 4_merge"]) + .current_dir(repo) + .output() + .unwrap(); + + let pool = Arc::new(AgentPool::new_test(3001)); + let job = run_merge_to_completion(&pool, repo, "675_zero_commits").await; + + // The job must have failed with a "no commits to merge" error. + match &job.status { + MergeJobStatus::Failed(e) => { + assert!( + e.contains("no commits to merge"), + "error must contain 'no commits to merge', got: {e}" + ); + assert!( + e.contains("675_zero_commits"), + "error must name the story_id, got: {e}" + ); + } + MergeJobStatus::Completed(report) => { + panic!( + "expected Failed status, got Completed with success={}: {}", + report.success, report.gate_output + ); + } + MergeJobStatus::Running => panic!("should not still be running"), + } + + // Story file must still be in 4_merge — NOT advanced to 5_done. + assert!( + merge_dir.join("675_zero_commits.md").exists(), + "story file must remain in 4_merge when merge fails" + ); + assert!( + !repo + .join(".huskies/work/5_done/675_zero_commits.md") + .exists(), + "story must NOT advance to 5_done when merge fails with no commits" + ); + } + + /// Non-regression test for bug 675: a feature branch with exactly one commit + /// ahead of master must continue to merge successfully (happy path). + #[tokio::test] + async fn merge_agent_work_one_commit_ahead_merges_successfully() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Feature branch: one commit ahead of master. + Command::new("git") + .args(["checkout", "-b", "feature/story-675_one_commit"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write(repo.join("feature_675.txt"), "feature content\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add feature file"]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "master"]) + .current_dir(repo) + .output() + .unwrap(); + + // Place the story file in 4_merge. + let merge_dir = repo.join(".huskies/work/4_merge"); + fs::create_dir_all(&merge_dir).unwrap(); + fs::write( + merge_dir.join("675_one_commit.md"), + "---\nname: One commit test\n---\n", + ) + .unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "place story in 4_merge"]) + .current_dir(repo) + .output() + .unwrap(); + + let pool = Arc::new(AgentPool::new_test(3001)); + let job = run_merge_to_completion(&pool, repo, "675_one_commit").await; + + // The merge must not fail with "no commits to merge". + match &job.status { + MergeJobStatus::Failed(e) => { + assert!( + !e.contains("no commits to merge"), + "one-commit-ahead branch must NOT fail with 'no commits to merge': {e}" + ); + // Gate failures (no script/test) are acceptable in test env. + } + MergeJobStatus::Completed(report) => { + // Success or gate failure — both acceptable; the key invariant is + // that we didn't fail with the zero-commits early-exit. + assert!( + report.success || !report.gates_passed, + "unexpected state: success={} gates_passed={}", + report.success, + report.gates_passed + ); + } + MergeJobStatus::Running => panic!("should not still be running"), + } + } }