1use std::collections::BTreeMap;
43
44use serde::{Deserialize, Serialize};
45
46use crate::merge::{ConflictKind, MergeOutcome, MergeOutput};
47use crate::op_log::OpLog;
48use crate::operation::{OpId, Operation, SigId, StageId};
49
50pub type MergeSessionId = String;
55
56pub type ConflictId = SigId;
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ConflictRecord {
65 pub conflict_id: ConflictId,
66 pub sig_id: SigId,
67 pub kind: ConflictKind,
68 pub base: Option<StageId>,
71 pub ours: Option<StageId>,
74 pub theirs: Option<StageId>,
77}
78
79#[allow(clippy::large_enum_variant)]
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(tag = "kind", rename_all = "snake_case")]
88pub enum Resolution {
89 TakeOurs,
91 TakeTheirs,
93 Custom { op: Operation },
97 Defer,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(tag = "kind", rename_all = "snake_case")]
107pub enum ResolutionRejection {
108 UnknownConflict { conflict_id: ConflictId },
112 CustomOpMissingParents {
116 conflict_id: ConflictId,
117 expected: Vec<OpId>,
118 got: Vec<OpId>,
119 },
120 TypeError {
128 conflict_id: ConflictId,
129 errors: Vec<String>,
130 },
131}
132
133pub trait ResolutionChecker {
151 fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String>;
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct ResolveVerdict {
157 pub conflict_id: ConflictId,
158 pub accepted: bool,
159 pub rejection: Option<ResolutionRejection>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum CommitError {
167 ConflictsRemaining(Vec<ConflictId>),
171}
172
173#[derive(Debug, Serialize, Deserialize)]
178pub struct MergeSession {
179 pub merge_id: MergeSessionId,
180 pub src_head: Option<OpId>,
181 pub dst_head: Option<OpId>,
182 pub lca: Option<OpId>,
183 pub auto_resolved: Vec<MergeOutcome>,
187 conflicts: BTreeMap<ConflictId, ConflictRecord>,
189 resolutions: BTreeMap<ConflictId, Resolution>,
192}
193
194impl MergeSession {
195 pub fn start(
199 merge_id: impl Into<MergeSessionId>,
200 op_log: &OpLog,
201 src_head: Option<&OpId>,
202 dst_head: Option<&OpId>,
203 ) -> std::io::Result<Self> {
204 let MergeOutput { lca, outcomes } = crate::merge::merge(op_log, src_head, dst_head)?;
205 let mut auto_resolved = Vec::new();
206 let mut conflicts: BTreeMap<ConflictId, ConflictRecord> = BTreeMap::new();
207 for outcome in outcomes {
208 match outcome {
209 MergeOutcome::Conflict {
210 sig_id,
211 kind,
212 base,
213 src,
214 dst,
215 } => {
216 let conflict_id = sig_id.clone();
217 conflicts.insert(
218 conflict_id.clone(),
219 ConflictRecord {
220 conflict_id,
221 sig_id,
222 kind,
223 base,
224 ours: dst,
230 theirs: src,
231 },
232 );
233 }
234 other => auto_resolved.push(other),
235 }
236 }
237 Ok(Self {
238 merge_id: merge_id.into(),
239 src_head: src_head.cloned(),
240 dst_head: dst_head.cloned(),
241 lca,
242 auto_resolved,
243 conflicts,
244 resolutions: BTreeMap::new(),
245 })
246 }
247
248 pub fn remaining_conflicts(&self) -> Vec<&ConflictRecord> {
250 self.conflicts
251 .values()
252 .filter(|c| {
253 !matches!(self.resolutions.get(&c.conflict_id),
254 Some(Resolution::TakeOurs)
255 | Some(Resolution::TakeTheirs)
256 | Some(Resolution::Custom { .. }))
257 })
258 .collect()
259 }
260
261 pub fn resolve(
266 &mut self,
267 resolutions: Vec<(ConflictId, Resolution)>,
268 ) -> Vec<ResolveVerdict> {
269 let mut out = Vec::with_capacity(resolutions.len());
270 for (conflict_id, resolution) in resolutions {
271 match self.validate_resolution(&conflict_id, &resolution) {
272 Ok(()) => {
273 self.resolutions.insert(conflict_id.clone(), resolution);
274 out.push(ResolveVerdict {
275 conflict_id,
276 accepted: true,
277 rejection: None,
278 });
279 }
280 Err(rej) => {
281 out.push(ResolveVerdict {
282 conflict_id,
283 accepted: false,
284 rejection: Some(rej),
285 });
286 }
287 }
288 }
289 out
290 }
291
292 pub fn resolve_checked(
315 &mut self,
316 resolutions: Vec<(ConflictId, Resolution)>,
317 checker: &dyn ResolutionChecker,
318 ) -> Vec<ResolveVerdict> {
319 let mut out = Vec::with_capacity(resolutions.len());
320 for (conflict_id, resolution) in resolutions {
321 if let Err(rej) = self.validate_resolution(&conflict_id, &resolution) {
325 out.push(ResolveVerdict { conflict_id, accepted: false, rejection: Some(rej) });
326 continue;
327 }
328 let mut trial = self.resolutions.clone();
331 trial.insert(conflict_id.clone(), resolution.clone());
332 let delta = self.projected_delta(&trial);
333 let errors = checker.typecheck_projection(&delta);
334 if !errors.is_empty() {
335 out.push(ResolveVerdict {
336 conflict_id: conflict_id.clone(),
337 accepted: false,
338 rejection: Some(ResolutionRejection::TypeError { conflict_id, errors }),
339 });
340 continue;
341 }
342 self.resolutions.insert(conflict_id.clone(), resolution);
343 out.push(ResolveVerdict { conflict_id, accepted: true, rejection: None });
344 }
345 out
346 }
347
348 fn projected_delta(
362 &self,
363 resolutions: &BTreeMap<ConflictId, Resolution>,
364 ) -> BTreeMap<SigId, Option<StageId>> {
365 let mut delta: BTreeMap<SigId, Option<StageId>> = BTreeMap::new();
366 for outcome in &self.auto_resolved {
367 if let MergeOutcome::Src { sig_id, stage_id } = outcome {
368 delta.insert(sig_id.clone(), stage_id.clone());
369 }
370 }
371 for (conflict_id, record) in &self.conflicts {
372 match resolutions.get(conflict_id) {
373 Some(Resolution::TakeTheirs) => {
374 delta.insert(record.sig_id.clone(), record.theirs.clone());
375 }
376 Some(Resolution::Custom { op }) => {
377 if let Some((sig, stage)) = op.kind.merge_target() {
378 delta.insert(sig, stage);
379 }
380 }
381 _ => {}
384 }
385 }
386 delta
387 }
388
389 pub fn validate_resolution(
393 &self,
394 conflict_id: &ConflictId,
395 resolution: &Resolution,
396 ) -> Result<(), ResolutionRejection> {
397 if !self.conflicts.contains_key(conflict_id) {
398 return Err(ResolutionRejection::UnknownConflict { conflict_id: conflict_id.clone() });
399 }
400 if let Resolution::Custom { op } = resolution {
401 if op.parents.len() < 2 {
413 return Err(ResolutionRejection::CustomOpMissingParents {
414 conflict_id: conflict_id.clone(),
415 expected: vec!["ours-op-id".into(), "theirs-op-id".into()],
416 got: op.parents.clone(),
417 });
418 }
419 }
420 Ok(())
421 }
422
423 pub fn commit(self) -> Result<Vec<(ConflictId, Resolution)>, CommitError> {
429 let unresolved: Vec<ConflictId> = self
430 .conflicts
431 .keys()
432 .filter(|id| {
433 !matches!(self.resolutions.get(*id),
434 Some(Resolution::TakeOurs)
435 | Some(Resolution::TakeTheirs)
436 | Some(Resolution::Custom { .. }))
437 })
438 .cloned()
439 .collect();
440 if !unresolved.is_empty() {
441 return Err(CommitError::ConflictsRemaining(unresolved));
442 }
443 let mut resolved: Vec<(ConflictId, Resolution)> = self.resolutions.into_iter().collect();
444 resolved.sort_by(|a, b| a.0.cmp(&b.0));
445 Ok(resolved)
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::operation::{OperationKind, OperationRecord, StageTransition};
453 use std::collections::BTreeSet;
454
455 fn fixture() -> (tempfile::TempDir, OpLog, OpId, OpId) {
460 let tmp = tempfile::tempdir().unwrap();
461 let log = OpLog::open(tmp.path()).unwrap();
462 let r0 = OperationRecord::new(
463 Operation::new(
464 OperationKind::AddFunction {
465 sig_id: "fn::A".into(),
466 stage_id: "stage-0".into(),
467 effects: BTreeSet::new(),
468 budget_cost: None,
469 },
470 [],
471 ),
472 StageTransition::Create {
473 sig_id: "fn::A".into(),
474 stage_id: "stage-0".into(),
475 },
476 );
477 log.put(&r0).unwrap();
478
479 let r1 = OperationRecord::new(
480 Operation::new(
481 OperationKind::ModifyBody {
482 sig_id: "fn::A".into(),
483 from_stage_id: "stage-0".into(),
484 to_stage_id: "stage-1".into(),
485 from_budget: None,
486 to_budget: None,
487 },
488 [r0.op_id.clone()],
489 ),
490 StageTransition::Replace {
491 sig_id: "fn::A".into(),
492 from: "stage-0".into(),
493 to: "stage-1".into(),
494 },
495 );
496 log.put(&r1).unwrap();
497
498 let r2 = OperationRecord::new(
499 Operation::new(
500 OperationKind::ModifyBody {
501 sig_id: "fn::A".into(),
502 from_stage_id: "stage-0".into(),
503 to_stage_id: "stage-2".into(),
504 from_budget: None,
505 to_budget: None,
506 },
507 [r0.op_id.clone()],
508 ),
509 StageTransition::Replace {
510 sig_id: "fn::A".into(),
511 from: "stage-0".into(),
512 to: "stage-2".into(),
513 },
514 );
515 log.put(&r2).unwrap();
516
517 (tmp, log, r1.op_id, r2.op_id)
518 }
519
520 #[test]
521 fn start_collects_conflicts() {
522 let (_tmp, log, dst, src) = fixture();
523 let session =
524 MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
525 assert_eq!(session.remaining_conflicts().len(), 1);
526 assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
527 assert_eq!(
528 session.remaining_conflicts()[0].kind,
529 ConflictKind::ModifyModify
530 );
531 assert_eq!(
532 session.remaining_conflicts()[0].ours.as_deref(),
533 Some("stage-1"),
534 );
535 assert_eq!(
536 session.remaining_conflicts()[0].theirs.as_deref(),
537 Some("stage-2"),
538 );
539 assert_eq!(
540 session.remaining_conflicts()[0].base.as_deref(),
541 Some("stage-0"),
542 );
543 }
544
545 #[test]
546 fn no_conflicts_when_branches_dont_overlap() {
547 let tmp = tempfile::tempdir().unwrap();
548 let log = OpLog::open(tmp.path()).unwrap();
549 let r0 = OperationRecord::new(
550 Operation::new(
551 OperationKind::AddFunction {
552 sig_id: "fn::A".into(),
553 stage_id: "stage-0".into(),
554 effects: BTreeSet::new(),
555 budget_cost: None,
556 },
557 [],
558 ),
559 StageTransition::Create {
560 sig_id: "fn::A".into(),
561 stage_id: "stage-0".into(),
562 },
563 );
564 log.put(&r0).unwrap();
565 let r1 = OperationRecord::new(
566 Operation::new(
567 OperationKind::AddFunction {
568 sig_id: "fn::B".into(),
569 stage_id: "stage-B".into(),
570 effects: BTreeSet::new(),
571 budget_cost: None,
572 },
573 [r0.op_id.clone()],
574 ),
575 StageTransition::Create {
576 sig_id: "fn::B".into(),
577 stage_id: "stage-B".into(),
578 },
579 );
580 log.put(&r1).unwrap();
581
582 let session =
583 MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
584 assert!(session.remaining_conflicts().is_empty());
585 assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
586 }
587
588 #[test]
589 fn resolve_take_ours_clears_conflict() {
590 let (_tmp, log, dst, src) = fixture();
591 let mut session =
592 MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
593 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
594 assert_eq!(verdicts.len(), 1);
595 assert!(verdicts[0].accepted);
596 assert!(session.remaining_conflicts().is_empty());
597 }
598
599 #[test]
600 fn resolve_take_theirs_clears_conflict() {
601 let (_tmp, log, dst, src) = fixture();
602 let mut session =
603 MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
604 let verdicts =
605 session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
606 assert!(verdicts[0].accepted);
607 assert!(session.remaining_conflicts().is_empty());
608 }
609
610 #[test]
611 fn resolve_unknown_conflict_is_rejected() {
612 let (_tmp, log, dst, src) = fixture();
613 let mut session =
614 MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
615 let verdicts =
616 session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
617 assert_eq!(verdicts.len(), 1);
618 assert!(!verdicts[0].accepted);
619 assert!(matches!(
620 verdicts[0].rejection,
621 Some(ResolutionRejection::UnknownConflict { .. }),
622 ));
623 }
624
625 #[test]
626 fn custom_op_without_two_parents_is_rejected() {
627 let (_tmp, log, dst, src) = fixture();
628 let mut session =
629 MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
630 let bad_op = Operation::new(
632 OperationKind::ModifyBody {
633 sig_id: "fn::A".into(),
634 from_stage_id: "stage-0".into(),
635 to_stage_id: "stage-X".into(),
636 from_budget: None,
637 to_budget: None,
638 },
639 [],
640 );
641 let verdicts = session.resolve(vec![(
642 "fn::A".into(),
643 Resolution::Custom { op: bad_op },
644 )]);
645 assert!(!verdicts[0].accepted);
646 assert!(matches!(
647 verdicts[0].rejection,
648 Some(ResolutionRejection::CustomOpMissingParents { .. }),
649 ));
650 assert_eq!(session.remaining_conflicts().len(), 1);
653 }
654
655 #[test]
656 fn custom_op_with_two_parents_is_accepted() {
657 let (_tmp, log, dst, src) = fixture();
658 let mut session =
659 MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
660 let merge_op = Operation::new(
661 OperationKind::ModifyBody {
662 sig_id: "fn::A".into(),
663 from_stage_id: "stage-0".into(),
664 to_stage_id: "stage-merged".into(),
665 from_budget: None,
666 to_budget: None,
667 },
668 [src.clone(), dst.clone()],
669 );
670 let verdicts = session.resolve(vec![(
671 "fn::A".into(),
672 Resolution::Custom { op: merge_op },
673 )]);
674 assert!(verdicts[0].accepted);
675 assert!(session.remaining_conflicts().is_empty());
676 }
677
678 #[test]
679 fn defer_keeps_conflict_pending() {
680 let (_tmp, log, dst, src) = fixture();
681 let mut session =
682 MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
683 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
684 assert!(verdicts[0].accepted);
688 assert_eq!(session.remaining_conflicts().len(), 1);
689 }
690
691 #[test]
692 fn commit_with_no_conflicts_succeeds() {
693 let tmp = tempfile::tempdir().unwrap();
694 let log = OpLog::open(tmp.path()).unwrap();
695 let session = MergeSession::start("ms-9", &log, None, None).unwrap();
696 let resolved = session.commit().unwrap();
697 assert!(resolved.is_empty());
698 }
699
700 #[test]
701 fn commit_with_unresolved_conflict_fails() {
702 let (_tmp, log, dst, src) = fixture();
703 let session =
704 MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
705 let err = session.commit().unwrap_err();
706 match err {
707 CommitError::ConflictsRemaining(ids) => {
708 assert_eq!(ids, vec!["fn::A".to_string()]);
709 }
710 }
711 }
712
713 #[test]
714 fn commit_with_defer_remaining_fails() {
715 let (_tmp, log, dst, src) = fixture();
716 let mut session =
717 MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
718 session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
719 let err = session.commit().unwrap_err();
720 match err {
721 CommitError::ConflictsRemaining(ids) => {
722 assert_eq!(ids, vec!["fn::A".to_string()]);
723 }
724 }
725 }
726
727 #[test]
728 fn commit_after_resolve_succeeds() {
729 let (_tmp, log, dst, src) = fixture();
730 let mut session =
731 MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
732 session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
733 let resolved = session.commit().unwrap();
734 assert_eq!(resolved.len(), 1);
735 assert_eq!(resolved[0].0, "fn::A");
736 assert!(matches!(resolved[0].1, Resolution::TakeOurs));
737 }
738
739 #[test]
740 fn batch_resolve_accepts_partial() {
741 let (_tmp, log, dst, src) = fixture();
745 let mut session =
746 MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
747 let verdicts = session.resolve(vec![
748 ("fn::A".into(), Resolution::TakeOurs),
749 ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
750 ]);
751 assert_eq!(verdicts.len(), 2);
752 assert!(verdicts[0].accepted);
753 assert!(!verdicts[1].accepted);
754 assert!(session.remaining_conflicts().is_empty());
756 }
757
758 #[test]
759 fn auto_resolved_outcomes_are_visible() {
760 let tmp = tempfile::tempdir().unwrap();
761 let log = OpLog::open(tmp.path()).unwrap();
762 let r0 = OperationRecord::new(
766 Operation::new(
767 OperationKind::AddFunction {
768 sig_id: "fn::A".into(),
769 stage_id: "stage-0".into(),
770 effects: BTreeSet::new(),
771 budget_cost: None,
772 },
773 [],
774 ),
775 StageTransition::Create {
776 sig_id: "fn::A".into(),
777 stage_id: "stage-0".into(),
778 },
779 );
780 log.put(&r0).unwrap();
781 let session =
782 MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
783 assert!(session.remaining_conflicts().is_empty());
784 assert_eq!(session.auto_resolved.len(), 1);
787 }
788
789 struct MockChecker {
797 poison_stage: &'static str,
798 seen: std::cell::RefCell<Vec<BTreeMap<SigId, Option<StageId>>>>,
799 }
800 impl MockChecker {
801 fn new(poison_stage: &'static str) -> Self {
802 Self { poison_stage, seen: std::cell::RefCell::new(Vec::new()) }
803 }
804 }
805 impl ResolutionChecker for MockChecker {
806 fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String> {
807 self.seen.borrow_mut().push(delta.clone());
808 if delta.values().any(|s| s.as_deref() == Some(self.poison_stage)) {
809 vec![format!("stage {} does not type-check", self.poison_stage)]
810 } else {
811 Vec::new()
812 }
813 }
814 }
815
816 #[test]
817 fn resolve_checked_rejects_a_resolution_that_breaks_typechecking() {
818 let (_tmp, log, dst, src) = fixture();
822 let mut session = MergeSession::start("ms-c1", &log, Some(&src), Some(&dst)).unwrap();
823 let checker = MockChecker::new("stage-2");
824
825 let verdicts = session.resolve_checked(
826 vec![("fn::A".into(), Resolution::TakeTheirs)],
827 &checker,
828 );
829 assert_eq!(verdicts.len(), 1);
830 assert!(!verdicts[0].accepted);
831 assert!(matches!(
832 verdicts[0].rejection,
833 Some(ResolutionRejection::TypeError { .. })
834 ), "expected TypeError, got {:?}", verdicts[0].rejection);
835 assert_eq!(session.remaining_conflicts().len(), 1);
837 }
838
839 #[test]
840 fn resolve_checked_accepts_a_resolution_that_composes() {
841 let (_tmp, log, dst, src) = fixture();
845 let mut session = MergeSession::start("ms-c2", &log, Some(&src), Some(&dst)).unwrap();
846 let checker = MockChecker::new("stage-2");
847
848 let verdicts = session.resolve_checked(
849 vec![("fn::A".into(), Resolution::TakeOurs)],
850 &checker,
851 );
852 assert_eq!(verdicts.len(), 1);
853 assert!(verdicts[0].accepted, "got {:?}", verdicts[0].rejection);
854 assert!(session.remaining_conflicts().is_empty());
855 assert_eq!(checker.seen.borrow().last().unwrap().len(), 0);
857 }
858
859 #[test]
860 fn resolve_checked_still_rejects_structurally_invalid_before_typechecking() {
861 let (_tmp, log, dst, src) = fixture();
864 let mut session = MergeSession::start("ms-c3", &log, Some(&src), Some(&dst)).unwrap();
865 let checker = MockChecker::new("stage-2");
866 let verdicts = session.resolve_checked(
867 vec![("fn::NOPE".into(), Resolution::TakeTheirs)],
868 &checker,
869 );
870 assert!(!verdicts[0].accepted);
871 assert!(matches!(
872 verdicts[0].rejection,
873 Some(ResolutionRejection::UnknownConflict { .. })
874 ));
875 assert!(checker.seen.borrow().is_empty(), "checker must not run on a structural reject");
876 }
877
878 #[test]
879 fn projected_delta_sets_theirs_for_take_theirs() {
880 let (_tmp, log, dst, src) = fixture();
881 let session = MergeSession::start("ms-c4", &log, Some(&src), Some(&dst)).unwrap();
882 let mut res = BTreeMap::new();
883 res.insert("fn::A".to_string(), Resolution::TakeTheirs);
884 let delta = session.projected_delta(&res);
885 assert_eq!(delta.get("fn::A"), Some(&Some("stage-2".to_string())));
886 }
887}