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