huskies: merge 675_bug_mergemaster_silently_exits_when_feature_branch_has_zero_commits_ahead_of_master
This commit is contained in:
@@ -28,6 +28,34 @@ pub(crate) fn run_squash_merge(
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| format!("Merge lock poisoned: {e}"))?;
|
.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 mut all_output = String::new();
|
||||||
let merge_branch = format!("merge-queue/{story_id}");
|
let merge_branch = format!("merge-queue/{story_id}");
|
||||||
let merge_wt_path = project_root.join(".huskies").join("merge_workspace");
|
let merge_wt_path = project_root.join(".huskies").join("merge_workspace");
|
||||||
@@ -827,16 +855,25 @@ mod tests {
|
|||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.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.
|
// Bug 226 / 675: a zero-commit branch must not be treated as success.
|
||||||
assert!(
|
// The pre-flight check (bug 675) returns Err for zero commits ahead;
|
||||||
!result.success,
|
// the older code path returned Ok(SquashMergeResult { success: false }).
|
||||||
"empty diff merge must fail, not silently succeed: {}",
|
// Either form is a failure — just not success.
|
||||||
result.output
|
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!(
|
assert!(
|
||||||
!repo.join(".huskies/merge_workspace").exists(),
|
!repo.join(".huskies/merge_workspace").exists(),
|
||||||
"merge workspace should be cleaned up"
|
"merge workspace should be cleaned up"
|
||||||
|
|||||||
@@ -614,4 +614,165 @@ mod tests {
|
|||||||
MergeJobStatus::Running => panic!("should not still be running"),
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user