2026-04-26 21:15:06 +00:00
|
|
|
//! Merge operations — rebases agent work onto master and runs post-merge validation.
|
|
|
|
|
|
2026-04-28 10:19:43 +00:00
|
|
|
use serde::{Deserialize, Serialize};
|
2026-04-26 21:15:06 +00:00
|
|
|
|
|
|
|
|
mod squash;
|
|
|
|
|
|
2026-04-27 01:32:08 +00:00
|
|
|
pub(crate) use squash::run_squash_merge;
|
2026-04-26 21:15:06 +00:00
|
|
|
|
2026-05-13 16:26:09 +00:00
|
|
|
/// 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,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 10:18:33 +01:00
|
|
|
|
|
|
|
|
/// 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()),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 16:26:09 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 21:15:06 +00:00
|
|
|
/// 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,
|
2026-04-28 20:41:32 +00:00
|
|
|
/// Server start-time (Unix seconds) of the server instance that started
|
|
|
|
|
/// this job.
|
2026-04-27 17:41:39 +00:00
|
|
|
///
|
|
|
|
|
/// Used by stale-lock recovery: on a new merge attempt the system checks
|
2026-04-28 20:41:32 +00:00
|
|
|
/// 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,
|
2026-04-26 21:15:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Result of a mergemaster merge operation.
|
2026-04-28 10:19:43 +00:00
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
2026-04-26 21:15:06 +00:00
|
|
|
pub struct MergeReport {
|
|
|
|
|
pub story_id: String,
|
2026-05-13 16:26:09 +00:00
|
|
|
/// Typed outcome of the merge operation.
|
|
|
|
|
pub result: MergeResult,
|
2026-04-26 21:15:06 +00:00
|
|
|
pub worktree_cleaned_up: bool,
|
|
|
|
|
pub story_archived: bool,
|
|
|
|
|
}
|
2026-05-14 10:18:33 +01:00
|
|
|
|
|
|
|
|
#[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();
|
|
|
|
|
}
|
|
|
|
|
}
|