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