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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "snake_case")]
82pub enum Resolution {
83 TakeOurs,
85 TakeTheirs,
87 Custom { op: Operation },
91 Defer,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(tag = "kind", rename_all = "snake_case")]
101pub enum ResolutionRejection {
102 UnknownConflict { conflict_id: ConflictId },
106 CustomOpMissingParents {
110 conflict_id: ConflictId,
111 expected: Vec<OpId>,
112 got: Vec<OpId>,
113 },
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct ResolveVerdict {
119 pub conflict_id: ConflictId,
120 pub accepted: bool,
121 pub rejection: Option<ResolutionRejection>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum CommitError {
129 ConflictsRemaining(Vec<ConflictId>),
133}
134
135#[derive(Debug, Serialize, Deserialize)]
140pub struct MergeSession {
141 pub merge_id: MergeSessionId,
142 pub src_head: Option<OpId>,
143 pub dst_head: Option<OpId>,
144 pub lca: Option<OpId>,
145 pub auto_resolved: Vec<MergeOutcome>,
149 conflicts: BTreeMap<ConflictId, ConflictRecord>,
151 resolutions: BTreeMap<ConflictId, Resolution>,
154}
155
156impl MergeSession {
157 pub fn start(
161 merge_id: impl Into<MergeSessionId>,
162 op_log: &OpLog,
163 src_head: Option<&OpId>,
164 dst_head: Option<&OpId>,
165 ) -> std::io::Result<Self> {
166 let MergeOutput { lca, outcomes } = crate::merge::merge(op_log, src_head, dst_head)?;
167 let mut auto_resolved = Vec::new();
168 let mut conflicts: BTreeMap<ConflictId, ConflictRecord> = BTreeMap::new();
169 for outcome in outcomes {
170 match outcome {
171 MergeOutcome::Conflict {
172 sig_id,
173 kind,
174 base,
175 src,
176 dst,
177 } => {
178 let conflict_id = sig_id.clone();
179 conflicts.insert(
180 conflict_id.clone(),
181 ConflictRecord {
182 conflict_id,
183 sig_id,
184 kind,
185 base,
186 ours: dst,
192 theirs: src,
193 },
194 );
195 }
196 other => auto_resolved.push(other),
197 }
198 }
199 Ok(Self {
200 merge_id: merge_id.into(),
201 src_head: src_head.cloned(),
202 dst_head: dst_head.cloned(),
203 lca,
204 auto_resolved,
205 conflicts,
206 resolutions: BTreeMap::new(),
207 })
208 }
209
210 pub fn remaining_conflicts(&self) -> Vec<&ConflictRecord> {
212 self.conflicts
213 .values()
214 .filter(|c| {
215 !matches!(self.resolutions.get(&c.conflict_id),
216 Some(Resolution::TakeOurs)
217 | Some(Resolution::TakeTheirs)
218 | Some(Resolution::Custom { .. }))
219 })
220 .collect()
221 }
222
223 pub fn resolve(
228 &mut self,
229 resolutions: Vec<(ConflictId, Resolution)>,
230 ) -> Vec<ResolveVerdict> {
231 let mut out = Vec::with_capacity(resolutions.len());
232 for (conflict_id, resolution) in resolutions {
233 match self.validate_resolution(&conflict_id, &resolution) {
234 Ok(()) => {
235 self.resolutions.insert(conflict_id.clone(), resolution);
236 out.push(ResolveVerdict {
237 conflict_id,
238 accepted: true,
239 rejection: None,
240 });
241 }
242 Err(rej) => {
243 out.push(ResolveVerdict {
244 conflict_id,
245 accepted: false,
246 rejection: Some(rej),
247 });
248 }
249 }
250 }
251 out
252 }
253
254 pub fn validate_resolution(
258 &self,
259 conflict_id: &ConflictId,
260 resolution: &Resolution,
261 ) -> Result<(), ResolutionRejection> {
262 if !self.conflicts.contains_key(conflict_id) {
263 return Err(ResolutionRejection::UnknownConflict { conflict_id: conflict_id.clone() });
264 }
265 if let Resolution::Custom { op } = resolution {
266 if op.parents.len() < 2 {
278 return Err(ResolutionRejection::CustomOpMissingParents {
279 conflict_id: conflict_id.clone(),
280 expected: vec!["ours-op-id".into(), "theirs-op-id".into()],
281 got: op.parents.clone(),
282 });
283 }
284 }
285 Ok(())
286 }
287
288 pub fn commit(self) -> Result<Vec<(ConflictId, Resolution)>, CommitError> {
294 let unresolved: Vec<ConflictId> = self
295 .conflicts
296 .keys()
297 .filter(|id| {
298 !matches!(self.resolutions.get(*id),
299 Some(Resolution::TakeOurs)
300 | Some(Resolution::TakeTheirs)
301 | Some(Resolution::Custom { .. }))
302 })
303 .cloned()
304 .collect();
305 if !unresolved.is_empty() {
306 return Err(CommitError::ConflictsRemaining(unresolved));
307 }
308 let mut resolved: Vec<(ConflictId, Resolution)> = self.resolutions.into_iter().collect();
309 resolved.sort_by(|a, b| a.0.cmp(&b.0));
310 Ok(resolved)
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::operation::{OperationKind, OperationRecord, StageTransition};
318 use std::collections::BTreeSet;
319
320 fn fixture() -> (tempfile::TempDir, OpLog, OpId, OpId) {
325 let tmp = tempfile::tempdir().unwrap();
326 let log = OpLog::open(tmp.path()).unwrap();
327 let r0 = OperationRecord::new(
328 Operation::new(
329 OperationKind::AddFunction {
330 sig_id: "fn::A".into(),
331 stage_id: "stage-0".into(),
332 effects: BTreeSet::new(),
333 },
334 [],
335 ),
336 StageTransition::Create {
337 sig_id: "fn::A".into(),
338 stage_id: "stage-0".into(),
339 },
340 );
341 log.put(&r0).unwrap();
342
343 let r1 = OperationRecord::new(
344 Operation::new(
345 OperationKind::ModifyBody {
346 sig_id: "fn::A".into(),
347 from_stage_id: "stage-0".into(),
348 to_stage_id: "stage-1".into(),
349 },
350 [r0.op_id.clone()],
351 ),
352 StageTransition::Replace {
353 sig_id: "fn::A".into(),
354 from: "stage-0".into(),
355 to: "stage-1".into(),
356 },
357 );
358 log.put(&r1).unwrap();
359
360 let r2 = OperationRecord::new(
361 Operation::new(
362 OperationKind::ModifyBody {
363 sig_id: "fn::A".into(),
364 from_stage_id: "stage-0".into(),
365 to_stage_id: "stage-2".into(),
366 },
367 [r0.op_id.clone()],
368 ),
369 StageTransition::Replace {
370 sig_id: "fn::A".into(),
371 from: "stage-0".into(),
372 to: "stage-2".into(),
373 },
374 );
375 log.put(&r2).unwrap();
376
377 (tmp, log, r1.op_id, r2.op_id)
378 }
379
380 #[test]
381 fn start_collects_conflicts() {
382 let (_tmp, log, dst, src) = fixture();
383 let session =
384 MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
385 assert_eq!(session.remaining_conflicts().len(), 1);
386 assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
387 assert_eq!(
388 session.remaining_conflicts()[0].kind,
389 ConflictKind::ModifyModify
390 );
391 assert_eq!(
392 session.remaining_conflicts()[0].ours.as_deref(),
393 Some("stage-1"),
394 );
395 assert_eq!(
396 session.remaining_conflicts()[0].theirs.as_deref(),
397 Some("stage-2"),
398 );
399 assert_eq!(
400 session.remaining_conflicts()[0].base.as_deref(),
401 Some("stage-0"),
402 );
403 }
404
405 #[test]
406 fn no_conflicts_when_branches_dont_overlap() {
407 let tmp = tempfile::tempdir().unwrap();
408 let log = OpLog::open(tmp.path()).unwrap();
409 let r0 = OperationRecord::new(
410 Operation::new(
411 OperationKind::AddFunction {
412 sig_id: "fn::A".into(),
413 stage_id: "stage-0".into(),
414 effects: BTreeSet::new(),
415 },
416 [],
417 ),
418 StageTransition::Create {
419 sig_id: "fn::A".into(),
420 stage_id: "stage-0".into(),
421 },
422 );
423 log.put(&r0).unwrap();
424 let r1 = OperationRecord::new(
425 Operation::new(
426 OperationKind::AddFunction {
427 sig_id: "fn::B".into(),
428 stage_id: "stage-B".into(),
429 effects: BTreeSet::new(),
430 },
431 [r0.op_id.clone()],
432 ),
433 StageTransition::Create {
434 sig_id: "fn::B".into(),
435 stage_id: "stage-B".into(),
436 },
437 );
438 log.put(&r1).unwrap();
439
440 let session =
441 MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
442 assert!(session.remaining_conflicts().is_empty());
443 assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
444 }
445
446 #[test]
447 fn resolve_take_ours_clears_conflict() {
448 let (_tmp, log, dst, src) = fixture();
449 let mut session =
450 MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
451 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
452 assert_eq!(verdicts.len(), 1);
453 assert!(verdicts[0].accepted);
454 assert!(session.remaining_conflicts().is_empty());
455 }
456
457 #[test]
458 fn resolve_take_theirs_clears_conflict() {
459 let (_tmp, log, dst, src) = fixture();
460 let mut session =
461 MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
462 let verdicts =
463 session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
464 assert!(verdicts[0].accepted);
465 assert!(session.remaining_conflicts().is_empty());
466 }
467
468 #[test]
469 fn resolve_unknown_conflict_is_rejected() {
470 let (_tmp, log, dst, src) = fixture();
471 let mut session =
472 MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
473 let verdicts =
474 session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
475 assert_eq!(verdicts.len(), 1);
476 assert!(!verdicts[0].accepted);
477 assert!(matches!(
478 verdicts[0].rejection,
479 Some(ResolutionRejection::UnknownConflict { .. }),
480 ));
481 }
482
483 #[test]
484 fn custom_op_without_two_parents_is_rejected() {
485 let (_tmp, log, dst, src) = fixture();
486 let mut session =
487 MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
488 let bad_op = Operation::new(
490 OperationKind::ModifyBody {
491 sig_id: "fn::A".into(),
492 from_stage_id: "stage-0".into(),
493 to_stage_id: "stage-X".into(),
494 },
495 [],
496 );
497 let verdicts = session.resolve(vec![(
498 "fn::A".into(),
499 Resolution::Custom { op: bad_op },
500 )]);
501 assert!(!verdicts[0].accepted);
502 assert!(matches!(
503 verdicts[0].rejection,
504 Some(ResolutionRejection::CustomOpMissingParents { .. }),
505 ));
506 assert_eq!(session.remaining_conflicts().len(), 1);
509 }
510
511 #[test]
512 fn custom_op_with_two_parents_is_accepted() {
513 let (_tmp, log, dst, src) = fixture();
514 let mut session =
515 MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
516 let merge_op = Operation::new(
517 OperationKind::ModifyBody {
518 sig_id: "fn::A".into(),
519 from_stage_id: "stage-0".into(),
520 to_stage_id: "stage-merged".into(),
521 },
522 [src.clone(), dst.clone()],
523 );
524 let verdicts = session.resolve(vec![(
525 "fn::A".into(),
526 Resolution::Custom { op: merge_op },
527 )]);
528 assert!(verdicts[0].accepted);
529 assert!(session.remaining_conflicts().is_empty());
530 }
531
532 #[test]
533 fn defer_keeps_conflict_pending() {
534 let (_tmp, log, dst, src) = fixture();
535 let mut session =
536 MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
537 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
538 assert!(verdicts[0].accepted);
542 assert_eq!(session.remaining_conflicts().len(), 1);
543 }
544
545 #[test]
546 fn commit_with_no_conflicts_succeeds() {
547 let tmp = tempfile::tempdir().unwrap();
548 let log = OpLog::open(tmp.path()).unwrap();
549 let session = MergeSession::start("ms-9", &log, None, None).unwrap();
550 let resolved = session.commit().unwrap();
551 assert!(resolved.is_empty());
552 }
553
554 #[test]
555 fn commit_with_unresolved_conflict_fails() {
556 let (_tmp, log, dst, src) = fixture();
557 let session =
558 MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
559 let err = session.commit().unwrap_err();
560 match err {
561 CommitError::ConflictsRemaining(ids) => {
562 assert_eq!(ids, vec!["fn::A".to_string()]);
563 }
564 }
565 }
566
567 #[test]
568 fn commit_with_defer_remaining_fails() {
569 let (_tmp, log, dst, src) = fixture();
570 let mut session =
571 MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
572 session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
573 let err = session.commit().unwrap_err();
574 match err {
575 CommitError::ConflictsRemaining(ids) => {
576 assert_eq!(ids, vec!["fn::A".to_string()]);
577 }
578 }
579 }
580
581 #[test]
582 fn commit_after_resolve_succeeds() {
583 let (_tmp, log, dst, src) = fixture();
584 let mut session =
585 MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
586 session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
587 let resolved = session.commit().unwrap();
588 assert_eq!(resolved.len(), 1);
589 assert_eq!(resolved[0].0, "fn::A");
590 assert!(matches!(resolved[0].1, Resolution::TakeOurs));
591 }
592
593 #[test]
594 fn batch_resolve_accepts_partial() {
595 let (_tmp, log, dst, src) = fixture();
599 let mut session =
600 MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
601 let verdicts = session.resolve(vec![
602 ("fn::A".into(), Resolution::TakeOurs),
603 ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
604 ]);
605 assert_eq!(verdicts.len(), 2);
606 assert!(verdicts[0].accepted);
607 assert!(!verdicts[1].accepted);
608 assert!(session.remaining_conflicts().is_empty());
610 }
611
612 #[test]
613 fn auto_resolved_outcomes_are_visible() {
614 let tmp = tempfile::tempdir().unwrap();
615 let log = OpLog::open(tmp.path()).unwrap();
616 let r0 = OperationRecord::new(
620 Operation::new(
621 OperationKind::AddFunction {
622 sig_id: "fn::A".into(),
623 stage_id: "stage-0".into(),
624 effects: BTreeSet::new(),
625 },
626 [],
627 ),
628 StageTransition::Create {
629 sig_id: "fn::A".into(),
630 stage_id: "stage-0".into(),
631 },
632 );
633 log.put(&r0).unwrap();
634 let session =
635 MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
636 assert!(session.remaining_conflicts().is_empty());
637 assert_eq!(session.auto_resolved.len(), 1);
640 }
641}