//! Merge operations — rebases agent work onto master and runs post-merge validation. use serde::{Deserialize, Serialize}; 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, /// 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, output: String, }, /// Squash commit produced but quality gates failed; base branch may carry the commit. GateFailure { output: String, #[serde(default)] failure_kind: Option, }, /// 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, }, } 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, } } /// Convert a non-success outcome into the pipeline-level `MergeFailureKind` /// used to drive recovery routing. /// /// A `GateFailure` whose typed `failure_kind` is `Build` is reclassified as /// `ConflictDetected` — a post-squash compile error after a clean git merge /// is semantically a merge conflict that git's diff3 missed (the conflicting /// literal lives in a different file from the type definition that changed /// on master). Routing it as a conflict ensures mergemaster auto-spawns to /// resolve it, rather than the failure sitting as an opaque `GatesFailed`. /// /// Panics on `Success`; callers must guard with a success check first. pub fn to_merge_failure_kind(&self) -> crate::pipeline_state::MergeFailureKind { use crate::pipeline_state::MergeFailureKind; match self { Self::Success { .. } => { panic!("to_merge_failure_kind called on MergeResult::Success") } Self::NoCommits { .. } => MergeFailureKind::NoCommits, Self::Conflict { details, .. } => MergeFailureKind::ConflictDetected(details.clone()), Self::GateFailure { output, failure_kind: Some(crate::agents::gates::GateFailureKind::Build), } => MergeFailureKind::ConflictDetected(Some(output.clone())), Self::GateFailure { output, .. } => MergeFailureKind::GatesFailed(output.clone()), Self::Other { output, .. } => MergeFailureKind::Other(output.clone()), } } } /// Status of an async merge job. #[derive(Debug, Clone, Serialize)] pub enum MergeJobStatus { Running, Completed(MergeReport), Failed(String), } /// Tracks a background merge job started by `merge_agent_work`. #[derive(Debug, Clone, Serialize)] pub struct MergeJob { pub story_id: String, pub status: MergeJobStatus, /// Server start-time (Unix seconds) of the server instance that started /// this job. /// /// Used by stale-lock recovery: on a new merge attempt the system checks /// every Running entry and removes any whose recorded start-time is older /// than the current server's boot time. This survives `rebuild_and_restart` /// (which re-execs and keeps the same PID). pub server_start_time: f64, } /// Result of a mergemaster merge operation. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct MergeReport { pub story_id: String, /// Typed outcome of the merge operation. pub result: MergeResult, pub worktree_cleaned_up: bool, pub story_archived: bool, } #[cfg(test)] mod tests { use super::*; use crate::agents::gates::GateFailureKind; use crate::pipeline_state::MergeFailureKind; #[test] fn to_failure_kind_maps_build_gate_failure_to_conflict_detected() { // Post-squash compile error (e.g. story 1018: `error[E0063]: missing field // plan` because master gained Stage::Coding's plan field after the // coder's branch was committed) is a semantic merge conflict that git // missed. Mergemaster should auto-spawn, so the kind must be // ConflictDetected, not GatesFailed. let output = "error[E0063]: missing field `plan` in initializer of `Stage`".to_string(); let result = MergeResult::GateFailure { output: output.clone(), failure_kind: Some(GateFailureKind::Build), }; match result.to_merge_failure_kind() { MergeFailureKind::ConflictDetected(details) => { assert_eq!(details.as_deref(), Some(output.as_str())); } other => panic!("expected ConflictDetected, got {other:?}"), } } #[test] fn to_failure_kind_maps_test_gate_failure_to_gates_failed() { // Non-build gate failures (test failure, fmt drift, lint, etc.) are // genuine gate problems, not merge conflicts. They must remain // GatesFailed so mergemaster does NOT auto-spawn for them. let result = MergeResult::GateFailure { output: "test result: FAILED. 1 failed".to_string(), failure_kind: Some(GateFailureKind::Test), }; assert!(matches!( result.to_merge_failure_kind(), MergeFailureKind::GatesFailed(_) )); } #[test] fn to_failure_kind_maps_git_conflict_to_conflict_detected() { let result = MergeResult::Conflict { details: Some("conflicts in server/src/foo.rs".to_string()), output: "merge failed".to_string(), }; match result.to_merge_failure_kind() { MergeFailureKind::ConflictDetected(details) => { assert_eq!(details.as_deref(), Some("conflicts in server/src/foo.rs")); } other => panic!("expected ConflictDetected, got {other:?}"), } } #[test] fn to_failure_kind_maps_no_commits() { let result = MergeResult::NoCommits { output: "no commits".to_string(), }; assert!(matches!( result.to_merge_failure_kind(), MergeFailureKind::NoCommits )); } #[test] fn to_failure_kind_maps_other() { let result = MergeResult::Other { output: "cherry-pick failed".to_string(), conflict_details: None, }; assert!(matches!( result.to_merge_failure_kind(), MergeFailureKind::Other(_) )); } #[test] #[should_panic(expected = "to_merge_failure_kind called on MergeResult::Success")] fn to_failure_kind_panics_on_success() { let result = MergeResult::Success { conflicts_resolved: false, conflict_details: None, gate_output: String::new(), }; let _ = result.to_merge_failure_kind(); } }