Skip to main content

weave_cli/
preserve.rs

1//! The publish-time invariant: a merge keeps each side's changes.
2//!
3//! The gate ([`crate::land`]) judges the files BOTH sides changed. A file only
4//! one side changed has nothing to resolve, so the gate never reads it — and
5//! a merge whose tree is not git's merge (a half-applied merge, a merge git
6//! could not write to the index, a hand-made tree) can silently put such a
7//! file back to the merge base. This module checks the tree itself, however
8//! it was produced, over EVERY path in `base→ours` ∪ `base→theirs`:
9//!
10//! * a path only **theirs** changed must hold theirs' version, or a version
11//!   that still contains theirs' change (every token theirs added is there,
12//!   no token theirs deleted is back; the gate's DROPPED / UNDELETED measure,
13//!   the whole file read as one block). A deletion by theirs must stay
14//!   deleted; a file theirs changed must not be deleted;
15//! * a path only **ours** changed may be edited further by the merge (the
16//!   merge commit is ours' own work), but must not go back to base: not
17//!   restored to the base version, not deleted when ours changed it, not
18//!   brought back when ours deleted it.
19//!
20//! Paths both sides changed are the gate's. Any violation refuses, the file
21//! named.
22
23use std::collections::{BTreeMap, BTreeSet, HashMap};
24use std::path::Path;
25
26use serde::Serialize;
27
28use crate::gitscan;
29
30type R<T> = Result<T, Box<dyn std::error::Error>>;
31
32/// A path whose change by one side the merge does not keep.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct Violation {
35    pub path: String,
36    /// `"theirs"` or `"ours"`: whose change is lost.
37    pub side: &'static str,
38    pub detail: String,
39}
40
41/// `(mode, oid)` of a path after a change; `None` = deleted.
42type Entry = Option<(String, String)>;
43
44/// Every path `git diff --raw from to` reports, with its entry in `to`.
45fn changes(dir: &Path, from: &str, to: &str) -> R<BTreeMap<String, Entry>> {
46    let raw = gitscan::git(
47        dir,
48        &[
49            "diff",
50            "--raw",
51            "-z",
52            "--no-renames",
53            "--no-abbrev",
54            "--no-ext-diff",
55            "--no-textconv",
56            from,
57            to,
58        ],
59    )?;
60    let mut out = BTreeMap::new();
61    let mut parts = raw.split('\0').filter(|p| !p.is_empty());
62    while let Some(meta) = parts.next() {
63        let Some(path) = parts.next() else { break };
64        // :<old mode> <new mode> <old oid> <new oid> <status>
65        let f: Vec<&str> = meta.trim_start_matches(':').split(' ').collect();
66        if f.len() < 5 {
67            return Err(format!("could not read `git diff --raw` line `{meta}`").into());
68        }
69        let entry = if f[4].starts_with('D') {
70            None
71        } else {
72            Some((f[1].to_string(), f[3].to_string()))
73        };
74        out.insert(path.to_string(), entry);
75    }
76    Ok(out)
77}
78
79/// `git cat-file <args>` with weave's deadline: `(exit ok, stdout bytes)`.
80fn cat_file(dir: &Path, args: &[&str]) -> (bool, Vec<u8>) {
81    let mut cmd = std::process::Command::new("git");
82    cmd.arg("cat-file").args(args).current_dir(dir);
83    match gitscan::run_bounded(
84        cmd,
85        "git cat-file",
86        None,
87        std::time::Duration::from_secs(120),
88    ) {
89        Ok((status, out, _)) => (status.success(), out),
90        Err(_) => (false, Vec::new()),
91    }
92}
93
94/// The path's text at `rev`; `None` when it is absent or not UTF-8.
95fn blob(dir: &Path, rev: &str, path: &str) -> Option<String> {
96    match cat_file(dir, &["blob", &format!("{rev}:{path}")]) {
97        (true, bytes) => String::from_utf8(bytes).ok(),
98        _ => None,
99    }
100}
101
102/// Check the merge `result` (any tree-ish) of `ours` and `theirs` over
103/// `base`. Empty = every one-sided change is kept.
104pub fn check(dir: &Path, base: &str, ours: &str, theirs: &str, result: &str) -> R<Vec<Violation>> {
105    let o = changes(dir, base, ours)?;
106    let t = changes(dir, base, theirs)?;
107    let r = changes(dir, base, result)?;
108    let mut out = Vec::new();
109
110    for (path, te) in t.iter().filter(|(p, _)| !o.contains_key(*p)) {
111        // `None` here: the result has the path exactly as base has it.
112        let re = r.get(path);
113        if re == Some(te) {
114            continue;
115        }
116        let detail = match (te, re) {
117            (None, _) => Some("theirs deleted the file; the merge still has it".to_string()),
118            (Some(_), None) => Some(
119                "the merge has the file exactly as the merge base has it: theirs' change to it \
120                 is gone"
121                    .to_string(),
122            ),
123            (Some(_), Some(None)) => {
124                Some("the merge deletes the file, which theirs changed".to_string())
125            }
126            (Some(_), Some(Some(_))) => {
127                let b = blob(dir, base, path).or_else(|| {
128                    // absent at base: theirs added it
129                    (!rev_has(dir, base, path)).then(String::new)
130                });
131                match (b, blob(dir, theirs, path), blob(dir, result, path)) {
132                    (Some(b), Some(s), Some(m)) => contains_change(&b, &s, &m),
133                    _ => Some(
134                        "the merge's version differs from theirs and is not text weave can \
135                         compare"
136                            .to_string(),
137                    ),
138                }
139            }
140        };
141        if let Some(detail) = detail {
142            out.push(Violation {
143                path: path.clone(),
144                side: "theirs",
145                detail,
146            });
147        }
148    }
149
150    for (path, oe) in o.iter().filter(|(p, _)| !t.contains_key(*p)) {
151        let re = r.get(path);
152        if re == Some(oe) {
153            continue;
154        }
155        let detail = match (oe, re) {
156            (None, None) => Some(
157                "ours deleted the file; the merge has it back, as the merge base has it"
158                    .to_string(),
159            ),
160            (Some(_), None) => Some(
161                "the merge has the file exactly as the merge base has it: ours' change to it is \
162                 gone"
163                    .to_string(),
164            ),
165            (Some(_), Some(None)) => {
166                Some("the merge deletes the file, which ours changed".to_string())
167            }
168            _ => None, // the merge's own further edit of ours' file
169        };
170        if let Some(detail) = detail {
171            out.push(Violation {
172                path: path.clone(),
173                side: "ours",
174                detail,
175            });
176        }
177    }
178    Ok(out)
179}
180
181fn rev_has(dir: &Path, rev: &str, path: &str) -> bool {
182    cat_file(dir, &["-e", &format!("{rev}:{path}")]).0
183}
184
185/// The tree the index would commit, read without touching the index or its
186/// lock (a scratch copy of the index).
187pub fn index_tree(dir: &Path, gd: &Path) -> R<String> {
188    let scratch = gd.join(format!("weave-land-index-{}", std::process::id()));
189    std::fs::copy(gd.join("index"), &scratch)?;
190    let mut cmd = std::process::Command::new("git");
191    cmd.args(["write-tree"])
192        .current_dir(dir)
193        .env("GIT_INDEX_FILE", &scratch);
194    let run = gitscan::run_bounded(
195        cmd,
196        "git write-tree",
197        None,
198        std::time::Duration::from_secs(120),
199    );
200    let _ = std::fs::remove_file(&scratch);
201    let (status, stdout, stderr) = run?;
202    if !status.success() {
203        return Err(format!(
204            "git write-tree failed: {}",
205            String::from_utf8_lossy(&stderr).trim()
206        )
207        .into());
208    }
209    Ok(String::from_utf8_lossy(&stdout).trim().to_string())
210}
211
212/// The tree the merge in progress in `dir` would commit, once nothing is
213/// left unmerged; `None` when no merge is in progress or a path is still
214/// unmerged.
215pub fn merge_in_tree(dir: &Path) -> R<Option<String>> {
216    let gd =
217        std::path::PathBuf::from(gitscan::git(dir, &["rev-parse", "--absolute-git-dir"])?.trim());
218    if !gd.join("MERGE_HEAD").exists()
219        || !gitscan::git(dir, &["diff", "--name-only", "--diff-filter=U"])?
220            .trim()
221            .is_empty()
222    {
223        return Ok(None);
224    }
225    Ok(Some(index_tree(dir, &gd)?))
226}
227
228/// [`check`], keeping only the violations that hold at `now` too (the
229/// branch as it is now: a later commit that put the change back counts).
230pub fn check_now(
231    dir: &Path,
232    base: &str,
233    ours: &str,
234    theirs: &str,
235    result: &str,
236    now: &str,
237) -> R<Vec<Violation>> {
238    let at_merge = check(dir, base, ours, theirs, result)?;
239    if at_merge.is_empty() {
240        return Ok(at_merge);
241    }
242    let at_now: BTreeSet<(String, &'static str)> = check(dir, base, ours, theirs, now)?
243        .into_iter()
244        .map(|v| (v.path, v.side))
245        .collect();
246    Ok(at_merge
247        .into_iter()
248        .filter(|v| at_now.contains(&(v.path.clone(), v.side)))
249        .collect())
250}
251
252/// Whether `merged` still holds the change `base → side`, in tokens: every
253/// token the side added is there at least as often as the side has it, and
254/// no token the side deleted is there more often than the side has it.
255/// `None` = held; `Some(why)` = not.
256pub fn contains_change(base: &str, side: &str, merged: &str) -> Option<String> {
257    let (b, s, m) = (counts(base), counts(side), counts(merged));
258    let get = |c: &HashMap<&str, usize>, k: &str| c.get(k).copied().unwrap_or(0);
259    let mut missing: Vec<&str> = Vec::new();
260    let mut back: Vec<&str> = Vec::new();
261    for (k, &n) in &s {
262        if n > get(&b, k) && get(&m, k) < n {
263            missing.push(k);
264        }
265    }
266    for (k, &n) in &b {
267        let ns = get(&s, k);
268        if ns < n && get(&m, k) > ns {
269            back.push(k);
270        }
271    }
272    if missing.is_empty() && back.is_empty() {
273        return None;
274    }
275    let order = |v: &mut Vec<&str>| {
276        v.sort_by_key(|k| {
277            let word = k
278                .chars()
279                .next()
280                .is_some_and(|c| c.is_alphanumeric() || c == '_');
281            (!word, std::cmp::Reverse(k.len()), k.to_string())
282        })
283    };
284    order(&mut missing);
285    order(&mut back);
286    let names = |v: &[&str]| {
287        v.iter()
288            .take(5)
289            .map(|k| format!("`{k}`"))
290            .collect::<Vec<_>>()
291            .join(", ")
292    };
293    let mut parts = Vec::new();
294    if !missing.is_empty() {
295        parts.push(format!(
296            "{} token(s) theirs added are missing, e.g. {}",
297            missing.len(),
298            names(&missing)
299        ));
300    }
301    if !back.is_empty() {
302        parts.push(format!(
303            "{} token(s) theirs deleted are back, e.g. {}",
304            back.len(),
305            names(&back)
306        ));
307    }
308    Some(format!(
309        "the merge's version does not contain theirs' change: {}",
310        parts.join("; ")
311    ))
312}
313
314/// A change's tokens: word runs (letters, digits, `_`) and operator runs, as
315/// the gate's DROPPED rule reads them.
316fn tokens(line: &str) -> Vec<&str> {
317    const OPS: &str = "+-*/%=<>!&|^~@?";
318    let class = |c: char| {
319        if c.is_alphanumeric() || c == '_' {
320            1
321        } else if OPS.contains(c) {
322            2
323        } else {
324            0
325        }
326    };
327    let mut out = Vec::new();
328    let mut start: Option<(usize, u8)> = None;
329    for (i, c) in line
330        .char_indices()
331        .chain(std::iter::once((line.len(), ' ')))
332    {
333        let k = class(c);
334        match start {
335            Some((s, sk)) if sk != k => {
336                out.push(&line[s..i]);
337                start = (k != 0).then_some((i, k));
338            }
339            None if k != 0 => start = Some((i, k)),
340            _ => {}
341        }
342    }
343    out
344}
345
346fn counts(text: &str) -> HashMap<&str, usize> {
347    let mut m = HashMap::new();
348    for l in text.lines() {
349        for t in tokens(l) {
350            *m.entry(t).or_insert(0) += 1;
351        }
352    }
353    m
354}
355
356/// The violations as lines for a person: `  path  (side's change lost: …)`.
357pub fn render(v: &[Violation], ours: &str, theirs: &str) -> String {
358    let width = v.iter().map(|x| x.path.len()).max().unwrap_or(0);
359    v.iter()
360        .map(|x| {
361            let who = if x.side == "theirs" { theirs } else { ours };
362            format!(
363                "  {:<width$}  (changed only by {who}; that change is lost: {})",
364                x.path, x.detail
365            )
366        })
367        .collect::<Vec<_>>()
368        .join("\n")
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn a_revert_to_base_does_not_contain_the_change() {
377        let base = "package p\n\nfunc A() {}\n";
378        let theirs = "package p\n\nfunc A() {}\n\nfunc Shift(x int) int { return x << 1 }\n";
379        assert!(contains_change(base, theirs, theirs).is_none());
380        let why = contains_change(base, theirs, base).expect("a revert loses the change");
381        assert!(why.contains("`Shift`"), "{why}");
382        // the change plus an unrelated edit of the merge's own still holds it
383        let plus = theirs.replace("func A() {}", "func A() { _ = 1 }");
384        assert!(contains_change(base, theirs, &plus).is_none());
385        // a deletion by theirs coming back
386        assert!(contains_change(theirs, base, theirs).is_some());
387    }
388}