1use std::collections::BTreeMap;
43
44use serde::{Deserialize, Serialize};
45
46use crate::merge::{ConflictKind, MergeOutcome, MergeOutput};
47use crate::op_log::OpLog;
48use crate::operation::{BlobId, 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
79pub type FilePath = String;
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct FileEntry {
103 pub blob: BlobId,
104 pub mode: String,
105 pub size: u64,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct FileConflict {
121 pub path: FilePath,
122 pub base: Option<FileEntry>,
126 pub ours: Option<FileEntry>,
129 pub theirs: Option<FileEntry>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(tag = "kind", rename_all = "snake_case")]
139pub enum FileResolution {
140 TakeOurs,
142 TakeTheirs,
144 Defer,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(tag = "kind", rename_all = "snake_case")]
153pub enum FileResolutionRejection {
154 UnknownConflict { path: FilePath },
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct FileResolveVerdict {
162 pub path: FilePath,
163 pub accepted: bool,
164 pub rejection: Option<FileResolutionRejection>,
165}
166
167#[allow(clippy::large_enum_variant)]
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(tag = "kind", rename_all = "snake_case")]
176pub enum Resolution {
177 TakeOurs,
179 TakeTheirs,
181 Custom { op: Operation },
185 Defer,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(tag = "kind", rename_all = "snake_case")]
195pub enum ResolutionRejection {
196 UnknownConflict { conflict_id: ConflictId },
200 CustomOpMissingParents {
204 conflict_id: ConflictId,
205 expected: Vec<OpId>,
206 got: Vec<OpId>,
207 },
208 TypeError {
216 conflict_id: ConflictId,
217 errors: Vec<String>,
218 },
219}
220
221pub trait ResolutionChecker {
239 fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String>;
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct ResolveVerdict {
245 pub conflict_id: ConflictId,
246 pub accepted: bool,
247 pub rejection: Option<ResolutionRejection>,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum CommitError {
255 ConflictsRemaining(Vec<ConflictId>),
260 FileConflictsRemaining(Vec<FilePath>),
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct MergeCommitOutput {
272 pub resolved: Vec<(ConflictId, Resolution)>,
273 pub resolved_files: Vec<(FilePath, FileResolution)>,
274 pub needs_setfiles: bool,
280}
281
282#[derive(Debug, Serialize, Deserialize)]
287pub struct MergeSession {
288 pub merge_id: MergeSessionId,
289 pub src_head: Option<OpId>,
290 pub dst_head: Option<OpId>,
291 pub lca: Option<OpId>,
292 pub auto_resolved: Vec<MergeOutcome>,
296 conflicts: BTreeMap<ConflictId, ConflictRecord>,
298 resolutions: BTreeMap<ConflictId, Resolution>,
301 #[serde(default)]
306 file_conflicts: BTreeMap<FilePath, FileConflict>,
307 #[serde(default)]
309 file_resolutions: BTreeMap<FilePath, FileResolution>,
310 #[serde(default)]
313 needs_setfiles: bool,
314}
315
316impl MergeSession {
317 pub fn start(
321 merge_id: impl Into<MergeSessionId>,
322 op_log: &OpLog,
323 src_head: Option<&OpId>,
324 dst_head: Option<&OpId>,
325 ) -> std::io::Result<Self> {
326 let MergeOutput { lca, outcomes } = crate::merge::merge(op_log, src_head, dst_head)?;
327 let mut auto_resolved = Vec::new();
328 let mut conflicts: BTreeMap<ConflictId, ConflictRecord> = BTreeMap::new();
329 for outcome in outcomes {
330 match outcome {
331 MergeOutcome::Conflict {
332 sig_id,
333 kind,
334 base,
335 src,
336 dst,
337 } => {
338 let conflict_id = sig_id.clone();
339 conflicts.insert(
340 conflict_id.clone(),
341 ConflictRecord {
342 conflict_id,
343 sig_id,
344 kind,
345 base,
346 ours: dst,
352 theirs: src,
353 },
354 );
355 }
356 other => auto_resolved.push(other),
357 }
358 }
359 Ok(Self {
360 merge_id: merge_id.into(),
361 src_head: src_head.cloned(),
362 dst_head: dst_head.cloned(),
363 lca,
364 auto_resolved,
365 conflicts,
366 resolutions: BTreeMap::new(),
367 file_conflicts: BTreeMap::new(),
368 file_resolutions: BTreeMap::new(),
369 needs_setfiles: false,
370 })
371 }
372
373 pub fn attach_file_conflicts(&mut self, conflicts: Vec<FileConflict>, needs_setfiles: bool) {
384 self.file_conflicts = conflicts.into_iter().map(|c| (c.path.clone(), c)).collect();
385 self.needs_setfiles = needs_setfiles;
386 }
387
388 pub fn needs_setfiles(&self) -> bool {
391 self.needs_setfiles
392 }
393
394 pub fn remaining_file_conflicts(&self) -> Vec<&FileConflict> {
397 self.file_conflicts
398 .values()
399 .filter(|c| {
400 !matches!(
401 self.file_resolutions.get(&c.path),
402 Some(FileResolution::TakeOurs) | Some(FileResolution::TakeTheirs)
403 )
404 })
405 .collect()
406 }
407
408 pub fn resolve_files(
413 &mut self,
414 resolutions: Vec<(FilePath, FileResolution)>,
415 ) -> Vec<FileResolveVerdict> {
416 let mut out = Vec::with_capacity(resolutions.len());
417 for (path, resolution) in resolutions {
418 if !self.file_conflicts.contains_key(&path) {
419 out.push(FileResolveVerdict {
420 path: path.clone(),
421 accepted: false,
422 rejection: Some(FileResolutionRejection::UnknownConflict { path }),
423 });
424 continue;
425 }
426 self.file_resolutions.insert(path.clone(), resolution);
427 out.push(FileResolveVerdict { path, accepted: true, rejection: None });
428 }
429 out
430 }
431
432 pub fn remaining_conflicts(&self) -> Vec<&ConflictRecord> {
434 self.conflicts
435 .values()
436 .filter(|c| {
437 !matches!(self.resolutions.get(&c.conflict_id),
438 Some(Resolution::TakeOurs)
439 | Some(Resolution::TakeTheirs)
440 | Some(Resolution::Custom { .. }))
441 })
442 .collect()
443 }
444
445 pub fn resolve(
450 &mut self,
451 resolutions: Vec<(ConflictId, Resolution)>,
452 ) -> Vec<ResolveVerdict> {
453 let mut out = Vec::with_capacity(resolutions.len());
454 for (conflict_id, resolution) in resolutions {
455 match self.validate_resolution(&conflict_id, &resolution) {
456 Ok(()) => {
457 self.resolutions.insert(conflict_id.clone(), resolution);
458 out.push(ResolveVerdict {
459 conflict_id,
460 accepted: true,
461 rejection: None,
462 });
463 }
464 Err(rej) => {
465 out.push(ResolveVerdict {
466 conflict_id,
467 accepted: false,
468 rejection: Some(rej),
469 });
470 }
471 }
472 }
473 out
474 }
475
476 pub fn resolve_checked(
499 &mut self,
500 resolutions: Vec<(ConflictId, Resolution)>,
501 checker: &dyn ResolutionChecker,
502 ) -> Vec<ResolveVerdict> {
503 let mut out = Vec::with_capacity(resolutions.len());
504 for (conflict_id, resolution) in resolutions {
505 if let Err(rej) = self.validate_resolution(&conflict_id, &resolution) {
509 out.push(ResolveVerdict { conflict_id, accepted: false, rejection: Some(rej) });
510 continue;
511 }
512 let mut trial = self.resolutions.clone();
515 trial.insert(conflict_id.clone(), resolution.clone());
516 let delta = self.projected_delta(&trial);
517 let errors = checker.typecheck_projection(&delta);
518 if !errors.is_empty() {
519 out.push(ResolveVerdict {
520 conflict_id: conflict_id.clone(),
521 accepted: false,
522 rejection: Some(ResolutionRejection::TypeError { conflict_id, errors }),
523 });
524 continue;
525 }
526 self.resolutions.insert(conflict_id.clone(), resolution);
527 out.push(ResolveVerdict { conflict_id, accepted: true, rejection: None });
528 }
529 out
530 }
531
532 fn projected_delta(
546 &self,
547 resolutions: &BTreeMap<ConflictId, Resolution>,
548 ) -> BTreeMap<SigId, Option<StageId>> {
549 let mut delta: BTreeMap<SigId, Option<StageId>> = BTreeMap::new();
550 for outcome in &self.auto_resolved {
551 if let MergeOutcome::Src { sig_id, stage_id } = outcome {
552 delta.insert(sig_id.clone(), stage_id.clone());
553 }
554 }
555 for (conflict_id, record) in &self.conflicts {
556 match resolutions.get(conflict_id) {
557 Some(Resolution::TakeTheirs) => {
558 delta.insert(record.sig_id.clone(), record.theirs.clone());
559 }
560 Some(Resolution::Custom { op }) => {
561 if let Some((sig, stage)) = op.kind.merge_target() {
562 delta.insert(sig, stage);
563 }
564 }
565 _ => {}
568 }
569 }
570 delta
571 }
572
573 pub fn validate_resolution(
577 &self,
578 conflict_id: &ConflictId,
579 resolution: &Resolution,
580 ) -> Result<(), ResolutionRejection> {
581 if !self.conflicts.contains_key(conflict_id) {
582 return Err(ResolutionRejection::UnknownConflict { conflict_id: conflict_id.clone() });
583 }
584 if let Resolution::Custom { op } = resolution {
585 if op.parents.len() < 2 {
597 return Err(ResolutionRejection::CustomOpMissingParents {
598 conflict_id: conflict_id.clone(),
599 expected: vec!["ours-op-id".into(), "theirs-op-id".into()],
600 got: op.parents.clone(),
601 });
602 }
603 }
604 Ok(())
605 }
606
607 pub fn commit(self) -> Result<MergeCommitOutput, CommitError> {
621 let unresolved: Vec<ConflictId> = self
622 .conflicts
623 .keys()
624 .filter(|id| {
625 !matches!(self.resolutions.get(*id),
626 Some(Resolution::TakeOurs)
627 | Some(Resolution::TakeTheirs)
628 | Some(Resolution::Custom { .. }))
629 })
630 .cloned()
631 .collect();
632 if !unresolved.is_empty() {
633 return Err(CommitError::ConflictsRemaining(unresolved));
634 }
635 let unresolved_files: Vec<FilePath> = self
636 .file_conflicts
637 .keys()
638 .filter(|path| {
639 !matches!(
640 self.file_resolutions.get(*path),
641 Some(FileResolution::TakeOurs) | Some(FileResolution::TakeTheirs)
642 )
643 })
644 .cloned()
645 .collect();
646 if !unresolved_files.is_empty() {
647 return Err(CommitError::FileConflictsRemaining(unresolved_files));
648 }
649 let mut resolved: Vec<(ConflictId, Resolution)> = self.resolutions.into_iter().collect();
650 resolved.sort_by(|a, b| a.0.cmp(&b.0));
651 let mut resolved_files: Vec<(FilePath, FileResolution)> =
652 self.file_resolutions.into_iter().collect();
653 resolved_files.sort_by(|a, b| a.0.cmp(&b.0));
654 Ok(MergeCommitOutput { resolved, resolved_files, needs_setfiles: self.needs_setfiles })
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::operation::{OperationKind, OperationRecord, StageTransition};
662 use std::collections::BTreeSet;
663
664 fn fixture() -> (tempfile::TempDir, OpLog, OpId, OpId) {
669 let tmp = tempfile::tempdir().unwrap();
670 let log = OpLog::open(tmp.path()).unwrap();
671 let r0 = OperationRecord::new(
672 Operation::new(
673 OperationKind::AddFunction {
674 sig_id: "fn::A".into(),
675 stage_id: "stage-0".into(),
676 effects: BTreeSet::new(),
677 budget_cost: None,
678 in_file: None,
679 },
680 [],
681 ),
682 StageTransition::Create {
683 sig_id: "fn::A".into(),
684 stage_id: "stage-0".into(),
685 },
686 );
687 log.put(&r0).unwrap();
688
689 let r1 = OperationRecord::new(
690 Operation::new(
691 OperationKind::ModifyBody {
692 sig_id: "fn::A".into(),
693 from_stage_id: "stage-0".into(),
694 to_stage_id: "stage-1".into(),
695 from_budget: None,
696 to_budget: None,
697 to_sig_id: None,
698 },
699 [r0.op_id.clone()],
700 ),
701 StageTransition::Replace {
702 sig_id: "fn::A".into(),
703 from: "stage-0".into(),
704 to: "stage-1".into(),
705 },
706 );
707 log.put(&r1).unwrap();
708
709 let r2 = OperationRecord::new(
710 Operation::new(
711 OperationKind::ModifyBody {
712 sig_id: "fn::A".into(),
713 from_stage_id: "stage-0".into(),
714 to_stage_id: "stage-2".into(),
715 from_budget: None,
716 to_budget: None,
717 to_sig_id: None,
718 },
719 [r0.op_id.clone()],
720 ),
721 StageTransition::Replace {
722 sig_id: "fn::A".into(),
723 from: "stage-0".into(),
724 to: "stage-2".into(),
725 },
726 );
727 log.put(&r2).unwrap();
728
729 (tmp, log, r1.op_id, r2.op_id)
730 }
731
732 #[test]
733 fn start_collects_conflicts() {
734 let (_tmp, log, dst, src) = fixture();
735 let session =
736 MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
737 assert_eq!(session.remaining_conflicts().len(), 1);
738 assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
739 assert_eq!(
740 session.remaining_conflicts()[0].kind,
741 ConflictKind::ModifyModify
742 );
743 assert_eq!(
744 session.remaining_conflicts()[0].ours.as_deref(),
745 Some("stage-1"),
746 );
747 assert_eq!(
748 session.remaining_conflicts()[0].theirs.as_deref(),
749 Some("stage-2"),
750 );
751 assert_eq!(
752 session.remaining_conflicts()[0].base.as_deref(),
753 Some("stage-0"),
754 );
755 }
756
757 #[test]
758 fn no_conflicts_when_branches_dont_overlap() {
759 let tmp = tempfile::tempdir().unwrap();
760 let log = OpLog::open(tmp.path()).unwrap();
761 let r0 = OperationRecord::new(
762 Operation::new(
763 OperationKind::AddFunction {
764 sig_id: "fn::A".into(),
765 stage_id: "stage-0".into(),
766 effects: BTreeSet::new(),
767 budget_cost: None,
768 in_file: None,
769 },
770 [],
771 ),
772 StageTransition::Create {
773 sig_id: "fn::A".into(),
774 stage_id: "stage-0".into(),
775 },
776 );
777 log.put(&r0).unwrap();
778 let r1 = OperationRecord::new(
779 Operation::new(
780 OperationKind::AddFunction {
781 sig_id: "fn::B".into(),
782 stage_id: "stage-B".into(),
783 effects: BTreeSet::new(),
784 budget_cost: None,
785 in_file: None,
786 },
787 [r0.op_id.clone()],
788 ),
789 StageTransition::Create {
790 sig_id: "fn::B".into(),
791 stage_id: "stage-B".into(),
792 },
793 );
794 log.put(&r1).unwrap();
795
796 let session =
797 MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
798 assert!(session.remaining_conflicts().is_empty());
799 assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
800 }
801
802 #[test]
803 fn resolve_take_ours_clears_conflict() {
804 let (_tmp, log, dst, src) = fixture();
805 let mut session =
806 MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
807 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
808 assert_eq!(verdicts.len(), 1);
809 assert!(verdicts[0].accepted);
810 assert!(session.remaining_conflicts().is_empty());
811 }
812
813 #[test]
814 fn resolve_take_theirs_clears_conflict() {
815 let (_tmp, log, dst, src) = fixture();
816 let mut session =
817 MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
818 let verdicts =
819 session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
820 assert!(verdicts[0].accepted);
821 assert!(session.remaining_conflicts().is_empty());
822 }
823
824 #[test]
825 fn resolve_unknown_conflict_is_rejected() {
826 let (_tmp, log, dst, src) = fixture();
827 let mut session =
828 MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
829 let verdicts =
830 session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
831 assert_eq!(verdicts.len(), 1);
832 assert!(!verdicts[0].accepted);
833 assert!(matches!(
834 verdicts[0].rejection,
835 Some(ResolutionRejection::UnknownConflict { .. }),
836 ));
837 }
838
839 #[test]
840 fn custom_op_without_two_parents_is_rejected() {
841 let (_tmp, log, dst, src) = fixture();
842 let mut session =
843 MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
844 let bad_op = Operation::new(
846 OperationKind::ModifyBody {
847 sig_id: "fn::A".into(),
848 from_stage_id: "stage-0".into(),
849 to_stage_id: "stage-X".into(),
850 from_budget: None,
851 to_budget: None,
852 to_sig_id: None,
853 },
854 [],
855 );
856 let verdicts = session.resolve(vec![(
857 "fn::A".into(),
858 Resolution::Custom { op: bad_op },
859 )]);
860 assert!(!verdicts[0].accepted);
861 assert!(matches!(
862 verdicts[0].rejection,
863 Some(ResolutionRejection::CustomOpMissingParents { .. }),
864 ));
865 assert_eq!(session.remaining_conflicts().len(), 1);
868 }
869
870 #[test]
871 fn custom_op_with_two_parents_is_accepted() {
872 let (_tmp, log, dst, src) = fixture();
873 let mut session =
874 MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
875 let merge_op = Operation::new(
876 OperationKind::ModifyBody {
877 sig_id: "fn::A".into(),
878 from_stage_id: "stage-0".into(),
879 to_stage_id: "stage-merged".into(),
880 from_budget: None,
881 to_budget: None,
882 to_sig_id: None,
883 },
884 [src.clone(), dst.clone()],
885 );
886 let verdicts = session.resolve(vec![(
887 "fn::A".into(),
888 Resolution::Custom { op: merge_op },
889 )]);
890 assert!(verdicts[0].accepted);
891 assert!(session.remaining_conflicts().is_empty());
892 }
893
894 #[test]
895 fn defer_keeps_conflict_pending() {
896 let (_tmp, log, dst, src) = fixture();
897 let mut session =
898 MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
899 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
900 assert!(verdicts[0].accepted);
904 assert_eq!(session.remaining_conflicts().len(), 1);
905 }
906
907 #[test]
908 fn commit_with_no_conflicts_succeeds() {
909 let tmp = tempfile::tempdir().unwrap();
910 let log = OpLog::open(tmp.path()).unwrap();
911 let session = MergeSession::start("ms-9", &log, None, None).unwrap();
912 let out = session.commit().unwrap();
913 assert!(out.resolved.is_empty());
914 assert!(out.resolved_files.is_empty());
915 assert!(!out.needs_setfiles);
916 }
917
918 #[test]
919 fn commit_with_unresolved_conflict_fails() {
920 let (_tmp, log, dst, src) = fixture();
921 let session =
922 MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
923 let err = session.commit().unwrap_err();
924 match err {
925 CommitError::ConflictsRemaining(ids) => {
926 assert_eq!(ids, vec!["fn::A".to_string()]);
927 }
928 other => panic!("expected ConflictsRemaining, got {other:?}"),
929 }
930 }
931
932 #[test]
933 fn commit_with_defer_remaining_fails() {
934 let (_tmp, log, dst, src) = fixture();
935 let mut session =
936 MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
937 session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
938 let err = session.commit().unwrap_err();
939 match err {
940 CommitError::ConflictsRemaining(ids) => {
941 assert_eq!(ids, vec!["fn::A".to_string()]);
942 }
943 other => panic!("expected ConflictsRemaining, got {other:?}"),
944 }
945 }
946
947 #[test]
948 fn commit_after_resolve_succeeds() {
949 let (_tmp, log, dst, src) = fixture();
950 let mut session =
951 MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
952 session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
953 let out = session.commit().unwrap();
954 assert_eq!(out.resolved.len(), 1);
955 assert_eq!(out.resolved[0].0, "fn::A");
956 assert!(matches!(out.resolved[0].1, Resolution::TakeOurs));
957 assert!(out.resolved_files.is_empty());
958 }
959
960 #[test]
961 fn batch_resolve_accepts_partial() {
962 let (_tmp, log, dst, src) = fixture();
966 let mut session =
967 MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
968 let verdicts = session.resolve(vec![
969 ("fn::A".into(), Resolution::TakeOurs),
970 ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
971 ]);
972 assert_eq!(verdicts.len(), 2);
973 assert!(verdicts[0].accepted);
974 assert!(!verdicts[1].accepted);
975 assert!(session.remaining_conflicts().is_empty());
977 }
978
979 #[test]
980 fn auto_resolved_outcomes_are_visible() {
981 let tmp = tempfile::tempdir().unwrap();
982 let log = OpLog::open(tmp.path()).unwrap();
983 let r0 = OperationRecord::new(
987 Operation::new(
988 OperationKind::AddFunction {
989 sig_id: "fn::A".into(),
990 stage_id: "stage-0".into(),
991 effects: BTreeSet::new(),
992 budget_cost: None,
993 in_file: None,
994 },
995 [],
996 ),
997 StageTransition::Create {
998 sig_id: "fn::A".into(),
999 stage_id: "stage-0".into(),
1000 },
1001 );
1002 log.put(&r0).unwrap();
1003 let session =
1004 MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
1005 assert!(session.remaining_conflicts().is_empty());
1006 assert_eq!(session.auto_resolved.len(), 1);
1009 }
1010
1011 struct MockChecker {
1019 poison_stage: &'static str,
1020 seen: std::cell::RefCell<Vec<BTreeMap<SigId, Option<StageId>>>>,
1021 }
1022 impl MockChecker {
1023 fn new(poison_stage: &'static str) -> Self {
1024 Self { poison_stage, seen: std::cell::RefCell::new(Vec::new()) }
1025 }
1026 }
1027 impl ResolutionChecker for MockChecker {
1028 fn typecheck_projection(&self, delta: &BTreeMap<SigId, Option<StageId>>) -> Vec<String> {
1029 self.seen.borrow_mut().push(delta.clone());
1030 if delta.values().any(|s| s.as_deref() == Some(self.poison_stage)) {
1031 vec![format!("stage {} does not type-check", self.poison_stage)]
1032 } else {
1033 Vec::new()
1034 }
1035 }
1036 }
1037
1038 #[test]
1039 fn resolve_checked_rejects_a_resolution_that_breaks_typechecking() {
1040 let (_tmp, log, dst, src) = fixture();
1044 let mut session = MergeSession::start("ms-c1", &log, Some(&src), Some(&dst)).unwrap();
1045 let checker = MockChecker::new("stage-2");
1046
1047 let verdicts = session.resolve_checked(
1048 vec![("fn::A".into(), Resolution::TakeTheirs)],
1049 &checker,
1050 );
1051 assert_eq!(verdicts.len(), 1);
1052 assert!(!verdicts[0].accepted);
1053 assert!(matches!(
1054 verdicts[0].rejection,
1055 Some(ResolutionRejection::TypeError { .. })
1056 ), "expected TypeError, got {:?}", verdicts[0].rejection);
1057 assert_eq!(session.remaining_conflicts().len(), 1);
1059 }
1060
1061 #[test]
1062 fn resolve_checked_accepts_a_resolution_that_composes() {
1063 let (_tmp, log, dst, src) = fixture();
1067 let mut session = MergeSession::start("ms-c2", &log, Some(&src), Some(&dst)).unwrap();
1068 let checker = MockChecker::new("stage-2");
1069
1070 let verdicts = session.resolve_checked(
1071 vec![("fn::A".into(), Resolution::TakeOurs)],
1072 &checker,
1073 );
1074 assert_eq!(verdicts.len(), 1);
1075 assert!(verdicts[0].accepted, "got {:?}", verdicts[0].rejection);
1076 assert!(session.remaining_conflicts().is_empty());
1077 assert_eq!(checker.seen.borrow().last().unwrap().len(), 0);
1079 }
1080
1081 #[test]
1082 fn resolve_checked_still_rejects_structurally_invalid_before_typechecking() {
1083 let (_tmp, log, dst, src) = fixture();
1086 let mut session = MergeSession::start("ms-c3", &log, Some(&src), Some(&dst)).unwrap();
1087 let checker = MockChecker::new("stage-2");
1088 let verdicts = session.resolve_checked(
1089 vec![("fn::NOPE".into(), Resolution::TakeTheirs)],
1090 &checker,
1091 );
1092 assert!(!verdicts[0].accepted);
1093 assert!(matches!(
1094 verdicts[0].rejection,
1095 Some(ResolutionRejection::UnknownConflict { .. })
1096 ));
1097 assert!(checker.seen.borrow().is_empty(), "checker must not run on a structural reject");
1098 }
1099
1100 #[test]
1101 fn projected_delta_sets_theirs_for_take_theirs() {
1102 let (_tmp, log, dst, src) = fixture();
1103 let session = MergeSession::start("ms-c4", &log, Some(&src), Some(&dst)).unwrap();
1104 let mut res = BTreeMap::new();
1105 res.insert("fn::A".to_string(), Resolution::TakeTheirs);
1106 let delta = session.projected_delta(&res);
1107 assert_eq!(delta.get("fn::A"), Some(&Some("stage-2".to_string())));
1108 }
1109
1110 fn some_entry(n: u8) -> FileEntry {
1113 FileEntry { blob: format!("{n:0>64}"), mode: "100644".into(), size: n as u64 }
1114 }
1115
1116 fn file_conflict(path: &str) -> FileConflict {
1117 FileConflict {
1118 path: path.into(),
1119 base: Some(some_entry(1)),
1120 ours: Some(some_entry(2)),
1121 theirs: Some(some_entry(3)),
1122 }
1123 }
1124
1125 #[test]
1126 fn no_file_conflicts_by_default() {
1127 let tmp = tempfile::tempdir().unwrap();
1131 let log = OpLog::open(tmp.path()).unwrap();
1132 let session = MergeSession::start("ms-f0", &log, None, None).unwrap();
1133 assert!(session.remaining_file_conflicts().is_empty());
1134 assert!(!session.needs_setfiles());
1135 let out = session.commit().unwrap();
1136 assert!(out.resolved_files.is_empty());
1137 assert!(!out.needs_setfiles);
1138 }
1139
1140 #[test]
1141 fn attach_file_conflicts_surfaces_them_as_pending() {
1142 let tmp = tempfile::tempdir().unwrap();
1143 let log = OpLog::open(tmp.path()).unwrap();
1144 let mut session = MergeSession::start("ms-f1", &log, None, None).unwrap();
1145 session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1146 assert!(session.needs_setfiles());
1147 let remaining = session.remaining_file_conflicts();
1148 assert_eq!(remaining.len(), 1);
1149 assert_eq!(remaining[0].path, "README.md");
1150 }
1151
1152 #[test]
1153 fn commit_blocked_by_unresolved_file_conflict() {
1154 let tmp = tempfile::tempdir().unwrap();
1155 let log = OpLog::open(tmp.path()).unwrap();
1156 let mut session = MergeSession::start("ms-f2", &log, None, None).unwrap();
1157 session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1158 let err = session.commit().unwrap_err();
1159 match err {
1160 CommitError::FileConflictsRemaining(paths) => {
1161 assert_eq!(paths, vec!["README.md".to_string()]);
1162 }
1163 other => panic!("expected FileConflictsRemaining, got {other:?}"),
1164 }
1165 }
1166
1167 #[test]
1168 fn sig_conflicts_take_precedence_over_file_conflicts_in_commit_error() {
1169 let (_tmp, log, dst, src) = fixture();
1173 let mut session = MergeSession::start("ms-f3", &log, Some(&src), Some(&dst)).unwrap();
1174 session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1175 let err = session.commit().unwrap_err();
1176 assert!(matches!(err, CommitError::ConflictsRemaining(_)));
1177 }
1178
1179 #[test]
1180 fn resolve_files_take_ours_clears_conflict_and_commits() {
1181 let tmp = tempfile::tempdir().unwrap();
1182 let log = OpLog::open(tmp.path()).unwrap();
1183 let mut session = MergeSession::start("ms-f4", &log, None, None).unwrap();
1184 session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1185 let verdicts =
1186 session.resolve_files(vec![("README.md".into(), FileResolution::TakeOurs)]);
1187 assert!(verdicts[0].accepted);
1188 assert!(session.remaining_file_conflicts().is_empty());
1189 let out = session.commit().unwrap();
1190 assert_eq!(out.resolved_files, vec![("README.md".to_string(), FileResolution::TakeOurs)]);
1191 assert!(out.needs_setfiles);
1192 }
1193
1194 #[test]
1195 fn resolve_files_unknown_path_is_rejected() {
1196 let tmp = tempfile::tempdir().unwrap();
1197 let log = OpLog::open(tmp.path()).unwrap();
1198 let mut session = MergeSession::start("ms-f5", &log, None, None).unwrap();
1199 session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1200 let verdicts =
1201 session.resolve_files(vec![("nope.txt".into(), FileResolution::TakeOurs)]);
1202 assert!(!verdicts[0].accepted);
1203 assert!(matches!(
1204 verdicts[0].rejection,
1205 Some(FileResolutionRejection::UnknownConflict { .. })
1206 ));
1207 assert_eq!(session.remaining_file_conflicts().len(), 1);
1209 }
1210
1211 #[test]
1212 fn defer_on_file_conflict_keeps_it_pending() {
1213 let tmp = tempfile::tempdir().unwrap();
1214 let log = OpLog::open(tmp.path()).unwrap();
1215 let mut session = MergeSession::start("ms-f6", &log, None, None).unwrap();
1216 session.attach_file_conflicts(vec![file_conflict("README.md")], true);
1217 let verdicts =
1218 session.resolve_files(vec![("README.md".into(), FileResolution::Defer)]);
1219 assert!(verdicts[0].accepted);
1220 assert_eq!(session.remaining_file_conflicts().len(), 1, "defer is not a resolution");
1221 assert!(matches!(
1222 session.commit().unwrap_err(),
1223 CommitError::FileConflictsRemaining(_)
1224 ));
1225 }
1226}