huskies: merge 987
This commit is contained in:
@@ -6,7 +6,7 @@ use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::super::gates::run_project_tests;
|
||||
use super::{MergeReport, SquashMergeResult};
|
||||
use super::{MergeReport, MergeResult};
|
||||
use crate::config::ProjectConfig;
|
||||
|
||||
/// Global lock ensuring only one squash-merge runs at a time.
|
||||
@@ -21,7 +21,7 @@ pub(crate) fn run_squash_merge(
|
||||
project_root: &Path,
|
||||
branch: &str,
|
||||
story_id: &str,
|
||||
) -> Result<SquashMergeResult, String> {
|
||||
) -> Result<MergeResult, String> {
|
||||
// Acquire the merge lock so concurrent calls don't clobber each other.
|
||||
let _lock = MERGE_LOCK
|
||||
.lock()
|
||||
@@ -48,18 +48,11 @@ pub(crate) fn run_squash_merge(
|
||||
.parse()
|
||||
.unwrap_or(1); // parse failure → don't false-positive; let merge proceed
|
||||
if ahead == 0 {
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts: false,
|
||||
conflicts_resolved: false,
|
||||
conflict_details: None,
|
||||
return Ok(MergeResult::NoCommits {
|
||||
output: format!(
|
||||
"{story_id}: no commits to merge — feature branch '{branch}' \
|
||||
has 0 commits ahead of '{base_branch}'"
|
||||
),
|
||||
gates_passed: false,
|
||||
gate_failure_kind: None,
|
||||
no_commits: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -115,9 +108,6 @@ pub(crate) fn run_squash_merge(
|
||||
all_output.push_str(&merge_stderr);
|
||||
all_output.push('\n');
|
||||
|
||||
let conflicts_resolved = false;
|
||||
let mut conflict_details: Option<String> = None;
|
||||
|
||||
if !merge.status.success() {
|
||||
all_output.push_str(
|
||||
"=== Conflicts detected — aborting merge. Use `start_agent mergemaster` \
|
||||
@@ -125,20 +115,12 @@ pub(crate) fn run_squash_merge(
|
||||
);
|
||||
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,
|
||||
return Ok(MergeResult::Conflict {
|
||||
details: Some(details),
|
||||
output: all_output,
|
||||
gates_passed: false,
|
||||
gate_failure_kind: None,
|
||||
no_commits: false,
|
||||
});
|
||||
}
|
||||
let had_conflicts = false;
|
||||
|
||||
// ── Commit in the temporary worktree ──────────────────────────
|
||||
all_output.push_str("=== git commit ===\n");
|
||||
@@ -169,27 +151,16 @@ pub(crate) fn run_squash_merge(
|
||||
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,
|
||||
return Ok(MergeResult::Success {
|
||||
conflicts_resolved: false,
|
||||
conflict_details: None,
|
||||
output: all_output,
|
||||
gates_passed: true,
|
||||
gate_failure_kind: None,
|
||||
no_commits: false,
|
||||
gate_output: all_output,
|
||||
});
|
||||
}
|
||||
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||
return Ok(SquashMergeResult {
|
||||
success: false,
|
||||
had_conflicts,
|
||||
conflicts_resolved,
|
||||
conflict_details,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
gates_passed: false,
|
||||
gate_failure_kind: None,
|
||||
no_commits: false,
|
||||
conflict_details: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,19 +182,13 @@ pub(crate) fn run_squash_merge(
|
||||
"=== 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,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
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,
|
||||
gate_failure_kind: None,
|
||||
no_commits: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -280,29 +245,17 @@ pub(crate) fn run_squash_merge(
|
||||
"=== 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,
|
||||
return Ok(MergeResult::GateFailure {
|
||||
output: all_output,
|
||||
gates_passed: false,
|
||||
gate_failure_kind: outcome.failure_kind,
|
||||
no_commits: false,
|
||||
failure_kind: outcome.failure_kind,
|
||||
});
|
||||
}
|
||||
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,
|
||||
return Ok(MergeResult::GateFailure {
|
||||
output: all_output,
|
||||
gates_passed: false,
|
||||
gate_failure_kind: None,
|
||||
no_commits: false,
|
||||
failure_kind: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -335,17 +288,11 @@ pub(crate) fn run_squash_merge(
|
||||
.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,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
conflict_details: Some(format!(
|
||||
"Cherry-pick of squash commit failed (conflict with master?):\n{cp_stderr}"
|
||||
)),
|
||||
output: all_output,
|
||||
gates_passed: true,
|
||||
gate_failure_kind: None,
|
||||
no_commits: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,17 +319,11 @@ pub(crate) fn run_squash_merge(
|
||||
'{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,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
conflict_details: Some(format!(
|
||||
"Cherry-pick landed on '{current_branch}' instead of '{base_branch}'"
|
||||
)),
|
||||
output: all_output,
|
||||
gates_passed: true,
|
||||
gate_failure_kind: None,
|
||||
no_commits: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -408,17 +349,11 @@ pub(crate) fn run_squash_merge(
|
||||
"=== 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,
|
||||
return Ok(MergeResult::Other {
|
||||
output: all_output,
|
||||
conflict_details: Some(
|
||||
"Cherry-pick commit contains no code changes (empty diff)".to_string(),
|
||||
),
|
||||
output: all_output,
|
||||
gates_passed: true,
|
||||
gate_failure_kind: None,
|
||||
no_commits: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -430,15 +365,10 @@ pub(crate) fn run_squash_merge(
|
||||
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,
|
||||
gate_failure_kind: None,
|
||||
no_commits: false,
|
||||
Ok(MergeResult::Success {
|
||||
conflicts_resolved: false,
|
||||
conflict_details: None,
|
||||
gate_output: all_output,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -64,9 +64,9 @@ async fn squash_merge_md_only_changes_fails() {
|
||||
// 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
|
||||
!matches!(result, super::MergeResult::Success { .. }),
|
||||
"merge with only .huskies/ changes must fail: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Cleanup should still happen.
|
||||
@@ -146,12 +146,10 @@ async fn squash_merge_additive_conflict_both_additions_preserved() {
|
||||
let result = run_squash_merge(repo, "feature/story-238_additive", "238_additive").unwrap();
|
||||
|
||||
// Deterministic merge does NOT auto-resolve conflicts — AC3 requires failure.
|
||||
assert!(result.had_conflicts, "additive conflict should be detected");
|
||||
assert!(
|
||||
!result.conflicts_resolved,
|
||||
"deterministic merge must NOT auto-resolve conflicts"
|
||||
matches!(result, super::MergeResult::Conflict { .. }),
|
||||
"additive conflict should produce Conflict variant; got: {result:?}"
|
||||
);
|
||||
assert!(!result.success, "conflict must cause merge failure");
|
||||
|
||||
// Master must not have been modified (merge aborted).
|
||||
let content = fs::read_to_string(repo.join("module.rs")).unwrap();
|
||||
@@ -254,18 +252,13 @@ async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() {
|
||||
// Squash-merge: conflict detected → aborted immediately (no gate run).
|
||||
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,
|
||||
"deterministic merge must NOT auto-resolve conflicts"
|
||||
);
|
||||
// Merge is aborted at conflict detection; gates are never reached.
|
||||
assert!(
|
||||
!result.success,
|
||||
"conflicting merge must be reported as failed"
|
||||
matches!(result, super::MergeResult::Conflict { .. }),
|
||||
"conflicting merge must produce Conflict variant; got: {result:?}"
|
||||
);
|
||||
assert!(
|
||||
!result.output.is_empty(),
|
||||
!result.output().is_empty(),
|
||||
"output must contain conflict details"
|
||||
);
|
||||
|
||||
@@ -329,9 +322,9 @@ async fn squash_merge_cleans_up_stale_workspace() {
|
||||
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
|
||||
matches!(result, super::MergeResult::Success { .. }),
|
||||
"merge should succeed after cleaning up stale workspace: {:?}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
!stale_ws.exists(),
|
||||
@@ -398,15 +391,15 @@ fn squash_merge_runs_component_setup_from_project_toml() {
|
||||
|
||||
// The output must mention component setup, proving the new code path ran.
|
||||
assert!(
|
||||
result.output.contains("component setup"),
|
||||
result.output().contains("component setup"),
|
||||
"merge output must mention component setup when project.toml has components, got:\n{}",
|
||||
result.output
|
||||
result.output()
|
||||
);
|
||||
// The sentinel command must appear in the output.
|
||||
assert!(
|
||||
result.output.contains("sentinel"),
|
||||
result.output().contains("sentinel"),
|
||||
"merge output must name the component, got:\n{}",
|
||||
result.output
|
||||
result.output()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,13 +454,13 @@ fn squash_merge_succeeds_without_components_in_project_toml() {
|
||||
|
||||
// No pnpm or frontend references should appear in the output.
|
||||
assert!(
|
||||
!result.output.contains("pnpm"),
|
||||
!result.output().contains("pnpm"),
|
||||
"output must not mention pnpm, got:\n{}",
|
||||
result.output
|
||||
result.output()
|
||||
);
|
||||
assert!(
|
||||
!result.output.contains("frontend/"),
|
||||
!result.output().contains("frontend/"),
|
||||
"output must not mention frontend/, got:\n{}",
|
||||
result.output
|
||||
result.output()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,21 +101,11 @@ async fn squash_merge_uses_merge_queue_no_conflict_markers_on_master() {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
// The merge should have had conflicts (returned as Conflict variant).
|
||||
assert!(
|
||||
matches!(result, super::MergeResult::Conflict { .. }),
|
||||
"should detect conflicts; got: {result:?}"
|
||||
);
|
||||
|
||||
// Verify no leftover merge-queue branch.
|
||||
let branches = Command::new("git")
|
||||
@@ -172,14 +162,15 @@ async fn squash_merge_clean_merge_succeeds() {
|
||||
|
||||
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"
|
||||
matches!(
|
||||
result,
|
||||
super::MergeResult::Success {
|
||||
conflicts_resolved: false,
|
||||
..
|
||||
}
|
||||
),
|
||||
"clean merge should succeed without conflicts; got: {result:?}"
|
||||
);
|
||||
assert!(
|
||||
repo.join("new_file.txt").exists(),
|
||||
@@ -197,7 +188,10 @@ async fn squash_merge_nonexistent_branch_fails() {
|
||||
|
||||
let result = run_squash_merge(repo, "feature/story-nope", "nope").unwrap();
|
||||
|
||||
assert!(!result.success, "merge of nonexistent branch should fail");
|
||||
assert!(
|
||||
!matches!(result, super::MergeResult::Success { .. }),
|
||||
"merge of nonexistent branch should fail; got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -267,11 +261,10 @@ async fn squash_merge_succeeds_when_master_diverges() {
|
||||
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
|
||||
matches!(result, super::MergeResult::Success { .. }),
|
||||
"squash merge should succeed despite diverged master: {:?}",
|
||||
result
|
||||
);
|
||||
assert!(!result.had_conflicts, "no conflicts expected");
|
||||
|
||||
// Verify the feature file landed on master.
|
||||
assert!(
|
||||
@@ -346,9 +339,9 @@ async fn squash_merge_empty_diff_fails() {
|
||||
// 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
|
||||
!matches!(r, super::MergeResult::Success { .. }),
|
||||
"empty diff merge must fail, not silently succeed: {:?}",
|
||||
r
|
||||
),
|
||||
Err(e) => assert!(
|
||||
e.contains("no commits to merge") || e.contains("nothing to commit"),
|
||||
@@ -417,24 +410,21 @@ async fn idempotent_retry_after_successful_merge_returns_success() {
|
||||
.expect("first merge produces Ok");
|
||||
// The merge may fail gates in test env (no script/test); only require that
|
||||
// the squash applied SOMETHING (cargo gates env-dependent).
|
||||
if r1.success {
|
||||
if matches!(r1, super::MergeResult::Success { .. }) {
|
||||
// Second merge of the SAME branch: must report success (idempotent),
|
||||
// not merge_failure. Feature branch's content is already on master so
|
||||
// the squash produces "nothing to commit" — bug 777 makes this success.
|
||||
let r2 = run_squash_merge(repo, "feature/story-777_idem", "777_idem")
|
||||
.expect("second merge produces Ok");
|
||||
assert!(
|
||||
r2.success,
|
||||
"idempotent retry must return success: {}",
|
||||
r2.output
|
||||
);
|
||||
assert!(
|
||||
!r2.had_conflicts,
|
||||
"idempotent retry should report no conflicts"
|
||||
);
|
||||
assert!(
|
||||
r2.conflict_details.is_none(),
|
||||
"no conflict_details on idempotent retry"
|
||||
matches!(
|
||||
r2,
|
||||
super::MergeResult::Success {
|
||||
conflicts_resolved: false,
|
||||
..
|
||||
}
|
||||
),
|
||||
"idempotent retry must return Success without conflicts: {r2:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user