use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::merge::{ConflictKind, MergeOutcome, MergeOutput};
use crate::op_log::OpLog;
use crate::operation::{OpId, Operation, SigId, StageId};
pub type MergeSessionId = String;
pub type ConflictId = SigId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConflictRecord {
pub conflict_id: ConflictId,
pub sig_id: SigId,
pub kind: ConflictKind,
pub base: Option<StageId>,
pub ours: Option<StageId>,
pub theirs: Option<StageId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Resolution {
TakeOurs,
TakeTheirs,
Custom { op: Operation },
Defer,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResolutionRejection {
UnknownConflict { conflict_id: ConflictId },
CustomOpMissingParents {
conflict_id: ConflictId,
expected: Vec<OpId>,
got: Vec<OpId>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveVerdict {
pub conflict_id: ConflictId,
pub accepted: bool,
pub rejection: Option<ResolutionRejection>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommitError {
ConflictsRemaining(Vec<ConflictId>),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MergeSession {
pub merge_id: MergeSessionId,
pub src_head: Option<OpId>,
pub dst_head: Option<OpId>,
pub lca: Option<OpId>,
pub auto_resolved: Vec<MergeOutcome>,
conflicts: BTreeMap<ConflictId, ConflictRecord>,
resolutions: BTreeMap<ConflictId, Resolution>,
}
impl MergeSession {
pub fn start(
merge_id: impl Into<MergeSessionId>,
op_log: &OpLog,
src_head: Option<&OpId>,
dst_head: Option<&OpId>,
) -> std::io::Result<Self> {
let MergeOutput { lca, outcomes } = crate::merge::merge(op_log, src_head, dst_head)?;
let mut auto_resolved = Vec::new();
let mut conflicts: BTreeMap<ConflictId, ConflictRecord> = BTreeMap::new();
for outcome in outcomes {
match outcome {
MergeOutcome::Conflict {
sig_id,
kind,
base,
src,
dst,
} => {
let conflict_id = sig_id.clone();
conflicts.insert(
conflict_id.clone(),
ConflictRecord {
conflict_id,
sig_id,
kind,
base,
ours: dst,
theirs: src,
},
);
}
other => auto_resolved.push(other),
}
}
Ok(Self {
merge_id: merge_id.into(),
src_head: src_head.cloned(),
dst_head: dst_head.cloned(),
lca,
auto_resolved,
conflicts,
resolutions: BTreeMap::new(),
})
}
pub fn remaining_conflicts(&self) -> Vec<&ConflictRecord> {
self.conflicts
.values()
.filter(|c| {
!matches!(self.resolutions.get(&c.conflict_id),
Some(Resolution::TakeOurs)
| Some(Resolution::TakeTheirs)
| Some(Resolution::Custom { .. }))
})
.collect()
}
pub fn resolve(
&mut self,
resolutions: Vec<(ConflictId, Resolution)>,
) -> Vec<ResolveVerdict> {
let mut out = Vec::with_capacity(resolutions.len());
for (conflict_id, resolution) in resolutions {
match self.validate_resolution(&conflict_id, &resolution) {
Ok(()) => {
self.resolutions.insert(conflict_id.clone(), resolution);
out.push(ResolveVerdict {
conflict_id,
accepted: true,
rejection: None,
});
}
Err(rej) => {
out.push(ResolveVerdict {
conflict_id,
accepted: false,
rejection: Some(rej),
});
}
}
}
out
}
pub fn validate_resolution(
&self,
conflict_id: &ConflictId,
resolution: &Resolution,
) -> Result<(), ResolutionRejection> {
if !self.conflicts.contains_key(conflict_id) {
return Err(ResolutionRejection::UnknownConflict { conflict_id: conflict_id.clone() });
}
if let Resolution::Custom { op } = resolution {
if op.parents.len() < 2 {
return Err(ResolutionRejection::CustomOpMissingParents {
conflict_id: conflict_id.clone(),
expected: vec!["ours-op-id".into(), "theirs-op-id".into()],
got: op.parents.clone(),
});
}
}
Ok(())
}
pub fn commit(self) -> Result<Vec<(ConflictId, Resolution)>, CommitError> {
let unresolved: Vec<ConflictId> = self
.conflicts
.keys()
.filter(|id| {
!matches!(self.resolutions.get(*id),
Some(Resolution::TakeOurs)
| Some(Resolution::TakeTheirs)
| Some(Resolution::Custom { .. }))
})
.cloned()
.collect();
if !unresolved.is_empty() {
return Err(CommitError::ConflictsRemaining(unresolved));
}
let mut resolved: Vec<(ConflictId, Resolution)> = self.resolutions.into_iter().collect();
resolved.sort_by(|a, b| a.0.cmp(&b.0));
Ok(resolved)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operation::{OperationKind, OperationRecord, StageTransition};
use std::collections::BTreeSet;
fn fixture() -> (tempfile::TempDir, OpLog, OpId, OpId) {
let tmp = tempfile::tempdir().unwrap();
let log = OpLog::open(tmp.path()).unwrap();
let r0 = OperationRecord::new(
Operation::new(
OperationKind::AddFunction {
sig_id: "fn::A".into(),
stage_id: "stage-0".into(),
effects: BTreeSet::new(),
},
[],
),
StageTransition::Create {
sig_id: "fn::A".into(),
stage_id: "stage-0".into(),
},
);
log.put(&r0).unwrap();
let r1 = OperationRecord::new(
Operation::new(
OperationKind::ModifyBody {
sig_id: "fn::A".into(),
from_stage_id: "stage-0".into(),
to_stage_id: "stage-1".into(),
},
[r0.op_id.clone()],
),
StageTransition::Replace {
sig_id: "fn::A".into(),
from: "stage-0".into(),
to: "stage-1".into(),
},
);
log.put(&r1).unwrap();
let r2 = OperationRecord::new(
Operation::new(
OperationKind::ModifyBody {
sig_id: "fn::A".into(),
from_stage_id: "stage-0".into(),
to_stage_id: "stage-2".into(),
},
[r0.op_id.clone()],
),
StageTransition::Replace {
sig_id: "fn::A".into(),
from: "stage-0".into(),
to: "stage-2".into(),
},
);
log.put(&r2).unwrap();
(tmp, log, r1.op_id, r2.op_id)
}
#[test]
fn start_collects_conflicts() {
let (_tmp, log, dst, src) = fixture();
let session =
MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
assert_eq!(session.remaining_conflicts().len(), 1);
assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
assert_eq!(
session.remaining_conflicts()[0].kind,
ConflictKind::ModifyModify
);
assert_eq!(
session.remaining_conflicts()[0].ours.as_deref(),
Some("stage-1"),
);
assert_eq!(
session.remaining_conflicts()[0].theirs.as_deref(),
Some("stage-2"),
);
assert_eq!(
session.remaining_conflicts()[0].base.as_deref(),
Some("stage-0"),
);
}
#[test]
fn no_conflicts_when_branches_dont_overlap() {
let tmp = tempfile::tempdir().unwrap();
let log = OpLog::open(tmp.path()).unwrap();
let r0 = OperationRecord::new(
Operation::new(
OperationKind::AddFunction {
sig_id: "fn::A".into(),
stage_id: "stage-0".into(),
effects: BTreeSet::new(),
},
[],
),
StageTransition::Create {
sig_id: "fn::A".into(),
stage_id: "stage-0".into(),
},
);
log.put(&r0).unwrap();
let r1 = OperationRecord::new(
Operation::new(
OperationKind::AddFunction {
sig_id: "fn::B".into(),
stage_id: "stage-B".into(),
effects: BTreeSet::new(),
},
[r0.op_id.clone()],
),
StageTransition::Create {
sig_id: "fn::B".into(),
stage_id: "stage-B".into(),
},
);
log.put(&r1).unwrap();
let session =
MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
assert!(session.remaining_conflicts().is_empty());
assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
}
#[test]
fn resolve_take_ours_clears_conflict() {
let (_tmp, log, dst, src) = fixture();
let mut session =
MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
assert_eq!(verdicts.len(), 1);
assert!(verdicts[0].accepted);
assert!(session.remaining_conflicts().is_empty());
}
#[test]
fn resolve_take_theirs_clears_conflict() {
let (_tmp, log, dst, src) = fixture();
let mut session =
MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
let verdicts =
session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
assert!(verdicts[0].accepted);
assert!(session.remaining_conflicts().is_empty());
}
#[test]
fn resolve_unknown_conflict_is_rejected() {
let (_tmp, log, dst, src) = fixture();
let mut session =
MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
let verdicts =
session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
assert_eq!(verdicts.len(), 1);
assert!(!verdicts[0].accepted);
assert!(matches!(
verdicts[0].rejection,
Some(ResolutionRejection::UnknownConflict { .. }),
));
}
#[test]
fn custom_op_without_two_parents_is_rejected() {
let (_tmp, log, dst, src) = fixture();
let mut session =
MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
let bad_op = Operation::new(
OperationKind::ModifyBody {
sig_id: "fn::A".into(),
from_stage_id: "stage-0".into(),
to_stage_id: "stage-X".into(),
},
[],
);
let verdicts = session.resolve(vec![(
"fn::A".into(),
Resolution::Custom { op: bad_op },
)]);
assert!(!verdicts[0].accepted);
assert!(matches!(
verdicts[0].rejection,
Some(ResolutionRejection::CustomOpMissingParents { .. }),
));
assert_eq!(session.remaining_conflicts().len(), 1);
}
#[test]
fn custom_op_with_two_parents_is_accepted() {
let (_tmp, log, dst, src) = fixture();
let mut session =
MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
let merge_op = Operation::new(
OperationKind::ModifyBody {
sig_id: "fn::A".into(),
from_stage_id: "stage-0".into(),
to_stage_id: "stage-merged".into(),
},
[src.clone(), dst.clone()],
);
let verdicts = session.resolve(vec![(
"fn::A".into(),
Resolution::Custom { op: merge_op },
)]);
assert!(verdicts[0].accepted);
assert!(session.remaining_conflicts().is_empty());
}
#[test]
fn defer_keeps_conflict_pending() {
let (_tmp, log, dst, src) = fixture();
let mut session =
MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
assert!(verdicts[0].accepted);
assert_eq!(session.remaining_conflicts().len(), 1);
}
#[test]
fn commit_with_no_conflicts_succeeds() {
let tmp = tempfile::tempdir().unwrap();
let log = OpLog::open(tmp.path()).unwrap();
let session = MergeSession::start("ms-9", &log, None, None).unwrap();
let resolved = session.commit().unwrap();
assert!(resolved.is_empty());
}
#[test]
fn commit_with_unresolved_conflict_fails() {
let (_tmp, log, dst, src) = fixture();
let session =
MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
let err = session.commit().unwrap_err();
match err {
CommitError::ConflictsRemaining(ids) => {
assert_eq!(ids, vec!["fn::A".to_string()]);
}
}
}
#[test]
fn commit_with_defer_remaining_fails() {
let (_tmp, log, dst, src) = fixture();
let mut session =
MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
let err = session.commit().unwrap_err();
match err {
CommitError::ConflictsRemaining(ids) => {
assert_eq!(ids, vec!["fn::A".to_string()]);
}
}
}
#[test]
fn commit_after_resolve_succeeds() {
let (_tmp, log, dst, src) = fixture();
let mut session =
MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
let resolved = session.commit().unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].0, "fn::A");
assert!(matches!(resolved[0].1, Resolution::TakeOurs));
}
#[test]
fn batch_resolve_accepts_partial() {
let (_tmp, log, dst, src) = fixture();
let mut session =
MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
let verdicts = session.resolve(vec![
("fn::A".into(), Resolution::TakeOurs),
("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
]);
assert_eq!(verdicts.len(), 2);
assert!(verdicts[0].accepted);
assert!(!verdicts[1].accepted);
assert!(session.remaining_conflicts().is_empty());
}
#[test]
fn auto_resolved_outcomes_are_visible() {
let tmp = tempfile::tempdir().unwrap();
let log = OpLog::open(tmp.path()).unwrap();
let r0 = OperationRecord::new(
Operation::new(
OperationKind::AddFunction {
sig_id: "fn::A".into(),
stage_id: "stage-0".into(),
effects: BTreeSet::new(),
},
[],
),
StageTransition::Create {
sig_id: "fn::A".into(),
stage_id: "stage-0".into(),
},
);
log.put(&r0).unwrap();
let session =
MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
assert!(session.remaining_conflicts().is_empty());
assert_eq!(session.auto_resolved.len(), 1);
}
}