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 budget_cost: None,
334 },
335 [],
336 ),
337 StageTransition::Create {
338 sig_id: "fn::A".into(),
339 stage_id: "stage-0".into(),
340 },
341 );
342 log.put(&r0).unwrap();
343
344 let r1 = OperationRecord::new(
345 Operation::new(
346 OperationKind::ModifyBody {
347 sig_id: "fn::A".into(),
348 from_stage_id: "stage-0".into(),
349 to_stage_id: "stage-1".into(),
350 from_budget: None,
351 to_budget: None,
352 },
353 [r0.op_id.clone()],
354 ),
355 StageTransition::Replace {
356 sig_id: "fn::A".into(),
357 from: "stage-0".into(),
358 to: "stage-1".into(),
359 },
360 );
361 log.put(&r1).unwrap();
362
363 let r2 = OperationRecord::new(
364 Operation::new(
365 OperationKind::ModifyBody {
366 sig_id: "fn::A".into(),
367 from_stage_id: "stage-0".into(),
368 to_stage_id: "stage-2".into(),
369 from_budget: None,
370 to_budget: None,
371 },
372 [r0.op_id.clone()],
373 ),
374 StageTransition::Replace {
375 sig_id: "fn::A".into(),
376 from: "stage-0".into(),
377 to: "stage-2".into(),
378 },
379 );
380 log.put(&r2).unwrap();
381
382 (tmp, log, r1.op_id, r2.op_id)
383 }
384
385 #[test]
386 fn start_collects_conflicts() {
387 let (_tmp, log, dst, src) = fixture();
388 let session =
389 MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
390 assert_eq!(session.remaining_conflicts().len(), 1);
391 assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
392 assert_eq!(
393 session.remaining_conflicts()[0].kind,
394 ConflictKind::ModifyModify
395 );
396 assert_eq!(
397 session.remaining_conflicts()[0].ours.as_deref(),
398 Some("stage-1"),
399 );
400 assert_eq!(
401 session.remaining_conflicts()[0].theirs.as_deref(),
402 Some("stage-2"),
403 );
404 assert_eq!(
405 session.remaining_conflicts()[0].base.as_deref(),
406 Some("stage-0"),
407 );
408 }
409
410 #[test]
411 fn no_conflicts_when_branches_dont_overlap() {
412 let tmp = tempfile::tempdir().unwrap();
413 let log = OpLog::open(tmp.path()).unwrap();
414 let r0 = OperationRecord::new(
415 Operation::new(
416 OperationKind::AddFunction {
417 sig_id: "fn::A".into(),
418 stage_id: "stage-0".into(),
419 effects: BTreeSet::new(),
420 budget_cost: None,
421 },
422 [],
423 ),
424 StageTransition::Create {
425 sig_id: "fn::A".into(),
426 stage_id: "stage-0".into(),
427 },
428 );
429 log.put(&r0).unwrap();
430 let r1 = OperationRecord::new(
431 Operation::new(
432 OperationKind::AddFunction {
433 sig_id: "fn::B".into(),
434 stage_id: "stage-B".into(),
435 effects: BTreeSet::new(),
436 budget_cost: None,
437 },
438 [r0.op_id.clone()],
439 ),
440 StageTransition::Create {
441 sig_id: "fn::B".into(),
442 stage_id: "stage-B".into(),
443 },
444 );
445 log.put(&r1).unwrap();
446
447 let session =
448 MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
449 assert!(session.remaining_conflicts().is_empty());
450 assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
451 }
452
453 #[test]
454 fn resolve_take_ours_clears_conflict() {
455 let (_tmp, log, dst, src) = fixture();
456 let mut session =
457 MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
458 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
459 assert_eq!(verdicts.len(), 1);
460 assert!(verdicts[0].accepted);
461 assert!(session.remaining_conflicts().is_empty());
462 }
463
464 #[test]
465 fn resolve_take_theirs_clears_conflict() {
466 let (_tmp, log, dst, src) = fixture();
467 let mut session =
468 MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
469 let verdicts =
470 session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
471 assert!(verdicts[0].accepted);
472 assert!(session.remaining_conflicts().is_empty());
473 }
474
475 #[test]
476 fn resolve_unknown_conflict_is_rejected() {
477 let (_tmp, log, dst, src) = fixture();
478 let mut session =
479 MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
480 let verdicts =
481 session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
482 assert_eq!(verdicts.len(), 1);
483 assert!(!verdicts[0].accepted);
484 assert!(matches!(
485 verdicts[0].rejection,
486 Some(ResolutionRejection::UnknownConflict { .. }),
487 ));
488 }
489
490 #[test]
491 fn custom_op_without_two_parents_is_rejected() {
492 let (_tmp, log, dst, src) = fixture();
493 let mut session =
494 MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
495 let bad_op = Operation::new(
497 OperationKind::ModifyBody {
498 sig_id: "fn::A".into(),
499 from_stage_id: "stage-0".into(),
500 to_stage_id: "stage-X".into(),
501 from_budget: None,
502 to_budget: None,
503 },
504 [],
505 );
506 let verdicts = session.resolve(vec![(
507 "fn::A".into(),
508 Resolution::Custom { op: bad_op },
509 )]);
510 assert!(!verdicts[0].accepted);
511 assert!(matches!(
512 verdicts[0].rejection,
513 Some(ResolutionRejection::CustomOpMissingParents { .. }),
514 ));
515 assert_eq!(session.remaining_conflicts().len(), 1);
518 }
519
520 #[test]
521 fn custom_op_with_two_parents_is_accepted() {
522 let (_tmp, log, dst, src) = fixture();
523 let mut session =
524 MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
525 let merge_op = Operation::new(
526 OperationKind::ModifyBody {
527 sig_id: "fn::A".into(),
528 from_stage_id: "stage-0".into(),
529 to_stage_id: "stage-merged".into(),
530 from_budget: None,
531 to_budget: None,
532 },
533 [src.clone(), dst.clone()],
534 );
535 let verdicts = session.resolve(vec![(
536 "fn::A".into(),
537 Resolution::Custom { op: merge_op },
538 )]);
539 assert!(verdicts[0].accepted);
540 assert!(session.remaining_conflicts().is_empty());
541 }
542
543 #[test]
544 fn defer_keeps_conflict_pending() {
545 let (_tmp, log, dst, src) = fixture();
546 let mut session =
547 MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
548 let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
549 assert!(verdicts[0].accepted);
553 assert_eq!(session.remaining_conflicts().len(), 1);
554 }
555
556 #[test]
557 fn commit_with_no_conflicts_succeeds() {
558 let tmp = tempfile::tempdir().unwrap();
559 let log = OpLog::open(tmp.path()).unwrap();
560 let session = MergeSession::start("ms-9", &log, None, None).unwrap();
561 let resolved = session.commit().unwrap();
562 assert!(resolved.is_empty());
563 }
564
565 #[test]
566 fn commit_with_unresolved_conflict_fails() {
567 let (_tmp, log, dst, src) = fixture();
568 let session =
569 MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
570 let err = session.commit().unwrap_err();
571 match err {
572 CommitError::ConflictsRemaining(ids) => {
573 assert_eq!(ids, vec!["fn::A".to_string()]);
574 }
575 }
576 }
577
578 #[test]
579 fn commit_with_defer_remaining_fails() {
580 let (_tmp, log, dst, src) = fixture();
581 let mut session =
582 MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
583 session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
584 let err = session.commit().unwrap_err();
585 match err {
586 CommitError::ConflictsRemaining(ids) => {
587 assert_eq!(ids, vec!["fn::A".to_string()]);
588 }
589 }
590 }
591
592 #[test]
593 fn commit_after_resolve_succeeds() {
594 let (_tmp, log, dst, src) = fixture();
595 let mut session =
596 MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
597 session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
598 let resolved = session.commit().unwrap();
599 assert_eq!(resolved.len(), 1);
600 assert_eq!(resolved[0].0, "fn::A");
601 assert!(matches!(resolved[0].1, Resolution::TakeOurs));
602 }
603
604 #[test]
605 fn batch_resolve_accepts_partial() {
606 let (_tmp, log, dst, src) = fixture();
610 let mut session =
611 MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
612 let verdicts = session.resolve(vec![
613 ("fn::A".into(), Resolution::TakeOurs),
614 ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
615 ]);
616 assert_eq!(verdicts.len(), 2);
617 assert!(verdicts[0].accepted);
618 assert!(!verdicts[1].accepted);
619 assert!(session.remaining_conflicts().is_empty());
621 }
622
623 #[test]
624 fn auto_resolved_outcomes_are_visible() {
625 let tmp = tempfile::tempdir().unwrap();
626 let log = OpLog::open(tmp.path()).unwrap();
627 let r0 = OperationRecord::new(
631 Operation::new(
632 OperationKind::AddFunction {
633 sig_id: "fn::A".into(),
634 stage_id: "stage-0".into(),
635 effects: BTreeSet::new(),
636 budget_cost: None,
637 },
638 [],
639 ),
640 StageTransition::Create {
641 sig_id: "fn::A".into(),
642 stage_id: "stage-0".into(),
643 },
644 );
645 log.put(&r0).unwrap();
646 let session =
647 MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
648 assert!(session.remaining_conflicts().is_empty());
649 assert_eq!(session.auto_resolved.len(), 1);
652 }
653}