huskies: merge 987

This commit is contained in:
dave
2026-05-13 16:26:09 +00:00
parent 430079ecbc
commit c3c9db3d8b
13 changed files with 662 additions and 311 deletions
+50 -34
View File
@@ -6,6 +6,54 @@ mod squash;
pub(crate) use squash::run_squash_merge;
/// Typed outcome of a completed squash-merge operation.
///
/// Each variant captures only the fields relevant to that outcome, eliminating
/// the four-bool soup of the old `MergeReport`.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "kind")]
pub enum MergeResult {
/// Squash commit landed on the base branch and all quality gates passed.
Success {
/// `true` when conflicts were detected and automatically resolved.
conflicts_resolved: bool,
conflict_details: Option<String>,
/// Human-readable output from the quality-gate run.
gate_output: String,
},
/// Merge was aborted due to unresolvable conflicts; base branch is untouched.
Conflict {
details: Option<String>,
output: String,
},
/// Squash commit produced but quality gates failed; base branch may carry the commit.
GateFailure {
output: String,
#[serde(default)]
failure_kind: Option<crate::agents::gates::GateFailureKind>,
},
/// Feature branch had zero commits ahead of the base branch.
NoCommits { output: String },
/// Unclassified failure (cherry-pick failed, git error, etc.).
Other {
output: String,
conflict_details: Option<String>,
},
}
impl MergeResult {
/// Extract the human-readable output string from any variant.
pub fn output(&self) -> &str {
match self {
Self::Success { gate_output, .. } => gate_output,
Self::Conflict { output, .. }
| Self::GateFailure { output, .. }
| Self::NoCommits { output }
| Self::Other { output, .. } => output,
}
}
}
/// Status of an async merge job.
#[derive(Debug, Clone, Serialize)]
pub enum MergeJobStatus {
@@ -33,40 +81,8 @@ pub struct MergeJob {
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MergeReport {
pub story_id: String,
pub success: bool,
pub had_conflicts: bool,
/// `true` when conflicts were detected but automatically resolved.
pub conflicts_resolved: bool,
pub conflict_details: Option<String>,
pub gates_passed: bool,
/// Human-readable output from quality gates (display and retry-prompt injection only).
pub gate_output: String,
/// Typed classification of the gate failure, produced at the gate boundary.
/// `None` when `gates_passed` is `true` or when there were no gate results.
#[serde(default)]
pub gate_failure_kind: Option<crate::agents::gates::GateFailureKind>,
/// `true` when the feature branch had zero commits ahead of the base branch.
#[serde(default)]
pub no_commits: bool,
/// Typed outcome of the merge operation.
pub result: MergeResult,
pub worktree_cleaned_up: bool,
pub story_archived: bool,
}
/// Result of a squash-merge operation.
pub(crate) struct SquashMergeResult {
pub(crate) success: bool,
pub(crate) had_conflicts: bool,
/// `true` when conflicts were detected but automatically resolved.
pub(crate) conflicts_resolved: bool,
pub(crate) conflict_details: Option<String>,
pub(crate) output: String,
/// Whether quality gates ran and passed. `false` when `success` is `false`
/// due to a gate failure; callers can use this to distinguish gate failures
/// from merge/commit/FF failures in the `MergeReport`.
pub(crate) gates_passed: bool,
/// Typed gate failure kind produced at the gate boundary. `None` when
/// `gates_passed` is `true` or when failure was not from the gate step.
pub(crate) gate_failure_kind: Option<crate::agents::gates::GateFailureKind>,
/// `true` when the feature branch had zero commits ahead of the base branch.
pub(crate) no_commits: bool,
}
+25 -95
View File
@@ -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()
);
}
+32 -42
View File
@@ -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:?}"
);
}
}
+40 -28
View File
@@ -105,22 +105,35 @@ impl AgentPool {
return;
}
let success = matches!(&report, Ok(r) if r.success);
let success = matches!(
&report,
Ok(r) if matches!(r.result, crate::agents::merge::MergeResult::Success { .. })
);
let finished_at = unix_now();
// On any failure: record merge_failure in CRDT and emit notification.
if !success {
let kind = match &report {
Ok(r) if r.no_commits => crate::pipeline_state::MergeFailureKind::NoCommits,
Ok(r) if r.had_conflicts => {
crate::pipeline_state::MergeFailureKind::ConflictDetected(
r.conflict_details.clone(),
)
}
Ok(r) => {
crate::pipeline_state::MergeFailureKind::GatesFailed(r.gate_output.clone())
}
Ok(r) => match &r.result {
crate::agents::merge::MergeResult::NoCommits { .. } => {
crate::pipeline_state::MergeFailureKind::NoCommits
}
crate::agents::merge::MergeResult::Conflict { details, .. } => {
crate::pipeline_state::MergeFailureKind::ConflictDetected(
details.clone(),
)
}
crate::agents::merge::MergeResult::GateFailure { output, .. } => {
crate::pipeline_state::MergeFailureKind::GatesFailed(output.clone())
}
crate::agents::merge::MergeResult::Other { output, .. } => {
crate::pipeline_state::MergeFailureKind::Other(output.clone())
}
crate::agents::merge::MergeResult::Success { .. } => {
unreachable!("success branch is guarded by !success above")
}
},
Err(e) => crate::pipeline_state::MergeFailureKind::Other(e.clone()),
};
let is_no_commits =
@@ -131,7 +144,17 @@ impl AgentPool {
&& report
.as_ref()
.ok()
.and_then(|r| r.gate_failure_kind.as_ref())
.and_then(|r| {
if let crate::agents::merge::MergeResult::GateFailure {
failure_kind: Some(k),
..
} = &r.result
{
Some(k)
} else {
None
}
})
.map(|k| k.is_self_evident_fix())
.unwrap_or(false);
@@ -271,17 +294,13 @@ impl AgentPool {
.await
.map_err(|e| format!("Merge task panicked: {e}"))??;
if !merge_result.success {
if !matches!(
merge_result,
crate::agents::merge::MergeResult::Success { .. }
) {
return Ok(crate::agents::merge::MergeReport {
story_id: story_id.to_string(),
success: false,
had_conflicts: merge_result.had_conflicts,
conflicts_resolved: merge_result.conflicts_resolved,
conflict_details: merge_result.conflict_details,
gates_passed: merge_result.gates_passed,
gate_output: merge_result.output,
gate_failure_kind: merge_result.gate_failure_kind,
no_commits: merge_result.no_commits,
result: merge_result,
worktree_cleaned_up: false,
story_archived: false,
});
@@ -305,14 +324,7 @@ impl AgentPool {
Ok(crate::agents::merge::MergeReport {
story_id: story_id.to_string(),
success: true,
had_conflicts: merge_result.had_conflicts,
conflicts_resolved: merge_result.conflicts_resolved,
conflict_details: merge_result.conflict_details,
gates_passed: true,
gate_output: merge_result.output,
gate_failure_kind: None,
no_commits: false,
result: merge_result,
worktree_cleaned_up,
story_archived,
})
@@ -23,14 +23,10 @@ impl AgentPool {
.and_then(|e| serde_json::from_str::<crate::agents::merge::MergeReport>(e).ok())
.unwrap_or_else(|| crate::agents::merge::MergeReport {
story_id: story_id.to_string(),
success: false,
had_conflicts: false,
conflicts_resolved: false,
conflict_details: None,
gates_passed: false,
gate_output: String::new(),
gate_failure_kind: None,
no_commits: false,
result: crate::agents::merge::MergeResult::Other {
output: String::new(),
conflict_details: None,
},
worktree_cleaned_up: false,
story_archived: false,
});
+70 -26
View File
@@ -237,7 +237,13 @@ async fn merge_agent_work_returns_error_when_branch_not_found() {
let job = run_merge_to_completion(&pool, repo, "99_nonexistent").await;
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(!report.success, "should fail when branch missing");
assert!(
!matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
),
"should fail when branch missing"
);
}
MergeJobStatus::Failed(_) => {
// Also acceptable — the pipeline errored out
@@ -305,11 +311,23 @@ async fn merge_agent_work_succeeds_on_clean_branch() {
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(!report.had_conflicts, "should have no conflicts");
assert!(
report.success
|| report.gate_output.contains("Failed to run")
|| !report.gates_passed,
!matches!(
report.result,
crate::agents::merge::MergeResult::Conflict { .. }
),
"should have no conflicts"
);
let is_success = matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
);
let is_gate_failure = matches!(
report.result,
crate::agents::merge::MergeResult::GateFailure { .. }
);
assert!(
is_success || is_gate_failure || report.result.output().contains("Failed to run"),
"report should be coherent: {report:?}"
);
if report.story_archived {
@@ -418,8 +436,8 @@ fn quality_gates_run_before_fast_forward_to_master() {
// Gates must have failed (script/test exits 1) so master should be untouched.
assert!(
!result.success,
"run_squash_merge must report failure when gates fail"
!matches!(result, crate::agents::merge::MergeResult::Success { .. }),
"run_squash_merge must report failure when gates fail; got: {result:?}"
);
assert_eq!(
head_before, head_after,
@@ -531,7 +549,13 @@ async fn merge_agent_work_conflict_does_not_break_master() {
// The report should accurately reflect what happened.
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(report.had_conflicts, "should report conflicts");
assert!(
matches!(
report.result,
crate::agents::merge::MergeResult::Conflict { .. }
),
"should report conflicts"
);
}
MergeJobStatus::Failed(_) => {
// Acceptable — merge aborted due to conflicts
@@ -596,17 +620,17 @@ async fn merge_agent_work_zero_commits_ahead_stays_in_merge_stage() {
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(
!report.success,
"merge must not have succeeded when feature branch is empty"
matches!(
report.result,
crate::agents::merge::MergeResult::NoCommits { .. }
),
"merge must fail with NoCommits when feature branch is empty; got: {:?}",
report.result
);
assert!(
report.no_commits,
"report.no_commits must be true for a zero-ahead branch"
);
assert!(
report.gate_output.contains("no commits to merge"),
"gate_output must contain 'no commits to merge', got: {}",
report.gate_output
report.result.output().contains("no commits to merge"),
"output must contain 'no commits to merge', got: {}",
report.result.output()
);
}
MergeJobStatus::Failed(e) => {
@@ -701,10 +725,16 @@ async fn server_side_merge_happy_path_advances_to_done() {
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(
!report.had_conflicts,
!matches!(
report.result,
crate::agents::merge::MergeResult::Conflict { .. }
),
"clean branch should have no conflicts"
);
if report.success {
if matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
) {
// story_archived may or may not be true depending on gate env,
// but merge_failure must NOT be in the content store.
let content = crate::db::read_content(crate::db::ContentKey::Story("757a_happy"));
@@ -838,7 +868,8 @@ async fn server_side_merge_conflict_sets_merge_failure() {
// The merge must fail (conflict).
let failed = matches!(
&job.status,
MergeJobStatus::Completed(r) if !r.success
MergeJobStatus::Completed(r)
if !matches!(r.result, crate::agents::merge::MergeResult::Success { .. })
) || matches!(&job.status, MergeJobStatus::Failed(_));
assert!(
failed,
@@ -953,11 +984,17 @@ async fn server_side_merge_gate_failure_sets_merge_failure() {
match &job.status {
MergeJobStatus::Completed(report) => {
assert!(
!report.success,
!matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
),
"gates should have failed; report: {report:?}"
);
assert!(
!report.had_conflicts,
!matches!(
report.result,
crate::agents::merge::MergeResult::Conflict { .. }
),
"should be a gate failure, not a conflict"
);
}
@@ -1052,11 +1089,18 @@ async fn merge_agent_work_one_commit_ahead_merges_successfully() {
MergeJobStatus::Completed(report) => {
// Success or gate failure — both acceptable; the key invariant is
// that we didn't fail with the zero-commits early-exit.
let is_success = matches!(
report.result,
crate::agents::merge::MergeResult::Success { .. }
);
let is_gate_failure = matches!(
report.result,
crate::agents::merge::MergeResult::GateFailure { .. }
);
assert!(
report.success || !report.gates_passed,
"unexpected state: success={} gates_passed={}",
report.success,
report.gates_passed
is_success || is_gate_failure,
"unexpected result variant: {:?}",
report.result
);
}
MergeJobStatus::Running => panic!("should not still be running"),