khive_vcs/merge_engine.rs
1// Copyright 2026 khive contributors. Licensed under Apache-2.0.
2//
3//! `MergeEngine` trait — pluggable merge implementation (ADR-042 §3).
4//!
5//! `khive-vcs` ships with `NoOpMergeEngine` which returns `VcsError::MergeNotImplemented`
6//! for all calls. `khive-merge` (v0.5, ADR-043) provides `ThreeWayMergeEngine` which
7//! implements the full algorithm.
8
9use khive_runtime::portability::KgArchive;
10
11use crate::error::VcsError;
12
13/// Strategy passed to `merge_branch`.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum MergeStrategy {
17 /// Compute diffs and auto-merge where safe; report conflicts otherwise.
18 #[default]
19 Auto,
20 /// Prefer `ours` on all conflicts. Always produces a `Clean` result.
21 Ours,
22 /// Prefer `theirs` on all conflicts. Always produces a `Clean` result.
23 Theirs,
24}
25
26/// Output of a three-way merge operation.
27#[derive(Debug)]
28pub enum MergeResult {
29 /// All changes merged without conflict. `merged` is the resulting archive.
30 Clean { merged: KgArchive },
31 /// One or more conflicts detected. No merged archive is produced.
32 /// The caller must resolve conflicts and call again with `force=true`.
33 Conflicts { conflicts: Vec<MergeConflict> },
34}
35
36/// A structured conflict that prevented auto-merge.
37#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum MergeConflict {
40 /// Two different names for the same entity.
41 NameConflict {
42 entity_id: uuid::Uuid,
43 ours: String,
44 theirs: String,
45 },
46 /// Incompatible `kind` values for the same entity.
47 KindConflict {
48 entity_id: uuid::Uuid,
49 ours: String,
50 theirs: String,
51 },
52 /// Same property key set to different values in ours and theirs.
53 PropertyMismatch {
54 entity_id: uuid::Uuid,
55 key: String,
56 ours: serde_json::Value,
57 theirs: serde_json::Value,
58 },
59 /// One branch modified an entity; the other deleted it.
60 ModifyDelete {
61 entity_id: uuid::Uuid,
62 modified_in: BranchSide,
63 deleted_in: BranchSide,
64 },
65 /// One branch modified an edge; the other deleted it.
66 EdgeModifyDelete {
67 source_id: uuid::Uuid,
68 target_id: uuid::Uuid,
69 relation: String,
70 modified_in: BranchSide,
71 deleted_in: BranchSide,
72 },
73 /// An edge in the merged set references a deleted endpoint.
74 DanglingEdge {
75 source_id: uuid::Uuid,
76 target_id: uuid::Uuid,
77 relation: String,
78 missing_endpoint: uuid::Uuid,
79 },
80}
81
82/// Which branch side an attribute comes from in a conflict.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum BranchSide {
86 Ours,
87 Theirs,
88}
89
90/// Pluggable merge engine.
91///
92/// `khive-vcs` calls this trait from `merge_branch`. The default implementation
93/// (`NoOpMergeEngine`) returns `VcsError::MergeNotImplemented`. Register a
94/// `ThreeWayMergeEngine` from `khive-merge` at startup to enable full merge.
95pub trait MergeEngine: Send + Sync {
96 fn merge(
97 &self,
98 base: &KgArchive,
99 ours: &KgArchive,
100 theirs: &KgArchive,
101 strategy: MergeStrategy,
102 ) -> Result<MergeResult, VcsError>;
103}
104
105/// Default no-op engine shipped with `khive-vcs`.
106///
107/// Returns `VcsError::MergeNotImplemented` for all calls.
108/// Replace with `ThreeWayMergeEngine` from `khive-merge` in production.
109pub struct NoOpMergeEngine;
110
111impl MergeEngine for NoOpMergeEngine {
112 fn merge(
113 &self,
114 _base: &KgArchive,
115 _ours: &KgArchive,
116 _theirs: &KgArchive,
117 _strategy: MergeStrategy,
118 ) -> Result<MergeResult, VcsError> {
119 Err(VcsError::MergeNotImplemented)
120 }
121}