Skip to main content

devset_core/
merge.rs

1//! Three-way merges, built in or through a configured [`Driver`].
2
3use core::str::FromStr;
4use std::io;
5use std::process::{Command, Stdio};
6
7use camino::Utf8Path;
8use derive_more::Display;
9use serde_with::{DeserializeFromStr, SerializeDisplay};
10
11use crate::digest::is_binary;
12use crate::errors::{MergeError, Result};
13use crate::path::RelPath;
14
15/// Lines that open, separate or close a conflict hunk.
16///
17/// `=======` is left out: it is also a Markdown heading underline.
18const MARKERS: [&[u8]; 3] = [b"<<<<<<<", b"|||||||", b">>>>>>>"];
19
20/// How three-way merges are performed; written as its `driver` line, checked as it is read, so a
21/// mistake points into the file that holds it.
22#[derive(Clone, Debug, Default, PartialEq, Eq, Display, SerializeDisplay, DeserializeFromStr)]
23pub enum Driver {
24    /// A line merge, `diffy`'s.
25    #[default]
26    #[display("builtin")]
27    Builtin,
28    /// A program, on git's merge-driver convention.
29    ///
30    /// `%O` is the ancestor, `%A` the local version and where the result is left, `%B` the
31    /// incoming version, `%P` the path; `%%` is `%`. Exit zero means merged cleanly.
32    #[display("{line}")]
33    Command {
34        /// As configured.
35        line: String,
36        /// `line` split into arguments, placeholders intact.
37        args: Vec<String>,
38    },
39}
40
41/// A merge's result.
42#[derive(Debug)]
43pub(crate) struct Merged {
44    /// The merged bytes, with conflict markers if not clean.
45    pub bytes: Vec<u8>,
46    /// Why the merge did not succeed; `None` when clean.
47    pub conflict: Option<String>,
48}
49
50/// The driver a `driver` line names, split as a POSIX shell would; no shell ever runs it.
51///
52/// Fails with [`MergeError::Driver`] when quotes are unbalanced, a placeholder is unknown, or
53/// `%A` is missing.
54impl FromStr for Driver {
55    type Err = MergeError;
56
57    fn from_str(line: &str) -> Result<Self, MergeError> {
58        let invalid = |reason| MergeError::Driver { line: line.to_owned(), reason };
59        let args = shlex::split(line).ok_or_else(|| invalid("unbalanced quotes"))?;
60        if args.is_empty() {
61            return Err(invalid("empty"));
62        }
63        for arg in &args {
64            let mut chars = arg.chars();
65            while let Some(c) = chars.next() {
66                if c == '%' && !matches!(chars.next(), Some('O' | 'A' | 'B' | 'P' | '%')) {
67                    return Err(invalid("placeholders are %O, %A, %B, %P and %%"));
68                }
69            }
70        }
71        if !args.iter().any(|arg| arg.contains("%A")) {
72            return Err(invalid("must name %A, where the merged result is left"));
73        }
74        Ok(Self::Command { line: line.to_owned(), args })
75    }
76}
77
78impl Driver {
79    /// Merges `ours` and `theirs` from `base` for `path`, running a command in `root`.
80    pub(crate) fn merge(
81        &self, root: &Utf8Path, path: &RelPath, base: &[u8], ours: &[u8], theirs: &[u8],
82    ) -> Result<Merged> {
83        let Self::Command { line, args } = self else {
84            if [base, ours, theirs].into_iter().any(is_binary) {
85                let conflict = Some("binary: the sidecar holds the profile's version".to_owned());
86                return Ok(Merged { bytes: theirs.to_vec(), conflict });
87            }
88            return Ok(match diffy::merge_bytes(base, ours, theirs) {
89                Ok(bytes) => Merged { bytes, conflict: None },
90                Err(bytes) => Merged { bytes, conflict: Some("conflicting changes".to_owned()) },
91            });
92        };
93        // Each version under its real file name, so drivers can detect the language.
94        let scratch = camino_tempfile::tempdir()?;
95        let [o, a, b] =
96            ["O", "A", "B"].map(|side| scratch.path().join(side).join(path.file_name()));
97        for (file, bytes) in [(&o, base), (&a, ours), (&b, theirs)] {
98            if let Some(dir) = file.parent() {
99                fs_err::create_dir_all(dir)?;
100            }
101            fs_err::write(file, bytes)?;
102        }
103        let expand =
104            |arg: &String| expand(arg, [o.as_str(), a.as_str(), b.as_str(), path.as_str()]);
105        let argv: Vec<String> = args.iter().map(expand).collect();
106        let (program, rest) = argv
107            .split_first()
108            .ok_or_else(|| MergeError::Driver { line: line.clone(), reason: "empty" })?;
109        let stopped = |reason| MergeError::Run { program: program.clone(), reason };
110        let output = Command::new(program)
111            .args(rest)
112            .current_dir(root)
113            .stdin(Stdio::null())
114            .stdout(Stdio::null())
115            .output()
116            .map_err(|e| {
117                if e.kind() == io::ErrorKind::NotFound {
118                    MergeError::NoDriver { program: program.clone() }
119                } else {
120                    stopped(e.to_string())
121                }
122            })?;
123        let bytes = fs_err::read(&a)?;
124        match output.status.code() {
125            Some(0) => Ok(Merged { bytes, conflict: None }),
126            Some(code) => {
127                let stderr = String::from_utf8_lossy(&output.stderr);
128                let why = stderr.trim();
129                let conflict = if why.is_empty() {
130                    format!("`{program}` exited {code}")
131                } else {
132                    why.to_owned()
133                };
134                Ok(Merged { bytes, conflict: Some(conflict) })
135            },
136            None => Err(stopped("killed by a signal".to_owned()).into()),
137        }
138    }
139}
140
141/// `arg` with `%O %A %B %P` replaced by `values`, in that order, and `%%` by `%`.
142fn expand(arg: &str, [o, a, b, p]: [&str; 4]) -> String {
143    let mut out = String::with_capacity(arg.len());
144    let mut chars = arg.chars();
145    while let Some(c) = chars.next() {
146        if c != '%' {
147            out.push(c);
148            continue;
149        }
150        match chars.next() {
151            Some('O') => out.push_str(o),
152            Some('A') => out.push_str(a),
153            Some('B') => out.push_str(b),
154            Some('P') => out.push_str(p),
155            Some(other) => out.push(other),
156            None => {},
157        }
158    }
159    out
160}
161
162/// Whether `bytes` still hold a conflict hunk.
163pub(crate) fn has_markers(bytes: &[u8]) -> bool {
164    bytes.split(|&b| b == b'\n').any(|line| MARKERS.iter().any(|marker| line.starts_with(marker)))
165}
166
167#[cfg(test)]
168mod tests {
169    use camino::Utf8Path;
170
171    use super::{Driver, expand, has_markers};
172    use crate::path::RelPath;
173
174    #[test]
175    fn parses_driver_lines() {
176        let Ok(Driver::Command { args, .. }) = "mergiraf merge --git %O %A %B -p '%P'".parse()
177        else {
178            panic!("valid driver");
179        };
180        assert_eq!(
181            args,
182            ["mergiraf", "merge", "--git", "%O", "%A", "%B", "-p", "%P"],
183            "split without a shell"
184        );
185        for bad in ["", "tool 'open", "tool %O %B", "tool %A %Q"] {
186            assert!(bad.parse::<Driver>().is_err(), "{bad:?} should be rejected");
187        }
188    }
189
190    #[test]
191    fn expands_placeholders() {
192        assert_eq!(expand("--base=%O", ["o", "a", "b", "p"]), "--base=o", "inside an argument");
193        assert_eq!(expand("100%%", ["o", "a", "b", "p"]), "100%", "escaped percent");
194    }
195
196    #[test]
197    fn builtin_merges_and_conflicts() {
198        let merge = |base: &str, ours: &str, theirs: &str| {
199            let path = RelPath::new("f.txt").unwrap();
200            Driver::Builtin
201                .merge(
202                    Utf8Path::new("."),
203                    &path,
204                    base.as_bytes(),
205                    ours.as_bytes(),
206                    theirs.as_bytes(),
207                )
208                .unwrap()
209        };
210        let clean = merge("a\nb\nc\n", "A\nb\nc\n", "a\nb\nC\n");
211        assert_eq!(
212            (clean.bytes.as_slice(), clean.conflict),
213            (&b"A\nb\nC\n"[..], None),
214            "disjoint edits merge"
215        );
216        let conflict = merge("a\n", "x\n", "y\n");
217        assert!(
218            conflict.conflict.is_some() && has_markers(&conflict.bytes),
219            "overlapping edits conflict"
220        );
221    }
222
223    #[test]
224    fn commands_follow_git_convention() {
225        let path = RelPath::new("f.txt").unwrap();
226        let run = |line: &str| {
227            line.parse::<Driver>()
228                .unwrap()
229                .merge(Utf8Path::new("."), &path, b"o\n", b"a\n", b"b\n")
230                .unwrap()
231        };
232        let theirs = run("cp %B %A");
233        assert_eq!(
234            (theirs.bytes.as_slice(), theirs.conflict),
235            (&b"b\n"[..], None),
236            "exit 0: result read from %A"
237        );
238        assert!(run("false %A").conflict.is_some(), "non-zero exit is a conflict");
239    }
240
241    #[test]
242    fn detects_markers() {
243        assert!(has_markers(b"a\n<<<<<<< ours\nb\n"), "opening marker");
244        assert!(!has_markers(b"Title\n=======\n"), "a Markdown underline is not a marker");
245    }
246}