Skip to main content

choir_merge/
lib.rs

1//! Merge-strategy pipeline seam (DECISIONS.md D4/D19).
2//!
3//! Ordered strategies, cheapest first; each maps (base, left, right) to
4//! Resolved or Conflict. LLM resolution and Mergiraf are just strategy slots,
5//! so widening or dropping them is configuration, not surgery. Mergiraf is
6//! GPLv3 (audited 2026-08-08): subprocess only, never linked (D4).
7//!
8//! # Examples
9//!
10//! ```
11//! use choir_merge::{MergeOutcome, Pipeline};
12//!
13//! let pipeline = Pipeline::default_v1();
14//! // Only the right side changed, so the merge resolves trivially.
15//! let result = pipeline.merge("a\n", "a\n", "a\nb\n");
16//! assert_eq!(result.strategy, "trivial");
17//! match result.outcome {
18//!     MergeOutcome::Resolved(text) => assert_eq!(text, "a\nb\n"),
19//!     _ => unreachable!(),
20//! }
21//! ```
22//!
23//! # Where this sits
24//!
25//! `docs/architecture.md` is the map of the whole workspace.
26//! This crate is the merge-strategy pipeline (D4/D19), cheapest strategy first.
27//!
28//! It depends on no other crate in this workspace.
29
30pub mod safety;
31pub mod silent_revert;
32
33/// Result of one strategy's attempt at a 3-way merge.
34pub enum MergeOutcome {
35    /// Strategy produced a clean merge.
36    Resolved(String),
37    /// Strategy ran but could not resolve; carry the conflict downstream
38    /// (jj-style first-class conflict is the pipeline's terminal fallback).
39    Conflict {
40        /// Merge text with conflict markers preserved.
41        annotated: String,
42    },
43    /// Strategy not applicable in this environment (e.g. binary missing).
44    Unavailable(String),
45}
46
47/// The strategy seam: one slot in the [`Pipeline`].
48pub trait MergeStrategy: Send + Sync {
49    /// Stable identifier, recorded in [`PipelineResult::strategy`] for
50    /// provenance and review escalation.
51    fn name(&self) -> &'static str;
52
53    /// Attempts a 3-way merge of `left` and `right` against `base`.
54    fn merge(&self, base: &str, left: &str, right: &str) -> MergeOutcome;
55}
56
57/// Runs strategies in order; first Resolved wins. If none resolves, returns
58/// the last Conflict (first-class conflict, never a silent pick).
59pub struct Pipeline {
60    strategies: Vec<Box<dyn MergeStrategy>>,
61}
62
63/// A [`Pipeline`] verdict plus which strategy produced it.
64pub struct PipelineResult {
65    /// The merge outcome; never [`MergeOutcome::Unavailable`].
66    pub outcome: MergeOutcome,
67    /// Which strategy produced the outcome (provenance for review escalation).
68    pub strategy: &'static str,
69}
70
71impl Pipeline {
72    /// Builds a pipeline from an ordered list of strategies, cheapest first.
73    pub fn new(strategies: Vec<Box<dyn MergeStrategy>>) -> Self {
74        Self { strategies }
75    }
76
77    /// The v1 default: trivial, then line-based. Structured (Mergiraf) and
78    /// LLM slots are appended by the caller when enabled.
79    pub fn default_v1() -> Self {
80        Self::new(vec![Box::new(TrivialMerge), Box::new(LineMerge)])
81    }
82
83    /// Runs the strategies in order and returns the first clean resolution,
84    /// or the last conflict if nothing resolves.
85    ///
86    /// # Panics
87    ///
88    /// Panics if every strategy reports [`MergeOutcome::Unavailable`]; a
89    /// pipeline must always contain at least one applicable strategy.
90    pub fn merge(&self, base: &str, left: &str, right: &str) -> PipelineResult {
91        let mut last_conflict: Option<PipelineResult> = None;
92        for s in &self.strategies {
93            match s.merge(base, left, right) {
94                MergeOutcome::Resolved(text) => {
95                    return PipelineResult {
96                        outcome: MergeOutcome::Resolved(text),
97                        strategy: s.name(),
98                    }
99                }
100                c @ MergeOutcome::Conflict { .. } => {
101                    last_conflict = Some(PipelineResult {
102                        outcome: c,
103                        strategy: s.name(),
104                    })
105                }
106                MergeOutcome::Unavailable(_) => continue,
107            }
108        }
109        last_conflict.expect("pipeline contains at least one applicable strategy")
110    }
111}
112
113/// Cheapest checks: unchanged sides and identical edits.
114pub struct TrivialMerge;
115
116impl MergeStrategy for TrivialMerge {
117    fn name(&self) -> &'static str {
118        "trivial"
119    }
120    fn merge(&self, base: &str, left: &str, right: &str) -> MergeOutcome {
121        if left == right {
122            return MergeOutcome::Resolved(left.to_string());
123        }
124        if left == base {
125            return MergeOutcome::Resolved(right.to_string());
126        }
127        if right == base {
128            return MergeOutcome::Resolved(left.to_string());
129        }
130        MergeOutcome::Conflict {
131            annotated: format!("<<<<<<< left\n{left}=======\n{right}>>>>>>> right\n"),
132        }
133    }
134}
135
136/// Line-based 3-way merge (diffy), the histogram/ORT analog in the plan.
137pub struct LineMerge;
138
139impl MergeStrategy for LineMerge {
140    fn name(&self) -> &'static str {
141        "line"
142    }
143    fn merge(&self, base: &str, left: &str, right: &str) -> MergeOutcome {
144        match diffy::merge(base, left, right) {
145            Ok(clean) => MergeOutcome::Resolved(clean),
146            Err(conflicted) => MergeOutcome::Conflict {
147                annotated: conflicted,
148            },
149        }
150    }
151}
152
153/// Structured (AST) merge via the mergiraf binary, subprocess only (GPLv3).
154/// CLI verified against mergiraf 0.18.0: `mergiraf merge <BASE> <LEFT> <RIGHT>
155/// -o <OUT>`; language is detected from the input file extension.
156pub struct MergirafMerge {
157    binary: std::path::PathBuf,
158    /// File extension used for language detection (e.g. "rs", "py").
159    pub extension: String,
160}
161
162impl MergirafMerge {
163    /// Returns `None` when mergiraf is not installed; the pipeline then simply
164    /// skips this slot (D4 fallback: line merge + first-class conflicts).
165    pub fn detect(extension: &str) -> Option<Self> {
166        let out = std::process::Command::new("which")
167            .arg("mergiraf")
168            .output()
169            .ok()?;
170        if !out.status.success() {
171            return None;
172        }
173        let path = String::from_utf8(out.stdout).ok()?.trim().to_string();
174        Some(Self {
175            binary: path.into(),
176            extension: extension.to_string(),
177        })
178    }
179
180    fn run(&self, base: &str, left: &str, right: &str) -> std::io::Result<MergeOutcome> {
181        let dir = std::env::temp_dir().join(format!(
182            "choir-mergiraf-{}-{:?}",
183            std::process::id(),
184            std::thread::current().id()
185        ));
186        std::fs::create_dir_all(&dir)?;
187        let ext = &self.extension;
188        let b = dir.join(format!("base.{ext}"));
189        let l = dir.join(format!("left.{ext}"));
190        let r = dir.join(format!("right.{ext}"));
191        let o = dir.join(format!("merged.{ext}"));
192        std::fs::write(&b, base)?;
193        std::fs::write(&l, left)?;
194        std::fs::write(&r, right)?;
195        let out = std::process::Command::new(&self.binary)
196            .arg("merge")
197            .arg(&b)
198            .arg(&l)
199            .arg(&r)
200            .arg("-o")
201            .arg(&o)
202            .output();
203        let merged = std::fs::read_to_string(&o).unwrap_or_default();
204        std::fs::remove_dir_all(&dir).ok();
205        let out = out?;
206        if out.status.success() {
207            Ok(MergeOutcome::Resolved(merged))
208        } else if !merged.is_empty() {
209            Ok(MergeOutcome::Conflict { annotated: merged })
210        } else {
211            Ok(MergeOutcome::Unavailable(
212                String::from_utf8_lossy(&out.stderr).into_owned(),
213            ))
214        }
215    }
216}
217
218impl MergeStrategy for MergirafMerge {
219    fn name(&self) -> &'static str {
220        "mergiraf"
221    }
222    fn merge(&self, base: &str, left: &str, right: &str) -> MergeOutcome {
223        match self.run(base, left, right) {
224            Ok(outcome) => outcome,
225            Err(e) => MergeOutcome::Unavailable(e.to_string()),
226        }
227    }
228}
229
230/// The position-independent content of a change: the deleted and
231/// inserted lines of its diff, with hunk positions and context stripped —
232/// the cheap analog of `git patch-id --stable` (DECISIONS.md
233/// D15: metadata, never a new merge substrate). Two authorings of the
234/// same edit on different bases — the change before and after the train
235/// rewrites or rebases it — normalize to the same string, which is what
236/// lets a queue or bridge recognize an already-landed change instead of
237/// re-merging it. It lives here because this crate already owns the diff
238/// dependency; callers hash the result.
239pub fn normalized_diff(base: &str, proposed: &str) -> String {
240    let patch = diffy::create_patch(base, proposed);
241    let mut out = String::new();
242    let mut push = |marker: char, text: &str| {
243        out.push(marker);
244        out.push_str(text);
245        // A final line without a terminating newline would otherwise
246        // fuse with the next marker and alias a different change.
247        if !text.ends_with('\n') {
248            out.push('\n');
249        }
250    };
251    for hunk in patch.hunks() {
252        for line in hunk.lines() {
253            match line {
254                diffy::Line::Delete(text) => push('-', text),
255                diffy::Line::Insert(text) => push('+', text),
256                diffy::Line::Context(_) => {}
257            }
258        }
259    }
260    out
261}