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 in_file: None,
470 },
471 [],
472 ),
473 StageTransition::Create {
474 sig_id: "fn::A".into(),
475 stage_id: "stage-0".into(),
476 },
477 );
478 log.put(&r0).unwrap();
479
480 let r1 = OperationRecord::new(
481 Operation::new(
482 OperationKind::ModifyBody {
483 sig_id: "fn::A".into(),
484 from_stage_id: "stage-0".into(),
485 to_stage_id: "stage-1".into(),
486 from_budget: None,
487 to_budget: None,
488 to_sig_id: None,
489 },
490 [r0.op_id.clone()],
491 ),
492 StageTransition::Replace {
493 sig_id: "fn::A".into(),
494 from: "stage-0".into(),
495 to: "stage-1".into(),
496 },
497 );
498 log.put(&r1).unwrap();
499
500 let r2 = OperationRecord::new(
501 Operation::new(
502 OperationKind::ModifyBody {
503 sig_id: "fn::A".into(),
504 from_stage_id: "stage-0".into(),
505 to_stage_id: "stage-2".into(),
506 from_budget: None,
507 to_budget: None,
508 to_sig_id: None,
509 },
510 [r0.op_id.clone()],
511 ),
512 StageTransition::Replace {
513 sig_id: "fn::A".into(),
514 from: "stage-0".into(),
515 to: "stage-2".into(),
516 },
517 );
518 log.put(&r2).unwrap();
519
520 (tmp, log, r1.op_id, r2.op_id)
521 }
522
523 #[test]
524 fn start_collects_conflicts() {
525 let (_tmp, log, dst, src) = fixture();
526 let session =
527 MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
528 assert_eq!(session.remaining_conflicts().len(), 1);
529 assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
530 assert_eq!(
531 session.remaining_conflicts()[0].kind,
532 ConflictKind::ModifyModify
533 );
534 assert_eq!(
535 session.remaining_conflicts()[0].ours.as_deref(),
536 Some("stage-1"),
537 );
538 assert_eq!(
539 session.remaining_conflicts()[0].theirs.as_deref(),
540 Some("stage-2"),
541 );
542 assert_eq!(
543 session.remaining_conflicts()[0].base.as_deref(),
544 Some("stage-0"),
545 );
546 }
547
548 #[test]
549 fn no_conflicts_when_branches_dont_overlap() {
550 let tmp = tempfile::tempdir().unwrap();
551 let log = OpLog::open(tmp.path()).unwrap();
552 let r0 = OperationRecord::new(
553 Operation::new(
554 OperationKind::AddFunction {
555 sig_id: "fn::A".into(),
556 stage_id: "stage-0".into(),
557 effects: BTreeSet::new(),
558 budget_cost: None,
559 in_file: None,
560 },
561 [],
562 ),
563 StageTransition::Create {
564 sig_id: "fn::A".into(),
565 stage_id: "stage-0".into(),
566 },
567 );
568 log.put(&r0).unwrap();
569 let r1 = OperationRecord::new(
570 Operation::new(
571 OperationKind::AddFunction {
572 sig_id: "fn::B".into(),
573 stage_id: "stage-B".into(),
574 effects: BTreeSet::new(),
575 budget_cost: None,
576 in_file: None,
577 },
578 [r0.op_id.clone()],
579 ),
580 StageTransition::Create {
581 sig_id: "fn::B".into(),
582 stage_id: "stage-B".into(),
583 },
584 );
585 log.put(&r1).unwrap();
586
587 let session =
588 MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
589 assert!(session.remaining_conflicts().is_empty());
590 assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
591 }
592
593 #[test]
594 fn resolve_take_ours_clears_conflict() {
595 let (_tmp, log, dst, src) = fixture();
596 let mut session =
597 MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
598 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
599 assert_eq!(verdicts.len(), 1);
600 assert!(verdicts[0].accepted);
601 assert!(session.remaining_conflicts().is_empty());
602 }
603
604 #[test]
605 fn resolve_take_theirs_clears_conflict() {
606 let (_tmp, log, dst, src) = fixture();
607 let mut session =
608 MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
609 let verdicts =
610 session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
611 assert!(verdicts[0].accepted);
612 assert!(session.remaining_conflicts().is_empty());
613 }
614
615 #[test]
616 fn resolve_unknown_conflict_is_rejected() {
617 let (_tmp, log, dst, src) = fixture();
618 let mut session =
619 MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
620 let verdicts =
621 session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
622 assert_eq!(verdicts.len(), 1);
623 assert!(!verdicts[0].accepted);
624 assert!(matches!(
625 verdicts[0].rejection,
626 Some(ResolutionRejection::UnknownConflict { .. }),
627 ));
628 }
629
630 #[test]
631 fn custom_op_without_two_parents_is_rejected() {
632 let (_tmp, log, dst, src) = fixture();
633 let mut session =
634 MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
635 let bad_op = Operation::new(
637 OperationKind::ModifyBody {
638 sig_id: "fn::A".into(),
639 from_stage_id: "stage-0".into(),
640 to_stage_id: "stage-X".into(),
641 from_budget: None,
642 to_budget: None,
643 to_sig_id: None,
644 },
645 [],
646 );
647 let verdicts = session.resolve(vec![(
648 "fn::A".into(),
649 Resolution::Custom { op: bad_op },
650 )]);
651 assert!(!verdicts[0].accepted);
652 assert!(matches!(
653 verdicts[0].rejection,
654 Some(ResolutionRejection::CustomOpMissingParents { .. }),
655 ));
656 assert_eq!(session.remaining_conflicts().len(), 1);
659 }
660
661 #[test]
662 fn custom_op_with_two_parents_is_accepted() {
663 let (_tmp, log, dst, src) = fixture();
664 let mut session =
665 MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
666 let merge_op = Operation::new(
667 OperationKind::ModifyBody {
668 sig_id: "fn::A".into(),
669 from_stage_id: "stage-0".into(),
670 to_stage_id: "stage-merged".into(),
671 from_budget: None,
672 to_budget: None,
673 to_sig_id: None,
674 },
675 [src.clone(), dst.clone()],
676 );
677 let verdicts = session.resolve(vec![(
678 "fn::A".into(),
679 Resolution::Custom { op: merge_op },
680 )]);
681 assert!(verdicts[0].accepted);
682 assert!(session.remaining_conflicts().is_empty());
683 }
684
685 #[test]
686 fn defer_keeps_conflict_pending() {
687 let (_tmp, log, dst, src) = fixture();
688 let mut session =
689 MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
690 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
691 assert!(verdicts[0].accepted);
695 assert_eq!(session.remaining_conflicts().len(), 1);
696 }
697
698 #[test]
699 fn commit_with_no_conflicts_succeeds() {
700 let tmp = tempfile::tempdir().unwrap();
701 let log = OpLog::open(tmp.path()).unwrap();
702 let session = MergeSession::start("ms-9", &log, None, None).unwrap();
703 let resolved = session.commit().unwrap();
704 assert!(resolved.is_empty());
705 }
706
707 #[test]
708 fn commit_with_unresolved_conflict_fails() {
709 let (_tmp, log, dst, src) = fixture();
710 let session =
711 MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
712 let err = session.commit().unwrap_err();
713 match err {
714 CommitError::ConflictsRemaining(ids) => {
715 assert_eq!(ids, vec!["fn::A".to_string()]);
716 }
717 }
718 }
719
720 #[test]
721 fn commit_with_defer_remaining_fails() {
722 let (_tmp, log, dst, src) = fixture();
723 let mut session =
724 MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
725 session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
726 let err = session.commit().unwrap_err();
727 match err {
728 CommitError::ConflictsRemaining(ids) => {
729 assert_eq!(ids, vec!["fn::A".to_string()]);
730 }
731 }
732 }
733
734 #[test]
735 fn commit_after_resolve_succeeds() {
736 let (_tmp, log, dst, src) = fixture();
737 let mut session =
738 MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
739 session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
740 let resolved = session.commit().unwrap();
741 assert_eq!(resolved.len(), 1);
742 assert_eq!(resolved[0].0, "fn::A");
743 assert!(matches!(resolved[0].1, Resolution::TakeOurs));
744 }
745
746 #[test]
747 fn batch_resolve_accepts_partial() {
748 let (_tmp, log, dst, src) = fixture();
752 let mut session =
753 MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
754 let verdicts = session.resolve(vec![
755 ("fn::A".into(), Resolution::TakeOurs),
756 ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
757 ]);
758 assert_eq!(verdicts.len(), 2);
759 assert!(verdicts[0].accepted);
760 assert!(!verdicts[1].accepted);
761 assert!(session.remaining_conflicts().is_empty());
763 }
764
765 #[test]
766 fn auto_resolved_outcomes_are_visible() {
767 let tmp = tempfile::tempdir().unwrap();
768 let log = OpLog::open(tmp.path()).unwrap();
769 let r0 = OperationRecord::new(
773 Operation::new(
774 OperationKind::AddFunction {
775 sig_id: "fn::A".into(),
776 stage_id: "stage-0".into(),
777 effects: BTreeSet::new(),
778 budget_cost: None,
779 in_file: None,
780 },
781 [],
782 ),
783 StageTransition::Create {
784 sig_id: "fn::A".into(),
785 stage_id: "stage-0".into(),
786 },
787 );
788 log.put(&r0).unwrap();
789 let session =
790 MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
791 assert!(session.remaining_conflicts().is_empty());
792 assert_eq!(session.auto_resolved.len(), 1);
795 }
796
797 struct MockChecker {
805 poison_stage: &'static str,
806 seen: std::cell::RefCell<Vec<BTreeMap<SigId, Option<StageId>>>>,
807 }
808 impl MockChecker {
809 fn new(poison_stage: &'static str) -> Self {
810 Self { poison_stage, seen: std::cell::RefCell::new(Vec::new()) }
811 }
812 }
813 impl ResolutionChecker for MockChecker {
814 fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String> {
815 self.seen.borrow_mut().push(delta.clone());
816 if delta.values().any(|s| s.as_deref() == Some(self.poison_stage)) {
817 vec![format!("stage {} does not type-check", self.poison_stage)]
818 } else {
819 Vec::new()
820 }
821 }
822 }
823
824 #[test]
825 fn resolve_checked_rejects_a_resolution_that_breaks_typechecking() {
826 let (_tmp, log, dst, src) = fixture();
830 let mut session = MergeSession::start("ms-c1", &log, Some(&src), Some(&dst)).unwrap();
831 let checker = MockChecker::new("stage-2");
832
833 let verdicts = session.resolve_checked(
834 vec![("fn::A".into(), Resolution::TakeTheirs)],
835 &checker,
836 );
837 assert_eq!(verdicts.len(), 1);
838 assert!(!verdicts[0].accepted);
839 assert!(matches!(
840 verdicts[0].rejection,
841 Some(ResolutionRejection::TypeError { .. })
842 ), "expected TypeError, got {:?}", verdicts[0].rejection);
843 assert_eq!(session.remaining_conflicts().len(), 1);
845 }
846
847 #[test]
848 fn resolve_checked_accepts_a_resolution_that_composes() {
849 let (_tmp, log, dst, src) = fixture();
853 let mut session = MergeSession::start("ms-c2", &log, Some(&src), Some(&dst)).unwrap();
854 let checker = MockChecker::new("stage-2");
855
856 let verdicts = session.resolve_checked(
857 vec![("fn::A".into(), Resolution::TakeOurs)],
858 &checker,
859 );
860 assert_eq!(verdicts.len(), 1);
861 assert!(verdicts[0].accepted, "got {:?}", verdicts[0].rejection);
862 assert!(session.remaining_conflicts().is_empty());
863 assert_eq!(checker.seen.borrow().last().unwrap().len(), 0);
865 }
866
867 #[test]
868 fn resolve_checked_still_rejects_structurally_invalid_before_typechecking() {
869 let (_tmp, log, dst, src) = fixture();
872 let mut session = MergeSession::start("ms-c3", &log, Some(&src), Some(&dst)).unwrap();
873 let checker = MockChecker::new("stage-2");
874 let verdicts = session.resolve_checked(
875 vec![("fn::NOPE".into(), Resolution::TakeTheirs)],
876 &checker,
877 );
878 assert!(!verdicts[0].accepted);
879 assert!(matches!(
880 verdicts[0].rejection,
881 Some(ResolutionRejection::UnknownConflict { .. })
882 ));
883 assert!(checker.seen.borrow().is_empty(), "checker must not run on a structural reject");
884 }
885
886 #[test]
887 fn projected_delta_sets_theirs_for_take_theirs() {
888 let (_tmp, log, dst, src) = fixture();
889 let session = MergeSession::start("ms-c4", &log, Some(&src), Some(&dst)).unwrap();
890 let mut res = BTreeMap::new();
891 res.insert("fn::A".to_string(), Resolution::TakeTheirs);
892 let delta = session.projected_delta(&res);
893 assert_eq!(delta.get("fn::A"), Some(&Some("stage-2".to_string())));
894 }
895}