Skip to main content

weave_cli/
land.rs

1//! `weave land` — land a merge, and say for every file git could not merge how
2//! much is known about the result.
3//!
4//! Three labels, strongest first:
5//!
6//! * **PROVEN** — weave merged the file cleanly AND the independent merge
7//!   certificate (`weave-certify`, which shares no merge code with weave)
8//!   shows the result is the three-way selection of base, ours and theirs.
9//! * **VERIFIED** — the file's answer passed the exact [`gate`], and it came
10//!   from one of two places: an external resolver (any program; see
11//!   [`Resolver`]) wrote it, or weave's element union did — a clean merge
12//!   whose both-changed regions the certificate admits only with the
13//!   `elem_union` check ([`UNION_ALLOWANCES`]). No resolver is called for the
14//!   second; see [`land_unit`] for why it is VERIFIED and not PROVEN.
15//! * **REFUSED** — nothing above holds. The file keeps its conflict markers and
16//!   stays unmerged in the index; the resolver's answer is never written.
17//!
18//! In a blind evaluation, a cheap model's accepted merges behind this gate were
19//! wrong 9.5% of the time, against 22.0% without it — plus one rule added
20//! since, DROPPED, after an answer that kept only one side of a conflict block
21//! passed it. Every check that reads `weave check` is asked of weave's own
22//! verifier here ([`crate::worktree::check`]) rather than restated.
23//!
24//! Scope: the files git's line merge conflicts on (content, add/add,
25//! modify/delete), and every other file both sides changed whose merge — the
26//! answer in the tree, else git's line merge — is not git's line merge or
27//! fails weave's own merge check ([`weave_core::verify::verify`]: a line both
28//! kept lost, a line or a `case` label / map key stated twice). The rest are
29//! counted as `not_examined`: git merged them line-cleanly and the check
30//! passed. An answer the tree already holds (a file the merge driver
31//! resolved; `--result`'s file) is judged before any resolver. Each file
32//! is checked on its own three stages, as the gate was measured; a cross-file
33//! effect (a caller in another file) is outside it.
34
35use std::collections::{BTreeMap, BTreeSet, HashMap};
36use std::path::Path;
37use std::process::Command;
38use std::time::Duration;
39
40use serde::Serialize;
41use sha2::{Digest, Sha256};
42use weave_core::host::Host;
43use weave_core::verify::{line_counts, significant, unanimity_floor};
44use weave_core::{entity_merge_fmt, MarkerFormat};
45
46use crate::gitscan::{self, Body, Entry};
47use crate::parsers::{is_supported, REGISTRY};
48use crate::repo_scope::Tree;
49use crate::worktree;
50
51type R<T> = Result<T, Box<dyn std::error::Error>>;
52
53/// The certificate's rule set, v2: a both-changed region is certified only by
54/// one of these allowances. v1 had `imp_strict` where v2 has `imp_used` —
55/// `imp_strict` plus a check that every import line one side added that the
56/// merge keeps binds a name the merged file uses.
57pub const PROOF_ALLOWANCES: [&str; 3] = ["imp_used", "subsume_ins", "nest"];
58
59/// What else may admit a both-changed region of weave's clean merge, for a
60/// VERIFIED landing (never PROVEN): `elem_union`, the certificate's own check
61/// of an element union (`weave_certify::elem`), and `nest_eu`, `nest` with
62/// children it admits. Not in the proof rule set, so a file that
63/// needs one of these must also pass the gate.
64pub const UNION_ALLOWANCES: [&str; 2] = ["elem_union", "nest_eu"];
65
66/// Version tag of the JSON report / certificate document.
67pub const SCHEMA: &str = "weave-land/1";
68
69// ------------------------------------------------------------------ the unit
70
71/// How git's merge left the file.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
73pub enum Kind {
74    /// Both sides edited it and git's line merge conflicts.
75    #[serde(rename = "content")]
76    Content,
77    /// Both sides created it, differently.
78    #[serde(rename = "add/add")]
79    AddAdd,
80    /// One side deleted it; the other modified it.
81    #[serde(rename = "modify/delete")]
82    ModifyDelete,
83}
84
85impl Kind {
86    pub fn as_str(self) -> &'static str {
87        match self {
88            Kind::Content => "content",
89            Kind::AddAdd => "add/add",
90            Kind::ModifyDelete => "modify/delete",
91        }
92    }
93}
94
95/// One file git could not merge: its three stages (absent = `None`) and git's
96/// own line merge of it, conflict markers and all.
97#[derive(Debug, Clone)]
98pub struct Unit {
99    pub path: String,
100    pub kind: Kind,
101    pub base: Option<String>,
102    pub ours: Option<String>,
103    pub theirs: Option<String>,
104    /// `git merge-file` output (plain `merge` style); `None` for
105    /// modify/delete, where git writes no markers. For a file git merges
106    /// line-cleanly that is a unit anyway (see [`plan`]), git's clean text.
107    pub gitmerged: Option<String>,
108    /// The answer the tree already holds, when there is one to judge: in
109    /// working-tree mode a file the merge driver resolved (stage 0 in the
110    /// index), with `--result <rev>` the file at that rev. It is labelled
111    /// before any resolver is asked: PROVEN when it is weave's certified
112    /// merge, VERIFIED when it passes the [`gate`].
113    pub present: Option<Candidate>,
114    /// git merged the file line-cleanly, and that merge is known bad: it
115    /// failed weave's merge check, or the merge driver refused the file. An
116    /// answer then has to differ from git's somewhere (to state `case 3`
117    /// once, say), so the gate does not hold it to every line of git's merge
118    /// (AUTOMERGED); it holds it to both sides' changes, the whole file read
119    /// as one block (DROPPED / UNDELETED).
120    pub unverified_git_merge: bool,
121}
122
123impl Unit {
124    fn sides(&self) -> Vec<&str> {
125        [self.ours.as_deref(), self.theirs.as_deref()]
126            .into_iter()
127            .flatten()
128            .collect()
129    }
130}
131
132/// A proposed resolution of one file.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum Candidate {
135    File(String),
136    Delete,
137}
138
139// ------------------------------------------------------------------ the gate
140
141/// One reason the gate gave, in the words fed back to a resolver.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct GateFinding {
144    pub class: String,
145    pub detail: String,
146}
147
148/// The gate's answer: it passes iff `reasons` is empty.
149#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
150pub struct GateVerdict {
151    /// `MALFORMED` | `MARKERS` | `PARSE` | `WEAVE` | `WEAVE_UNVERIFIED` |
152    /// `LINES` | `AUTOMERGED` | `DROPPED`.
153    pub reasons: Vec<&'static str>,
154    pub findings: Vec<GateFinding>,
155}
156
157impl GateVerdict {
158    pub fn pass(&self) -> bool {
159        self.reasons.is_empty()
160    }
161
162    fn fail(&mut self, reason: &'static str, class: &str, detail: impl Into<String>) {
163        if !self.reasons.contains(&reason) {
164            self.reasons.push(reason);
165        }
166        self.findings.push(GateFinding {
167            class: class.to_string(),
168            detail: detail.into(),
169        });
170    }
171
172    /// What a retry is told: every finding, or the bare reasons when a check
173    /// failed without one.
174    pub fn feedback(&self) -> Vec<String> {
175        let lines: Vec<String> = self
176            .findings
177            .iter()
178            .map(|f| format!("{}: {}", f.class, f.detail))
179            .collect();
180        if lines.is_empty() {
181            self.reasons.iter().map(|r| r.to_string()).collect()
182        } else {
183            lines
184        }
185    }
186
187    fn malformed(why: &str) -> GateVerdict {
188        let mut v = GateVerdict::default();
189        v.fail("MALFORMED", "MALFORMED", why);
190        v
191    }
192}
193
194/// The exact gate: check `cand` against the file's three stages.
195///
196/// 1. **MARKERS** — a conflict-marker line kind neither side has.
197/// 2. **PARSE** — the candidate must parse (tree-sitter) when every present
198///    side does.
199/// 3. **WEAVE** — `weave check`'s findings on this file (markers, loss,
200///    duplicates, dangling names, duplicate data keys, a data file that no
201///    longer loads, modify/delete), minus duplicate/dangling names ours or
202///    theirs already trigger when taken as the resolution. A crash is
203///    `WEAVE_UNVERIFIED`, never a pass.
204/// 4. **LINES** — for a file weave has no grammar for, weave check's line
205///    rules (loss, duplication) alone.
206/// 5. **AUTOMERGED** — every line git merged automatically outside the
207///    conflict blocks, that base does not already have, is still there.
208/// 6. **DROPPED** — inside the conflict blocks, each side's change relative to
209///    base survives: every line one side added there is still there, and
210///    every line one side deleted there has not come back (see [`dropped`]).
211///    A resolution that keeps one side of a block verbatim fails this unless
212///    the other side's change is subsumed by it.
213pub fn gate(u: &Unit, cand: &Candidate) -> GateVerdict {
214    gate_with(u, cand, false)
215}
216
217/// Whether a `weave check` finding is the line-multiplicity rule (a line
218/// stated more often than any version states it).
219fn is_line_duplication(f: &worktree::Finding) -> bool {
220    f.class == "DUP"
221        && f.detail
222            .contains("line(s) appear more often than any version")
223}
224
225/// [`gate`]; with `merge_result`, the answer is a merge as committed — it may
226/// carry the committer's own new code beside the merge (a test table with
227/// one more `want any` row), so the line-multiplicity rule, which reads any
228/// extra copy of a line as a resolution stating it twice, is not asked. Keys
229/// stated twice, definitions stated twice, lost lines and both sides'
230/// changes still are.
231fn gate_with(u: &Unit, cand: &Candidate, merge_result: bool) -> GateVerdict {
232    let mut v = GateVerdict::default();
233    if let Candidate::File(c) = cand {
234        let theirs_or_ours: BTreeSet<&str> = u.sides().into_iter().flat_map(markers).collect();
235        if markers(c).iter().any(|m| !theirs_or_ours.contains(m)) {
236            v.fail(
237                "MARKERS",
238                "MARKERS",
239                "conflict marker lines are still present in the file",
240            );
241        }
242        let sides = u.sides();
243        if !sides.is_empty()
244            && sides.iter().all(|s| parses(&u.path, s) == Some(true))
245            && parses(&u.path, c) == Some(false)
246        {
247            v.fail(
248                "PARSE",
249                "PARSE",
250                "the file does not parse (syntax error), although both sides parse",
251            );
252        }
253    }
254
255    let work = match cand {
256        Candidate::File(c) => Some(c.as_str()),
257        Candidate::Delete => None,
258    };
259    if is_supported(&u.path) {
260        match weave_check(u, work) {
261            Err(why) => v.fail("WEAVE_UNVERIFIED", "UNVERIFIED", why),
262            Ok(found) if found.is_empty() => {}
263            Ok(found) => {
264                let pre = preexisting(u);
265                for f in found {
266                    if merge_result && is_line_duplication(&f) {
267                        continue;
268                    }
269                    let names = backticked(&f.detail);
270                    let key = (f.class.to_string(), finding_pattern(&f.detail));
271                    let already = matches!(f.class, "DUP" | "DANGLING")
272                        && !names.is_empty()
273                        && names
274                            .iter()
275                            .all(|n| pre.contains(&(key.clone(), n.clone())));
276                    if !already {
277                        v.fail("WEAVE", f.class, f.detail);
278                    }
279                }
280            }
281        }
282    } else if let Some(c) = work {
283        // No grammar: weave check's line rules are the whole structural answer.
284        if let Ok(found) = weave_check(u, Some(c)) {
285            for f in found
286                .into_iter()
287                .filter(|f| matches!(f.class, "LOSS" | "DUP"))
288                .filter(|f| !(merge_result && is_line_duplication(f)))
289            {
290                v.fail("LINES", f.class, f.detail);
291            }
292        }
293    }
294
295    if let Candidate::File(c) = cand {
296        let automerged = if u.unverified_git_merge {
297            None
298        } else {
299            automerged(u.base.as_deref(), u.gitmerged.as_deref(), c)
300        };
301        if let Some(detail) = automerged {
302            v.fail("AUTOMERGED", "AUTOMERGED", detail);
303        }
304        if let (Some(o), Some(t)) = (u.ours.as_deref(), u.theirs.as_deref()) {
305            let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
306                dropped(
307                    u.base.as_deref().unwrap_or(""),
308                    o,
309                    t,
310                    c,
311                    u.unverified_git_merge,
312                )
313                .map_err(|e| e.to_string())
314            }))
315            .unwrap_or_else(|_| Err("the check crashed on this file".into()));
316            match run {
317                Ok(found) => {
318                    for f in found {
319                        v.fail("DROPPED", &f.class, f.detail);
320                    }
321                }
322                Err(why) => v.fail(
323                    "DROPPED",
324                    "UNVERIFIED",
325                    format!("the conflict blocks could not be read: {why}"),
326                ),
327            }
328        }
329    }
330    v
331}
332
333/// The marker-line kinds present in `text` (`<<<<<<<`, `=======`, `>>>>>>>`,
334/// `|||||||` at the start of a line).
335fn markers(text: &str) -> BTreeSet<&'static str> {
336    const KINDS: [&str; 4] = ["<<<<<<<", "=======", ">>>>>>>", "|||||||"];
337    text.split('\n')
338        .filter_map(|l| KINDS.iter().find(|k| l.starts_with(**k)).copied())
339        .collect()
340}
341
342/// `Some(true)` = parses without an error node, `None` = no grammar.
343fn parses(path: &str, text: &str) -> Option<bool> {
344    let (_, tree) = REGISTRY.extract_entities_with_tree(path, text)?;
345    tree.map(|t| !t.root_node().has_error())
346}
347
348/// `weave check`'s findings on this one file, with only its three stages in
349/// scope — the minimal repository the gate was measured with. `work = None`
350/// is the file deleted.
351fn weave_check(u: &Unit, work: Option<&str>) -> Result<Vec<worktree::Finding>, String> {
352    let tree = |t: Option<&str>| -> Tree {
353        t.map(|c| (u.path.clone(), c.to_string()))
354            .into_iter()
355            .collect()
356    };
357    let (b, o, t, w) = (
358        tree(u.base.as_deref()),
359        tree(u.ours.as_deref()),
360        tree(u.theirs.as_deref()),
361        tree(work),
362    );
363    let subjects = [u.path.clone()];
364    let verdicts = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
365        worktree::check(&b, &o, &t, &w, &subjects)
366    }))
367    .map_err(|_| "weave check crashed on this file".to_string())?;
368    verdicts
369        .into_iter()
370        .find(|v| v.file == u.path)
371        .map(|v| v.findings)
372        .ok_or_else(|| "weave check gave no verdict for this file".to_string())
373}
374
375type NameKey = ((String, String), String);
376
377/// Names ours or theirs already trigger a duplicate/dangling finding for when
378/// taken as the resolution: pre-existing, not evidence against a resolution.
379fn preexisting(u: &Unit) -> BTreeSet<NameKey> {
380    let mut pre = BTreeSet::new();
381    for side in u.sides() {
382        for f in weave_check(u, Some(side)).unwrap_or_default() {
383            for n in backticked(&f.detail) {
384                pre.insert(((f.class.to_string(), finding_pattern(&f.detail)), n));
385            }
386        }
387    }
388    pre
389}
390
391/// Every `` `…` `` span's contents, left to right.
392fn backticked(detail: &str) -> Vec<String> {
393    let mut out = Vec::new();
394    let mut rest = detail;
395    while let Some(open) = rest.find('`') {
396        let after = &rest[open + 1..];
397        let Some(close) = after.find('`') else { break };
398        out.push(after[..close].to_string());
399        rest = &after[close + 1..];
400    }
401    out
402}
403
404/// A finding's shape with its names and numbers taken out, so two findings of
405/// one rule about different names compare equal.
406fn finding_pattern(detail: &str) -> String {
407    let mut out = String::new();
408    let mut rest = detail;
409    loop {
410        match rest.find('`') {
411            Some(open) if rest[open + 1..].contains('`') => {
412                out.push_str(&rest[..open]);
413                let after = &rest[open + 1..];
414                rest = &after[after.find('`').unwrap_or(0) + 1..];
415            }
416            _ => {
417                out.push_str(rest);
418                break;
419            }
420        }
421    }
422    out.chars().filter(|c| !c.is_ascii_digit()).collect()
423}
424
425/// The AUTOMERGED rule: a significant line git's merge placed outside every
426/// conflict block, and that base does not already state as often, must be in
427/// the candidate at least as often as git's merge states it there. For a
428/// file git merged line-cleanly (a unit only because its merge failed weave's
429/// merge check, or because the tree holds another answer) that is every line
430/// of git's merge: each side's change must survive there too.
431fn automerged(base: Option<&str>, gitmerged: Option<&str>, cand: &str) -> Option<String> {
432    let gitmerged = gitmerged?;
433    let mut depth = 0usize;
434    let mut outside: HashMap<&str, usize> = HashMap::new();
435    for l in gitmerged.lines() {
436        if l.starts_with("<<<<<<<") {
437            depth += 1;
438        } else if l.starts_with(">>>>>>>") && depth > 0 {
439            depth -= 1;
440        } else if depth == 0 && significant(l) {
441            *outside.entry(l.trim()).or_insert(0) += 1;
442        }
443    }
444    let (cb, cw) = (line_counts(base.unwrap_or("")), line_counts(cand));
445    let get = |m: &HashMap<&str, usize>, l: &str| m.get(l).copied().unwrap_or(0);
446    let mut miss: Vec<(&str, usize)> = outside
447        .iter()
448        .filter(|(l, n)| **n > get(&cb, l) && get(&cw, l) < **n)
449        .map(|(l, n)| (*l, n - get(&cw, l)))
450        .collect();
451    miss.sort();
452    let first = miss.first()?;
453    Some(format!(
454        "{} line(s) that git merged automatically (outside the conflict blocks) are missing, \
455         e.g. `{}`",
456        miss.iter().map(|(_, n)| n).sum::<usize>(),
457        first.0.chars().take(72).collect::<String>()
458    ))
459}
460
461/// The DROPPED rule, over git's diff3 line merge of the file: each conflict
462/// block, with its base section, is a region both sides changed. A faithful
463/// resolution keeps BOTH changes. A change is measured in tokens (identifier,
464/// number and word runs, and operator runs; brackets, commas and layout are
465/// not tokens), so a resolver may re-wrap lines or fold two edits of one line
466/// into one line, but may not leave a side's edit out:
467///
468/// * **DROPPED** — a token one side added in a block (more copies in its
469///   section than in base's) must survive. Per block a token is owed
470///   `max(added by ours, added by theirs)` times — the max, not the sum, is
471///   what lets an identical change on both sides, or one side's change that
472///   subsumes the other's, land once. Across the file the candidate must hold
473///   it at least `outside + Σ owed` times (`outside` = its copies outside
474///   every block).
475/// * **UNDELETED** — a token a side deleted in a block must not come back:
476///   the candidate may hold it at most `outside + Σ (base copies both sides
477///   kept + owed)` times. This keeps an in-block delete-vs-modify (one side
478///   removes what the other edits) refused whichever side is kept.
479///
480/// With `whole_file`, a merge git calls clean (no block at all) is read as
481/// one block over the whole file — for a file whose clean git merge is known
482/// bad, where each side's change must still survive in the answer.
483///
484/// `Ok(findings)`; empty = every block's two changes survive.
485fn dropped(
486    base: &str,
487    ours: &str,
488    theirs: &str,
489    cand: &str,
490    whole_file: bool,
491) -> R<Vec<GateFinding>> {
492    let mut blocks = diff3_blocks(base, ours, theirs)?;
493    if blocks.regions.is_empty() {
494        if !whole_file {
495            return Ok(Vec::new());
496        }
497        let lines = |t: &str| t.lines().map(str::to_string).collect::<Vec<_>>();
498        blocks = Diff3 {
499            regions: vec![[lines(ours), lines(base), lines(theirs)]],
500            outside: Vec::new(),
501        };
502    }
503    let get = |m: &HashMap<&str, usize>, k: &str| m.get(k).copied().unwrap_or(0);
504
505    #[derive(Default)]
506    struct Tally<'a> {
507        add: [usize; 2],
508        owed: usize,
509        kept: usize,
510        deleted_by: [bool; 2],
511        /// a line of each side's section that adds this token, for the finding
512        line: [Option<&'a str>; 2],
513    }
514    let mut tally: BTreeMap<&str, Tally> = BTreeMap::new();
515    for [o, b, t] in &blocks.regions {
516        let (co, cb, ct) = (tokens_of(o), tokens_of(b), tokens_of(t));
517        let keys: BTreeSet<&str> = co
518            .keys()
519            .chain(cb.keys())
520            .chain(ct.keys())
521            .copied()
522            .collect();
523        for k in keys {
524            let (n, a, z) = (get(&cb, k), get(&co, k), get(&ct, k));
525            let e = tally.entry(k).or_default();
526            let (ao, at) = (a.saturating_sub(n), z.saturating_sub(n));
527            e.add[0] += ao;
528            e.add[1] += at;
529            e.owed += ao.max(at);
530            e.kept += unanimity_floor(n, a, z);
531            e.deleted_by[0] |= a < n;
532            e.deleted_by[1] |= z < n;
533            for (side, (added, sec)) in [(ao, o), (at, t)].into_iter().enumerate() {
534                if added > 0 && e.line[side].is_none() {
535                    e.line[side] = sec
536                        .iter()
537                        .map(|l| l.trim())
538                        .find(|l| tokens(l).any(|x| x == k));
539                }
540            }
541        }
542    }
543    let outside = tokens_of(&blocks.outside);
544    let whole = [cand];
545    let cw = tokens_of(&whole);
546
547    // whose addition is short: 0 = ours, 1 = theirs, 2 = both together
548    let mut missing: [Vec<(&str, Option<&str>)>; 3] = Default::default();
549    let mut back: Vec<(&str, &'static str)> = Vec::new();
550    for (k, e) in &tally {
551        let (out, found) = (get(&outside, k), get(&cw, k));
552        if e.owed > 0 && found < out + e.owed {
553            let short = |s: usize| e.add[s] > 0 && found < out + e.add[s];
554            match (short(0), short(1)) {
555                (true, false) => missing[0].push((k, e.line[0])),
556                (false, true) => missing[1].push((k, e.line[1])),
557                _ => missing[2].push((k, e.line[0].or(e.line[1]))),
558            }
559        }
560        if (e.deleted_by[0] || e.deleted_by[1]) && found > out + e.kept + e.owed {
561            back.push((
562                k,
563                match e.deleted_by {
564                    [true, true] => "both sides",
565                    [true, false] => "ours",
566                    _ => "theirs",
567                },
568            ));
569        }
570    }
571
572    // name the most telling tokens first: words before operators, long first
573    for m in missing.iter_mut() {
574        m.sort_by_key(|(k, _)| {
575            let word = k
576                .chars()
577                .next()
578                .is_some_and(|c| c.is_alphanumeric() || c == '_');
579            (!word, std::cmp::Reverse(k.len()), *k)
580        });
581    }
582    let clip = |l: &str| l.chars().take(72).collect::<String>();
583    let names = |m: &[(&str, Option<&str>)]| {
584        m.iter()
585            .take(5)
586            .map(|(k, _)| format!("`{k}`"))
587            .collect::<Vec<_>>()
588            .join(", ")
589    };
590    let mut out = Vec::new();
591    for (side, who) in [(1usize, "theirs"), (0, "ours"), (2, "ours and theirs")] {
592        let m = &missing[side];
593        if m.is_empty() {
594            continue;
595        }
596        let line = m[0]
597            .1
598            .map(|l| format!(" (from `{}`)", clip(l)))
599            .unwrap_or_default();
600        out.push(GateFinding {
601            class: "DROPPED".into(),
602            detail: format!(
603                "{who} changed a conflict block and the resolution drops {}: {} token(s) {who} \
604                 added there are missing, e.g. {}{line}; keep both sides' changes",
605                if side == 2 {
606                    "part of it"
607                } else {
608                    "that change"
609                },
610                m.len(),
611                names(m),
612            ),
613        });
614    }
615    if !back.is_empty() {
616        out.push(GateFinding {
617            class: "UNDELETED".into(),
618            detail: format!(
619                "{} token(s) a side deleted inside a conflict block are back, e.g. `{}` \
620                 (deleted by {}); keep both sides' changes",
621                back.len(),
622                back[0].0,
623                back[0].1
624            ),
625        });
626    }
627    Ok(out)
628}
629
630/// git's diff3 merge of one file, split: every conflict block's three
631/// sections `[ours, base, theirs]`, and every line outside the blocks.
632struct Diff3 {
633    regions: Vec<[Vec<String>; 3]>,
634    outside: Vec<String>,
635}
636
637fn diff3_blocks(base: &str, ours: &str, theirs: &str) -> R<Diff3> {
638    // A marker size no source line plausibly starts with, so the sides' own
639    // marker-like lines are never read as structure.
640    const SIZE: usize = 41;
641    let merged = line_merge_with(
642        base,
643        ours,
644        theirs,
645        &["--diff3", &format!("--marker-size={SIZE}")],
646    )?
647    .1;
648    // bytes, not chars: a marker is ASCII, and a source line need not be
649    let marker = |l: &str, c: u8| {
650        let b = l.as_bytes();
651        b.len() >= SIZE
652            && b[..SIZE].iter().all(|x| *x == c)
653            && b.get(SIZE).is_none_or(|x| *x == b' ')
654    };
655    let mut d = Diff3 {
656        regions: Vec::new(),
657        outside: Vec::new(),
658    };
659    // 0 = outside, 1 = ours, 2 = base, 3 = theirs
660    let mut section = 0usize;
661    for l in merged.lines() {
662        match section {
663            0 if marker(l, b'<') => {
664                section = 1;
665                d.regions.push(Default::default());
666            }
667            1 if marker(l, b'|') => section = 2,
668            1 | 2 if marker(l, b'=') => section = 3,
669            3 if marker(l, b'>') => section = 0,
670            0 => d.outside.push(l.to_string()),
671            s => d.regions.last_mut().expect("in a block")[s - 1].push(l.to_string()),
672        }
673    }
674    if section != 0 {
675        return Err("git's diff3 merge has an unterminated conflict block".into());
676    }
677    Ok(d)
678}
679
680/// A change's tokens: word runs (letters, digits, `_`) and operator runs.
681/// Brackets, commas, quotes, `:`, `;` and whitespace are not tokens, so
682/// re-wrapping or re-punctuating a line changes nothing.
683fn tokens(line: &str) -> impl Iterator<Item = &str> {
684    const OPS: &str = "+-*/%=<>!&|^~@?";
685    let class = |c: char| {
686        if c.is_alphanumeric() || c == '_' {
687            1
688        } else if OPS.contains(c) {
689            2
690        } else {
691            0
692        }
693    };
694    let mut out = Vec::new();
695    let mut start: Option<(usize, u8)> = None;
696    for (i, c) in line
697        .char_indices()
698        .chain(std::iter::once((line.len(), ' ')))
699    {
700        let k = class(c);
701        match start {
702            Some((s, sk)) if sk != k => {
703                out.push(&line[s..i]);
704                start = (k != 0).then_some((i, k));
705            }
706            None if k != 0 => start = Some((i, k)),
707            _ => {}
708        }
709    }
710    out.into_iter()
711}
712
713fn tokens_of<S: AsRef<str>>(lines: &[S]) -> HashMap<&str, usize> {
714    let mut m: HashMap<&str, usize> = HashMap::new();
715    for l in lines {
716        for t in tokens(l.as_ref()) {
717            *m.entry(t).or_insert(0) += 1;
718        }
719    }
720    m
721}
722
723// ------------------------------------------------------------- the resolver
724
725/// An external resolver: a shell command run once per attempt.
726///
727/// stdin is one JSON object: `path`, `kind`, `base`, `ours`, `theirs`,
728/// `conflicted` (git's merge with markers; `null` for modify/delete),
729/// `attempt` (1 or 2), `previous` (the rejected answer, or `null`) and
730/// `findings` (why it was rejected). Absent sides are `null`.
731///
732/// stdout is the complete resolved file — or one line: `DELETE` / `KEEP`
733/// (modify/delete only: delete the file / keep the modified side as it is) or
734/// `CANNOT[: reason]`. A non-zero exit is a failed call.
735#[derive(Debug, Clone)]
736pub struct Resolver {
737    pub command: String,
738    pub timeout: Duration,
739}
740
741/// What the resolver said.
742#[derive(Debug, Clone, PartialEq, Eq)]
743pub enum Answer {
744    File(String),
745    Delete,
746    Keep,
747    Cannot(String),
748    /// Exited non-zero, timed out, or wrote something that is not UTF-8.
749    Failed(String),
750}
751
752impl Resolver {
753    pub fn ask(
754        &self,
755        u: &Unit,
756        attempt: usize,
757        previous: Option<&Candidate>,
758        findings: &[String],
759    ) -> R<Answer> {
760        let job = serde_json::json!({
761            "path": u.path,
762            "kind": u.kind,
763            "base": u.base,
764            "ours": u.ours,
765            "theirs": u.theirs,
766            "conflicted": u.gitmerged,
767            "attempt": attempt,
768            "previous": previous.map(|p| match p {
769                Candidate::File(c) => c.as_str(),
770                Candidate::Delete => "DELETE",
771            }),
772            "findings": findings,
773        });
774        let mut cmd = Command::new("sh");
775        cmd.arg("-c").arg(&self.command);
776        let run = gitscan::run_bounded(
777            cmd,
778            "the resolver",
779            Some(serde_json::to_vec(&job)?),
780            self.timeout,
781        );
782        let (status, stdout, stderr) = match run {
783            Ok(r) => r,
784            Err(e) => return Ok(Answer::Failed(e.to_string())),
785        };
786        // 126/127: the shell could not run the command at all. That is the
787        // invocation being wrong, not one file's answer.
788        if matches!(status.code(), Some(126) | Some(127)) {
789            return Err(format!(
790                "the resolver command could not be run: {}",
791                String::from_utf8_lossy(&stderr).trim()
792            )
793            .into());
794        }
795        if !status.success() {
796            let tail = String::from_utf8_lossy(&stderr);
797            let tail: String = tail.trim().chars().rev().take(300).collect();
798            return Ok(Answer::Failed(format!(
799                "the resolver exited {}: {}",
800                status
801                    .code()
802                    .map_or("on a signal".into(), |c| c.to_string()),
803                tail.chars().rev().collect::<String>()
804            )));
805        }
806        let Ok(out) = String::from_utf8(stdout) else {
807            return Ok(Answer::Failed("the resolver's answer is not UTF-8".into()));
808        };
809        Ok(parse_answer(out))
810    }
811}
812
813fn parse_answer(out: String) -> Answer {
814    let t = out.trim();
815    if !t.contains('\n') {
816        if t == "DELETE" {
817            return Answer::Delete;
818        }
819        if t == "KEEP" {
820            return Answer::Keep;
821        }
822        if let Some(rest) = t.strip_prefix("CANNOT") {
823            if rest.is_empty() || rest.starts_with(':') || rest.starts_with(' ') {
824                return Answer::Cannot(rest.trim_start_matches(':').trim().to_string());
825            }
826        }
827    }
828    Answer::File(out)
829}
830
831/// The resolved text's newline conventions follow the inputs': a trailing
832/// newline when every side ends in one, none when no side does, CRLF when
833/// every side is CRLF throughout.
834fn follow_newlines(mut body: String, sides: &[&str]) -> String {
835    if sides.is_empty() {
836        return body;
837    }
838    if sides.iter().all(|s| s.ends_with('\n')) && !body.is_empty() && !body.ends_with('\n') {
839        body.push('\n');
840    }
841    if !sides.iter().any(|s| s.ends_with('\n')) && body.ends_with('\n') {
842        body.pop();
843    }
844    let crlf = |s: &str| s.contains("\r\n") && s.matches("\r\n").count() == s.matches('\n').count();
845    if sides.iter().all(|s| crlf(s)) {
846        body = body.replace("\r\n", "\n").replace('\n', "\r\n");
847    }
848    body
849}
850
851// ----------------------------------------------------------------- landing
852
853#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
854#[serde(rename_all = "UPPERCASE")]
855pub enum Status {
856    Proven,
857    Verified,
858    Refused,
859}
860
861/// What to leave in the working tree for a file.
862#[derive(Debug, Clone, PartialEq, Eq)]
863pub enum Landed {
864    File(String),
865    Deleted,
866    /// Keep it conflicted: write these bytes (conflict markers), or leave the
867    /// working-tree file as git left it (`None`).
868    Conflicted(Option<String>),
869}
870
871/// One file's line in the report.
872#[derive(Debug, Clone, Serialize)]
873pub struct FileReport {
874    pub path: String,
875    pub kind: Kind,
876    pub status: Status,
877    /// `certificate` | `elem_union` | `present` | `gate` | `resolver` |
878    /// `no-resolver` | `not-text` | `keys`.
879    pub rule: &'static str,
880    /// One sentence.
881    pub reason: String,
882    /// The gate's reasons (empty unless the gate ran and failed).
883    pub reasons: Vec<&'static str>,
884    pub findings: Vec<GateFinding>,
885    /// Resolver calls made (0, 1 or 2).
886    pub attempts: usize,
887    /// What weave's merge did: `clean` | `conflicted` | `not-run`.
888    pub weave: &'static str,
889    /// sha256 of the bytes landed (PROVEN / VERIFIED file), else `null`.
890    pub sha256: Option<String>,
891    #[serde(skip)]
892    pub landed: Landed,
893}
894
895/// Land one file: weave, then the certificate, then the resolver behind the
896/// gate. `Err` only for a failure of the whole run (the resolver cannot be
897/// started at all).
898///
899/// A clean weave merge the certificate admits with its v2 rule set is PROVEN.
900/// One it admits only by also allowing [`UNION_ALLOWANCES`] — weave united
901/// elements two sides inserted into one collection, and the certificate's
902/// independent `elem_union` check, reading the four parse trees, found every
903/// inserted element verbatim, base order kept, no key twice, a construct the
904/// policy calls a set, and a result that parses — lands VERIFIED, after the
905/// same gate a resolver's answer must pass (DROPPED / UNDELETED included),
906/// with no resolver call. When the region-by-region verdict fails, the
907/// check also reads the whole file as one statement list (both sides
908/// appending named top-level declarations at one point). Not PROVEN: PROVEN means the certificate's
909/// fixed proof rule set, measured before it was adopted; `elem_union` is a
910/// policy about which collections are sets, not a three-way selection, and
911/// it has not been measured that way. Any doubt — the check declines or
912/// crashes, the gate fails — sends the file on to the resolver, as before.
913pub fn land_unit(u: &Unit, host: &Host, resolver: Option<&Resolver>) -> R<FileReport> {
914    let mut report = FileReport {
915        path: u.path.clone(),
916        kind: u.kind,
917        status: Status::Refused,
918        rule: "gate",
919        reason: String::new(),
920        reasons: Vec::new(),
921        findings: Vec::new(),
922        attempts: 0,
923        weave: "not-run",
924        sha256: None,
925        landed: Landed::Conflicted(u.gitmerged.as_deref().map(|g| {
926            if markers(g).is_empty() {
927                whole_file_conflict(u)
928            } else {
929                g.to_string()
930            }
931        })),
932    };
933
934    // ---- 1. weave, and 2. the certificate on a clean result
935    let mut unproven = String::new();
936    if let (Kind::Content | Kind::AddAdd, Some(ours), Some(theirs)) =
937        (u.kind, u.ours.as_deref(), u.theirs.as_deref())
938    {
939        let fmt = MarkerFormat::default().for_file(&u.path);
940        let base = u.base.as_deref().unwrap_or("");
941        let merged = entity_merge_fmt(base, ours, theirs, &u.path, &fmt, host);
942        // weave's labels describe weave's merge; when the tree already holds
943        // a different answer, that answer is the one to judge (step 3).
944        let describes_tree = match &u.present {
945            None => true,
946            Some(Candidate::File(p)) => *p == merged.content,
947            Some(Candidate::Delete) => false,
948        };
949        if merged.is_clean() && !describes_tree {
950            report.weave = "clean";
951            unproven = "the tree's answer is not weave's merge; ".into();
952        } else if merged.is_clean() {
953            report.weave = "clean";
954            let cert = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
955                certify(u, &merged.content)
956            }))
957            .unwrap_or_else(|_| Err("the certificate crashed on this file".into()));
958            match cert {
959                Ok((admitted, true)) => {
960                    let v = gate(u, &Candidate::File(merged.content.clone()));
961                    if v.pass() {
962                        report.status = Status::Verified;
963                        report.rule = "elem_union";
964                        report.reason = format!(
965                            "weave's element union passed the independent element check \
966                             (both-changed regions admitted by: {}) and the gate; no resolver \
967                             was asked",
968                            admitted.join(", ")
969                        );
970                        report.sha256 = Some(sha256(&merged.content));
971                        report.landed = Landed::File(merged.content);
972                        return Ok(report);
973                    }
974                    unproven = format!(
975                        "weave's element union passed the element check but not the gate ({}); ",
976                        v.reasons.join(", ")
977                    );
978                }
979                Ok((admitted, false)) => {
980                    report.status = Status::Proven;
981                    report.rule = "certificate";
982                    report.reason = if admitted.is_empty() {
983                        "weave's merge is the three-way selection of every region".into()
984                    } else {
985                        format!(
986                            "weave's merge is the three-way selection (both-changed regions \
987                             admitted by: {})",
988                            admitted.join(", ")
989                        )
990                    };
991                    report.sha256 = Some(sha256(&merged.content));
992                    report.landed = Landed::File(merged.content);
993                    return Ok(report);
994                }
995                Err(why) => unproven = format!("weave's clean merge is not certified ({why}); "),
996            }
997        } else {
998            report.weave = "conflicted";
999            report.landed = Landed::Conflicted(Some(weave_core::conflict::teach_on_marker(
1000                &merged.content,
1001                &u.path,
1002                fmt.marker_length,
1003            )));
1004        }
1005    }
1006
1007    // ---- 3. the answer the tree already holds, behind the gate
1008    if let Some(present) = &u.present {
1009        let v = gate_with(u, present, true);
1010        if v.pass() {
1011            report.status = Status::Verified;
1012            report.rule = "present";
1013            report.reason = format!(
1014                "{unproven}the merge's own result (the merge driver's, or the result \
1015                 revision's) passed the gate; no resolver was asked"
1016            );
1017            report.landed = match present {
1018                Candidate::File(c) => {
1019                    report.sha256 = Some(sha256(c));
1020                    Landed::File(c.clone())
1021                }
1022                Candidate::Delete => Landed::Deleted,
1023            };
1024            return Ok(report);
1025        }
1026        unproven = format!(
1027            "{unproven}the merge's own result failed the gate ({}); ",
1028            v.reasons.join(", ")
1029        );
1030        report.reasons = v.reasons;
1031        report.findings = v.findings;
1032    }
1033
1034    // ---- 4. the resolver, behind 5. the gate, with at most one retry
1035    let Some(resolver) = resolver else {
1036        report.rule = "no-resolver";
1037        report.reason = format!("{unproven}no resolver was given (--resolver)");
1038        return Ok(report);
1039    };
1040    let mut previous: Option<Candidate> = None;
1041    let mut feedback: Vec<String> = Vec::new();
1042    for attempt in 1..=2 {
1043        report.attempts = attempt;
1044        let answer = resolver.ask(u, attempt, previous.as_ref(), &feedback)?;
1045        let modify_delete = u.kind == Kind::ModifyDelete;
1046        let (cand, verdict) = match answer {
1047            Answer::Cannot(why) => {
1048                report.rule = "resolver";
1049                report.reason = format!(
1050                    "{unproven}the resolver declined: {}",
1051                    if why.is_empty() {
1052                        "no reason given"
1053                    } else {
1054                        &why
1055                    }
1056                );
1057                return Ok(report);
1058            }
1059            Answer::Failed(why) => {
1060                report.rule = "resolver";
1061                report.reason = format!("{unproven}{why}");
1062                return Ok(report);
1063            }
1064            Answer::Delete if modify_delete => (Candidate::Delete, None),
1065            Answer::Keep if modify_delete => {
1066                let kept = u
1067                    .ours
1068                    .clone()
1069                    .or_else(|| u.theirs.clone())
1070                    .unwrap_or_default();
1071                (Candidate::File(kept), None)
1072            }
1073            Answer::Delete | Answer::Keep => (
1074                Candidate::File(String::new()),
1075                Some(GateVerdict::malformed(
1076                    "DELETE and KEEP answer a modify/delete conflict only; this file needs \
1077                     its complete resolved content",
1078                )),
1079            ),
1080            Answer::File(text) if text.trim().is_empty() => (
1081                Candidate::File(text),
1082                Some(GateVerdict::malformed("the answer was empty")),
1083            ),
1084            Answer::File(text) => (Candidate::File(follow_newlines(text, &u.sides())), None),
1085        };
1086        let verdict = verdict.unwrap_or_else(|| gate(u, &cand));
1087        if verdict.pass() {
1088            report.status = Status::Verified;
1089            report.rule = "gate";
1090            report.reason =
1091                format!("{unproven}the resolver's answer passed the gate on attempt {attempt}");
1092            report.reasons.clear();
1093            report.findings.clear();
1094            report.landed = match cand {
1095                Candidate::File(c) => {
1096                    report.sha256 = Some(sha256(&c));
1097                    Landed::File(c)
1098                }
1099                Candidate::Delete => Landed::Deleted,
1100            };
1101            return Ok(report);
1102        }
1103        feedback = verdict.feedback();
1104        report.reasons = verdict.reasons;
1105        report.findings = verdict.findings;
1106        previous = Some(cand);
1107    }
1108    report.rule = "gate";
1109    report.reason = format!(
1110        "{unproven}the resolver's answer failed the gate twice: {}",
1111        report.reasons.join(", ")
1112    );
1113    Ok(report)
1114}
1115
1116/// One conflict box over the whole file, ours against theirs: what a
1117/// refused file git merged line-cleanly is left as, so that it reads as
1118/// unresolved and not as git's merge.
1119fn whole_file_conflict(u: &Unit) -> String {
1120    let side = |t: Option<&str>| {
1121        let t = t.unwrap_or("");
1122        if t.is_empty() || t.ends_with('\n') {
1123            t.to_string()
1124        } else {
1125            format!("{t}\n")
1126        }
1127    };
1128    format!(
1129        "<<<<<<< ours\n{}=======\n{}>>>>>>> theirs\n",
1130        side(u.ours.as_deref()),
1131        side(u.theirs.as_deref())
1132    )
1133}
1134
1135/// The certificate on weave's clean merge: `Ok((allowances that admitted a
1136/// both-changed region, whether one needed [`UNION_ALLOWANCES`]))` when every
1137/// both-changed region is admitted — or, failing that, when `elem_union`
1138/// admits the whole file as one statement list — and `Err(why not)`
1139/// otherwise.
1140fn certify(u: &Unit, merged: &str) -> Result<(Vec<&'static str>, bool), String> {
1141    use weave_certify::{check, decompose, normalize};
1142    let version = |t: Option<&str>| t.map(|t| decompose(&REGISTRY, &u.path, &normalize(t)));
1143    let (o, a, b) = (
1144        version(u.base.as_deref()),
1145        version(u.ours.as_deref()),
1146        version(u.theirs.as_deref()),
1147    );
1148    let m = decompose(&REGISTRY, &u.path, &normalize(merged));
1149    let r = check(&u.path, o.as_ref(), a.as_ref(), b.as_ref(), &m);
1150    by_region(&r).or_else(|why| {
1151        // Both sides inserting top-level declarations at one point leaves the
1152        // text after an inserted entity a region neither side wrote; the
1153        // whole file read as one statement list may still be a union.
1154        match (a.as_ref(), b.as_ref()) {
1155            (Some(a), Some(b))
1156                if weave_certify::elem_union_file(&u.path, o.as_ref(), a, b, &m).is_ok() =>
1157            {
1158                Ok((vec!["elem_union (whole file)"], true))
1159            }
1160            _ => Err(why),
1161        }
1162    })
1163}
1164
1165/// The certificate's verdict region by region (see [`certify`]).
1166fn by_region(r: &weave_certify::Report) -> Result<(Vec<&'static str>, bool), String> {
1167    if let Some((rule, key)) = r.hard.first() {
1168        return Err(format!("{rule} at `{key}`"));
1169    }
1170    let mut admitted: Vec<&'static str> = Vec::new();
1171    let mut union = false;
1172    for (key, verdicts) in &r.both {
1173        let admits = |set: &[&str]| {
1174            verdicts
1175                .iter()
1176                .find(|(a, s)| set.contains(a) && *s == "admit")
1177                .map(|(a, _)| *a)
1178        };
1179        let by = admits(&PROOF_ALLOWANCES).or_else(|| {
1180            let a = admits(&UNION_ALLOWANCES);
1181            union |= a.is_some();
1182            a
1183        });
1184        match by {
1185            Some(a) if !admitted.contains(&a) => admitted.push(a),
1186            Some(_) => {}
1187            None => {
1188                return Err(format!(
1189                    "both sides changed `{key}` and no allowance admits the result"
1190                ))
1191            }
1192        }
1193    }
1194    Ok((admitted, union))
1195}
1196
1197fn sha256(text: &str) -> String {
1198    Sha256::digest(text.as_bytes())
1199        .iter()
1200        .map(|b| format!("{b:02x}"))
1201        .collect()
1202}
1203
1204// ----------------------------------------------------------------- the plan
1205
1206/// Where the answer already in the tree is read, for [`plan`].
1207#[derive(Debug, Clone, PartialEq, Eq)]
1208pub enum Present {
1209    /// The working tree, for every path the index holds at stage 0 (a file
1210    /// the merge driver resolved, or git merged) — and, with `unmerged`, for
1211    /// a conflicted path too once the file on disk has no conflict-marker
1212    /// line left (someone resolved it in place and did not stage it).
1213    WorkingTree { unmerged: bool },
1214    /// The tree of this revision (a merge commit being re-checked).
1215    Rev(String),
1216}
1217
1218/// The merge to land, read out of git.
1219pub struct Plan {
1220    pub base: String,
1221    pub ours: String,
1222    pub theirs: String,
1223    pub units: Vec<Unit>,
1224    /// Files git could not merge that are not text weave can read (binary,
1225    /// symlink, submodule, unreadable blob): refused before anything runs.
1226    pub not_text: Vec<FileReport>,
1227    /// Files both sides changed that git's line merge merges cleanly, and
1228    /// whose merge — the answer in the tree, else git's — is git's own and
1229    /// passes weave's merge check ([`weave_core::verify::verify`]).
1230    pub not_examined: Vec<String>,
1231    /// Each conflicted path's index entries at base / ours / theirs, for
1232    /// restoring a refused file's unmerged stages.
1233    pub stages: BTreeMap<String, [Option<(String, String)>; 3]>,
1234}
1235
1236/// Read the merge `base` × `ours` × `theirs` (each defaulting as `weave check`
1237/// does: HEAD, the operation in progress, their merge base) and classify every
1238/// file both sides changed.
1239///
1240/// A file git's line merge merges cleanly is still examined: when the answer
1241/// in the tree (`present`) is git's merge, that merge must pass weave's own
1242/// merge check — no line both sides kept lost, nothing stated twice, no
1243/// `case` or map key twice ([`weave_core::verify::verify`]); git calling it
1244/// clean is not enough (two features each adding `case 3:` to one switch is
1245/// line-clean). When it fails, or the tree holds something else, the file is
1246/// a unit like any conflicted one.
1247pub fn plan(
1248    dir: &Path,
1249    base: Option<&str>,
1250    ours: Option<&str>,
1251    theirs: Option<&str>,
1252    present: Option<&Present>,
1253) -> R<Plan> {
1254    let (base, ours, theirs) = gitscan::resolve_revs(dir, base, ours, theirs)?;
1255    let oid = |rev: &str| -> R<String> {
1256        Ok(gitscan::git(dir, &["rev-parse", "--verify", rev])?
1257            .trim()
1258            .to_string())
1259    };
1260    let (base, ours, theirs) = (oid(&base)?, oid(&ours)?, oid(&theirs)?);
1261    let changed = |side: &str| -> R<BTreeSet<String>> {
1262        Ok(gitscan::git(
1263            dir,
1264            &["diff", "--name-only", "--no-renames", "-z", &base, side],
1265        )?
1266        .split('\0')
1267        .filter(|p| !p.is_empty())
1268        .map(str::to_string)
1269        .collect())
1270    };
1271    let both: Vec<String> = changed(&ours)?
1272        .intersection(&changed(&theirs)?)
1273        .cloned()
1274        .collect();
1275    let [eb, eo, et] = [&base, &ours, &theirs].map(|rev| gitscan::entries_at_rev(dir, rev, &both));
1276    let (eb, eo, et) = (eb?, eo?, et?);
1277    let (present_of, unmerged) = present_answers(dir, present, &both)?;
1278
1279    let mut plan = Plan {
1280        base,
1281        ours,
1282        theirs,
1283        units: Vec::new(),
1284        not_text: Vec::new(),
1285        not_examined: Vec::new(),
1286        stages: BTreeMap::new(),
1287    };
1288    for path in both {
1289        let (b, o, t) = (eb.get(&path), eo.get(&path), et.get(&path));
1290        let same = |x: Option<&Entry>, y: Option<&Entry>| {
1291            x.map(|e| (&e.mode, &e.oid)) == y.map(|e| (&e.mode, &e.oid))
1292        };
1293        if same(o, t) {
1294            continue; // both sides made the same change
1295        }
1296        let kind = match (b, o, t) {
1297            (_, None, _) | (_, _, None) => Kind::ModifyDelete,
1298            (None, _, _) => Kind::AddAdd,
1299            _ => Kind::Content,
1300        };
1301        let entry = |e: Option<&Entry>| e.map(|e| (e.mode.clone(), e.oid.clone()));
1302        let stages = [entry(b), entry(o), entry(t)];
1303        let odd = [b, o, t].into_iter().flatten().find_map(|e| match &e.body {
1304            Body::Text(_) => None,
1305            Body::Binary => Some("a merge stage is binary (not UTF-8)".to_string()),
1306            Body::Irregular => Some("a merge stage is a symlink or a submodule".to_string()),
1307            Body::Unreadable(why) => Some(format!("a merge stage could not be read: {why}")),
1308        });
1309        if let Some(why) = odd {
1310            plan.stages.insert(path.clone(), stages);
1311            plan.not_text.push(FileReport {
1312                path,
1313                kind,
1314                status: Status::Refused,
1315                rule: "not-text",
1316                reason: format!("{why}; not attempted"),
1317                reasons: Vec::new(),
1318                findings: Vec::new(),
1319                attempts: 0,
1320                weave: "not-run",
1321                sha256: None,
1322                landed: Landed::Conflicted(None),
1323            });
1324            continue;
1325        }
1326        let text = |e: Option<&Entry>| match e.map(|e| &e.body) {
1327            Some(Body::Text(t)) => Some(t.clone()),
1328            _ => None,
1329        };
1330        let (base_text, ours_text, theirs_text) = (text(b), text(o), text(t));
1331        let present_answer = present_of.get(&path).cloned();
1332        let mut unverified_git_merge = false;
1333        let gitmerged = match (kind, &ours_text, &theirs_text) {
1334            (Kind::ModifyDelete, _, _) => None,
1335            (_, Some(o), Some(t)) => {
1336                let base_t = base_text.as_deref().unwrap_or("");
1337                let (clean, merged) = line_merge(base_t, o, t)?;
1338                if clean {
1339                    // A path the index holds unmerged was refused by the
1340                    // merge driver: never waved through on git's line merge.
1341                    let holds =
1342                        !unmerged.contains(&path) && line_clean_holds(&path, base_t, o, t, &merged);
1343                    let answer = match &present_answer {
1344                        Some(Candidate::File(p)) => Some(p.as_str()),
1345                        Some(Candidate::Delete) => None,
1346                        None => Some(merged.as_str()),
1347                    };
1348                    if holds && answer == Some(merged.as_str()) {
1349                        plan.not_examined.push(path);
1350                        continue;
1351                    }
1352                    unverified_git_merge = !holds;
1353                }
1354                Some(merged)
1355            }
1356            _ => None,
1357        };
1358        plan.stages.insert(path.clone(), stages);
1359        plan.units.push(Unit {
1360            path,
1361            kind,
1362            base: base_text,
1363            ours: ours_text,
1364            theirs: theirs_text,
1365            gitmerged,
1366            present: present_answer,
1367            unverified_git_merge,
1368        });
1369    }
1370    Ok(plan)
1371}
1372
1373/// git's clean line merge of a file passes the check weave's own merge must
1374/// pass before it may call a merge clean. Files too large or binary for
1375/// structure keep git's guarantee, as weave's merge does.
1376fn line_clean_holds(path: &str, base: &str, ours: &str, theirs: &str, merged: &str) -> bool {
1377    if [base, ours, theirs, merged]
1378        .iter()
1379        .any(|t| t.len() > weave_core::merge::STRUCTURE_LIMIT_BYTES)
1380    {
1381        return true;
1382    }
1383    let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1384        weave_core::verify::verify(base, ours, theirs, merged, path, &REGISTRY, &[])
1385    }));
1386    matches!(run, Ok(None))
1387}
1388
1389/// The answer already in the tree for each path, per [`Present`]: a file's
1390/// text, or `Delete` when the tree does not have it. A path the working
1391/// tree's index holds unmerged has no answer here unless `unmerged` asks for
1392/// in-place resolutions — a conflicted file is the resolver's to answer.
1393/// Also: the paths the index holds unmerged (working-tree mode only).
1394fn present_answers(
1395    dir: &Path,
1396    present: Option<&Present>,
1397    paths: &[String],
1398) -> R<(BTreeMap<String, Candidate>, BTreeSet<String>)> {
1399    let mut out = BTreeMap::new();
1400    let mut unmerged_paths = BTreeSet::new();
1401    match present {
1402        None => {}
1403        Some(Present::Rev(rev)) => {
1404            let at = gitscan::entries_at_rev(dir, rev, paths)?;
1405            for p in paths {
1406                match at.get(p).map(|e| &e.body) {
1407                    Some(Body::Text(t)) => {
1408                        out.insert(p.clone(), Candidate::File(t.clone()));
1409                    }
1410                    Some(_) => {}
1411                    None => {
1412                        out.insert(p.clone(), Candidate::Delete);
1413                    }
1414                }
1415            }
1416        }
1417        Some(Present::WorkingTree {
1418            unmerged: take_unmerged,
1419        }) => {
1420            let unmerged: BTreeSet<String> =
1421                gitscan::git(dir, &["diff", "--name-only", "--diff-filter=U", "-z"])?
1422                    .split('\0')
1423                    .filter(|p| !p.is_empty())
1424                    .map(str::to_string)
1425                    .collect();
1426            unmerged_paths = unmerged.clone();
1427            for p in paths
1428                .iter()
1429                .filter(|p| *take_unmerged || !unmerged.contains(*p))
1430            {
1431                let on_disk = dir.join(p);
1432                match std::fs::read(&on_disk) {
1433                    Ok(bytes) => {
1434                        if let Ok(t) = String::from_utf8(bytes) {
1435                            if !unmerged.contains(p) || markers(&t).is_empty() {
1436                                out.insert(p.clone(), Candidate::File(t));
1437                            }
1438                        }
1439                    }
1440                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1441                        out.insert(p.clone(), Candidate::Delete);
1442                    }
1443                    Err(_) => {}
1444                }
1445            }
1446        }
1447    }
1448    Ok((out, unmerged_paths))
1449}
1450
1451/// git's own line merge of one file, as `git merge` writes it (plain `merge`
1452/// conflict style): `(clean, text)`.
1453fn line_merge(base: &str, ours: &str, theirs: &str) -> R<(bool, String)> {
1454    line_merge_with(base, ours, theirs, &[])
1455}
1456
1457/// [`line_merge`] with extra `git merge-file` options (a conflict style).
1458fn line_merge_with(base: &str, ours: &str, theirs: &str, opts: &[&str]) -> R<(bool, String)> {
1459    use std::sync::atomic::{AtomicUsize, Ordering};
1460    static SEQ: AtomicUsize = AtomicUsize::new(0);
1461    let dir = std::env::temp_dir().join(format!(
1462        "weave-land-{}-{}",
1463        std::process::id(),
1464        SEQ.fetch_add(1, Ordering::Relaxed)
1465    ));
1466    std::fs::create_dir_all(&dir)?;
1467    let result = (|| -> R<(bool, String)> {
1468        for (name, text) in [("ours", ours), ("base", base), ("theirs", theirs)] {
1469            std::fs::write(dir.join(name), text)?;
1470        }
1471        let mut cmd = Command::new("git");
1472        cmd.args([
1473            "merge-file",
1474            "-p",
1475            "-L",
1476            "ours",
1477            "-L",
1478            "base",
1479            "-L",
1480            "theirs",
1481        ])
1482        .args(opts)
1483        .args(["ours", "base", "theirs"])
1484        .current_dir(&dir)
1485        // No user config: `merge.conflictStyle` would change which lines
1486        // count as merged automatically.
1487        .env("GIT_CONFIG_NOSYSTEM", "1")
1488        .env("GIT_CONFIG_GLOBAL", "/dev/null");
1489        let (status, stdout, stderr) =
1490            gitscan::run_bounded(cmd, "git merge-file", None, Duration::from_secs(120))?;
1491        match status.code() {
1492            Some(c @ 0..=127) => Ok((c == 0, String::from_utf8(stdout)?)),
1493            _ => Err(format!(
1494                "git merge-file failed: {}",
1495                String::from_utf8_lossy(&stderr).trim()
1496            )
1497            .into()),
1498        }
1499    })();
1500    let _ = std::fs::remove_dir_all(&dir);
1501    result
1502}
1503
1504// ------------------------------------------------------------ the write-back
1505
1506/// Write every file's outcome into the working tree and index at `dir`:
1507/// PROVEN / VERIFIED files are written and staged (a verified DELETE is
1508/// removed); a REFUSED file keeps its conflict markers and is put back to
1509/// unmerged, so `git commit` refuses until someone resolves it.
1510pub fn write_back(dir: &Path, plan: &Plan, reports: &[FileReport]) -> R<()> {
1511    for r in reports {
1512        let on_disk = dir.join(&r.path);
1513        match &r.landed {
1514            Landed::File(text) => {
1515                if let Some(parent) = on_disk.parent() {
1516                    std::fs::create_dir_all(parent)?;
1517                }
1518                std::fs::write(&on_disk, text)?;
1519                gitscan::git(dir, &["add", "--", &r.path])?;
1520            }
1521            Landed::Deleted => {
1522                gitscan::git(
1523                    dir,
1524                    &["rm", "-q", "--cached", "--ignore-unmatch", "--", &r.path],
1525                )?;
1526                if on_disk.symlink_metadata().is_ok() {
1527                    std::fs::remove_file(&on_disk)?;
1528                }
1529            }
1530            Landed::Conflicted(text) => {
1531                if let Some(text) = text {
1532                    if let Some(parent) = on_disk.parent() {
1533                        std::fs::create_dir_all(parent)?;
1534                    }
1535                    std::fs::write(&on_disk, text)?;
1536                }
1537                let Some(stages) = plan.stages.get(&r.path) else {
1538                    continue;
1539                };
1540                // Drop whatever stage-0 entry a merge driver left, and put the
1541                // three merge stages back.
1542                let mut info = format!("0 {}\t{}\n", "0".repeat(40), r.path);
1543                for (n, entry) in stages.iter().enumerate() {
1544                    if let Some((mode, oid)) = entry {
1545                        info.push_str(&format!("{mode} {oid} {}\t{}\n", n + 1, r.path));
1546                    }
1547                }
1548                gitscan::git_input(
1549                    dir,
1550                    &["update-index", "--index-info"],
1551                    Some(info.into_bytes()),
1552                )?;
1553            }
1554        }
1555    }
1556    Ok(())
1557}
1558
1559// ------------------------------------------------------------------- the run
1560
1561/// A non-publishing landing in `dir`: the merge `weave land` (without
1562/// `--onto`/`--queue`) runs, shared by the CLI and the MCP server.
1563pub struct Run<'a> {
1564    pub base: Option<&'a str>,
1565    pub ours: Option<&'a str>,
1566    pub theirs: Option<&'a str>,
1567    /// Judge this revision's files as the merge's answer.
1568    pub result: Option<&'a str>,
1569    pub resolver: Option<Resolver>,
1570    /// In the working tree: label every file but write nothing.
1571    pub dry_run: bool,
1572}
1573
1574/// What a [`run`] decided.
1575pub struct Landing {
1576    /// The JSON report (`--json`, `--certificate`).
1577    pub doc: serde_json::Value,
1578    /// The human summary.
1579    pub text: String,
1580    /// Some file REFUSED, or the result loses a one-sided change.
1581    pub refused: bool,
1582    /// `revisions`, `dry-run` or `working-tree`; only the last writes.
1583    pub mode: &'static str,
1584}
1585
1586/// Plan the merge in `dir`, land every unit, and in the working tree write
1587/// the PROVEN and VERIFIED files back.
1588pub fn run(dir: &Path, args: &Run<'_>, host: &Host) -> R<Landing> {
1589    let revisions = args.base.is_some() || args.ours.is_some() || args.theirs.is_some();
1590    let present = match (revisions, args.result) {
1591        (_, Some(rev)) => Some(Present::Rev(rev.to_string())),
1592        (false, None) => Some(Present::WorkingTree { unmerged: false }),
1593        (true, None) => None,
1594    };
1595    let plan = plan(dir, args.base, args.ours, args.theirs, present.as_ref())?;
1596
1597    let mut reports = plan.not_text.clone();
1598    for unit in &plan.units {
1599        reports.push(land_unit(unit, host, args.resolver.as_ref())?);
1600    }
1601    reports.sort_by(|a, b| a.path.cmp(&b.path));
1602
1603    let mode = if revisions {
1604        "revisions"
1605    } else if args.dry_run {
1606        "dry-run"
1607    } else {
1608        "working-tree"
1609    };
1610    if mode == "working-tree" {
1611        write_back(dir, &plan, &reports)?;
1612    }
1613    // A merge as committed (`result`): every change only one side made must
1614    // be in it too — files the gate never reads, since nothing in them
1615    // conflicts. In the working tree, once nothing is left unmerged: the tree
1616    // the merge commit would hold, however it came about.
1617    let judged = match args.result {
1618        Some(result) => Some(result.to_string()),
1619        None if mode == "working-tree" => crate::preserve::merge_in_tree(dir)?,
1620        None => None,
1621    };
1622    let lost = match &judged {
1623        Some(result) => crate::preserve::check(dir, &plan.base, &plan.ours, &plan.theirs, result)?,
1624        None => Vec::new(),
1625    };
1626    let mut doc = document(&plan, &reports, mode);
1627    if judged.is_some() {
1628        doc["one_sided"] = serde_json::to_value(&lost)?;
1629    }
1630    let mut text = render(&plan, &reports);
1631    if !lost.is_empty() {
1632        text.push_str(&format!(
1633            "REFUSED: the result loses changes only one side made:\n{}\n",
1634            crate::preserve::render(&lost, "ours", "theirs")
1635        ));
1636    }
1637    if mode != "working-tree" {
1638        text.push_str(&format!(
1639            "({mode}: nothing was written to the working tree or the index)\n"
1640        ));
1641    }
1642    let refused = !lost.is_empty() || reports.iter().any(|r| r.status == Status::Refused);
1643    Ok(Landing {
1644        doc,
1645        text,
1646        refused,
1647        mode,
1648    })
1649}
1650
1651// ----------------------------------------------------------------- the report
1652
1653/// The whole run, as `--json` prints it and `--certificate` writes it.
1654pub fn document(plan: &Plan, reports: &[FileReport], mode: &str) -> serde_json::Value {
1655    let count = |s: Status| reports.iter().filter(|r| r.status == s).count();
1656    serde_json::json!({
1657        "schema": SCHEMA,
1658        "merge": {
1659            "base": plan.base,
1660            "ours": plan.ours,
1661            "theirs": plan.theirs,
1662            "mode": mode,
1663        },
1664        "proof_allowances": PROOF_ALLOWANCES,
1665        "union_allowances": UNION_ALLOWANCES,
1666        "summary": {
1667            "proven": count(Status::Proven),
1668            "verified": count(Status::Verified),
1669            "refused": count(Status::Refused),
1670            "not_examined": plan.not_examined.len(),
1671        },
1672        "files": reports,
1673        "not_examined": plan.not_examined,
1674    })
1675}
1676
1677/// The human summary.
1678pub fn render(plan: &Plan, reports: &[FileReport]) -> String {
1679    let count = |s: Status| reports.iter().filter(|r| r.status == s).count();
1680    let mut out = format!(
1681        "weave land: {} file(s) git could not merge — {} PROVEN, {} VERIFIED, {} REFUSED",
1682        reports.len(),
1683        count(Status::Proven),
1684        count(Status::Verified),
1685        count(Status::Refused),
1686    );
1687    if !plan.not_examined.is_empty() {
1688        out.push_str(&format!(
1689            " ({} more that both sides changed git merged line-cleanly, and the merge check passed)",
1690            plan.not_examined.len()
1691        ));
1692    }
1693    out.push('\n');
1694    let width = reports.iter().map(|r| r.path.len()).max().unwrap_or(0);
1695    for r in reports {
1696        let status = match r.status {
1697            Status::Proven => "PROVEN",
1698            Status::Verified => "VERIFIED",
1699            Status::Refused => "REFUSED",
1700        };
1701        out.push_str(&format!(
1702            "  {status:<8}  {:<width$}  {}: {}\n",
1703            r.path, r.rule, r.reason
1704        ));
1705        for f in &r.findings {
1706            out.push_str(&format!("              - {}: {}\n", f.class, f.detail));
1707        }
1708    }
1709    out
1710}
1711
1712#[cfg(test)]
1713mod tests {
1714    use super::*;
1715
1716    fn unit(path: &str, base: &str, ours: &str, theirs: &str, gitmerged: &str) -> Unit {
1717        Unit {
1718            path: path.into(),
1719            kind: Kind::Content,
1720            base: Some(base.into()),
1721            ours: Some(ours.into()),
1722            theirs: Some(theirs.into()),
1723            gitmerged: Some(gitmerged.into()),
1724            present: None,
1725            unverified_git_merge: false,
1726        }
1727    }
1728
1729    #[test]
1730    fn finding_pattern_strips_names_and_digits() {
1731        assert_eq!(
1732            finding_pattern("2 name(s) are defined more than once: `f` (2x)"),
1733            " name(s) are defined more than once:  (x)"
1734        );
1735        assert_eq!(backticked("a `x` b `y` c `z"), vec!["x", "y"]);
1736    }
1737
1738    #[test]
1739    fn markers_are_a_set_difference_with_the_sides() {
1740        let u = unit("a.txt", "x\n", "=======\n", "y\n", "x\n");
1741        // ours verbatim: no MARKERS (ours has the line), though theirs' `y`
1742        // is DROPPED
1743        let v = gate(&u, &Candidate::File("=======\n".into()));
1744        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1745        let v = gate(&u, &Candidate::File("<<<<<<< ours\n".into()));
1746        assert!(v.reasons.contains(&"MARKERS"));
1747    }
1748
1749    #[test]
1750    fn automerged_lines_must_survive() {
1751        let gm =
1752            "keep_this_line_git_merged()\n<<<<<<< ours\na = 1\n=======\na = 2\n>>>>>>> theirs\n";
1753        assert!(automerged(Some("a = 0\n"), Some(gm), "a = 3\n").is_some());
1754        assert!(automerged(
1755            Some("a = 0\n"),
1756            Some(gm),
1757            "keep_this_line_git_merged()\na = 3\n"
1758        )
1759        .is_none());
1760        // a clean git merge (a unit only because the tree holds another
1761        // answer, or git's failed the merge check): every line it wrote that
1762        // base does not have must survive
1763        assert!(automerged(None, Some("keep_this_line_git_merged()\n"), "").is_some());
1764        assert!(automerged(
1765            Some("keep_this_line_git_merged()\n"),
1766            Some("keep_this_line_git_merged()\n"),
1767            ""
1768        )
1769        .is_none());
1770    }
1771
1772    /// The gate on a real three-way merge of `m.py`.
1773    fn gate_py(base: &str, ours: &str, theirs: &str, cand: &str) -> GateVerdict {
1774        let (clean, gm) = line_merge(base, ours, theirs).unwrap();
1775        assert!(!clean, "the fixture must conflict");
1776        gate(
1777            &unit("m.py", base, ours, theirs, &gm),
1778            &Candidate::File(cand.into()),
1779        )
1780    }
1781
1782    fn classes(v: &GateVerdict) -> Vec<&str> {
1783        v.findings.iter().map(|f| f.class.as_str()).collect()
1784    }
1785
1786    // Both sides append a different entry to one dict, at the same place.
1787    const D_BASE: &str = "T = {\n    'alpha': 1,\n    'omega': 9,\n}\n";
1788    const D_OURS: &str = "T = {\n    'alpha': 1,\n    'beta': 2,\n    'omega': 9,\n}\n";
1789    const D_THEIRS: &str = "T = {\n    'alpha': 1,\n    'gamma': 3,\n    'omega': 9,\n}\n";
1790
1791    #[test]
1792    fn one_sided_resolutions_are_dropped() {
1793        for (cand, side) in [(D_OURS, "theirs"), (D_THEIRS, "ours")] {
1794            let v = gate_py(D_BASE, D_OURS, D_THEIRS, cand);
1795            assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1796            assert!(v.findings[0].detail.starts_with(side), "{v:?}");
1797        }
1798    }
1799
1800    #[test]
1801    fn a_union_in_either_order_or_rewrapped_passes() {
1802        for cand in [
1803            "T = {\n    'alpha': 1,\n    'beta': 2,\n    'gamma': 3,\n    'omega': 9,\n}\n",
1804            "T = {\n    'alpha': 1,\n    'gamma': 3,\n    'beta': 2,\n    'omega': 9,\n}\n",
1805            "T = {\n    'alpha': 1,\n    'beta': 2, 'gamma': 3,\n    'omega': 9,\n}\n",
1806        ] {
1807            let v = gate_py(D_BASE, D_OURS, D_THEIRS, cand);
1808            assert!(v.pass(), "{cand}: {v:?}");
1809        }
1810    }
1811
1812    #[test]
1813    fn an_identical_change_inside_a_block_is_owed_once() {
1814        // both add `'beta': 2`; each adds one more entry of its own
1815        let ours = "T = {\n    'alpha': 1,\n    'beta': 2,\n    'delta': 4,\n    'omega': 9,\n}\n";
1816        let theirs =
1817            "T = {\n    'alpha': 1,\n    'beta': 2,\n    'gamma': 3,\n    'omega': 9,\n}\n";
1818        let union = "T = {\n    'alpha': 1,\n    'beta': 2,\n    'delta': 4,\n    'gamma': 3,\n    'omega': 9,\n}\n";
1819        assert!(gate_py(D_BASE, ours, theirs, union).pass());
1820        let v = gate_py(D_BASE, ours, theirs, ours);
1821        assert_eq!(classes(&v), vec!["DROPPED"], "{v:?}");
1822        assert!(v.findings[0].detail.contains("`gamma`"), "{v:?}");
1823    }
1824
1825    #[test]
1826    fn a_side_that_subsumes_the_other_passes_verbatim() {
1827        // theirs adds `beta`; ours adds `beta` and `gamma`: ours holds both changes
1828        let ours = "T = {\n    'alpha': 1,\n    'beta': 2,\n    'gamma': 3,\n    'omega': 9,\n}\n";
1829        let v = gate_py(D_BASE, ours, D_OURS, ours);
1830        assert!(v.pass(), "{v:?}");
1831        // the subsumed side is not enough
1832        let v = gate_py(D_BASE, ours, D_OURS, D_OURS);
1833        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1834    }
1835
1836    #[test]
1837    fn two_edits_of_one_line_folded_into_one_line_pass() {
1838        let base = "def f():\n    return os.sep\n";
1839        let ours = "def f():\n    return os.sep + 'a'\n";
1840        let theirs = "def f():\n    return os.sep + 'b'\n";
1841        assert!(gate_py(
1842            base,
1843            ours,
1844            theirs,
1845            "def f():\n    return os.sep + 'a' + 'b'\n"
1846        )
1847        .pass());
1848        // incompatible edits of one value: picking one is a dropped change
1849        let (o, t) = ("X = 2\n", "X = 3\n");
1850        let v = gate_py("X = 1\n", o, t, o);
1851        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1852    }
1853
1854    #[test]
1855    fn delete_vs_modify_inside_a_block_is_refused_either_way() {
1856        let base = "def keep():\n    return 0\n\n\ndef f():\n    return 1\n";
1857        let ours = "def keep():\n    return 0\n";
1858        let theirs = "def keep():\n    return 0\n\n\ndef f():\n    return 2\n";
1859        // keep theirs' edit: ours' deletion is undone
1860        let v = gate_py(base, ours, theirs, theirs);
1861        assert!(classes(&v).contains(&"UNDELETED"), "{v:?}");
1862        // keep ours' deletion: theirs' edit is dropped
1863        let v = gate_py(base, ours, theirs, ours);
1864        assert!(classes(&v).contains(&"DROPPED"), "{v:?}");
1865    }
1866
1867    #[test]
1868    fn a_one_sided_deletion_must_stay_deleted() {
1869        // ours deletes `old`, theirs adds `new` next to it
1870        let base = "A = [\n    'first_entry',\n    'old_entry',\n]\n";
1871        let ours = "A = [\n    'first_entry',\n]\n";
1872        let theirs = "A = [\n    'first_entry',\n    'old_entry',\n    'new_entry',\n]\n";
1873        let v = gate_py(base, ours, theirs, theirs);
1874        assert_eq!(classes(&v), vec!["UNDELETED"], "{v:?}");
1875        assert!(v.findings[0].detail.contains("`old_entry`"), "{v:?}");
1876        let both = "A = [\n    'first_entry',\n    'new_entry',\n]\n";
1877        assert!(gate_py(base, ours, theirs, both).pass());
1878    }
1879
1880    #[test]
1881    fn add_add_keeps_both_files_content() {
1882        let ours = "def a():\n    return 'from ours'\n";
1883        let theirs = "def b():\n    return 'from theirs'\n";
1884        let (_, gm) = line_merge("", ours, theirs).unwrap();
1885        let u = Unit {
1886            kind: Kind::AddAdd,
1887            base: None,
1888            ..unit("m.py", "", ours, theirs, &gm)
1889        };
1890        let v = gate(&u, &Candidate::File(ours.into()));
1891        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1892        let both = format!("{ours}\n\n{theirs}");
1893        assert!(gate(&u, &Candidate::File(both)).pass());
1894    }
1895
1896    #[test]
1897    fn non_ascii_lines_near_a_block_are_read() {
1898        // a 41-byte prefix that ends inside a multi-byte char must not panic
1899        let base = "x = 1\n        # Test NULL literal: Snowflake \u{2192} DuckDB\n";
1900        let ours = "x = 2\n        # Test NULL literal: Snowflake \u{2192} DuckDB\n";
1901        let theirs = "x = 3\n        # Test NULL literal: Snowflake \u{2192} DuckDB\n";
1902        let v = gate_py(base, ours, theirs, ours);
1903        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1904        let marker_like = "=".repeat(41) + " \u{2192}\n";
1905        let d = diff3_blocks(
1906            &marker_like,
1907            &format!("a\n{marker_like}"),
1908            &format!("b\n{marker_like}"),
1909        )
1910        .unwrap();
1911        assert_eq!(d.regions.len(), 1);
1912    }
1913
1914    #[test]
1915    fn tokens_ignore_layout_and_brackets() {
1916        let t: Vec<&str> = tokens("  exp.IsInf(this=e.this.copy()), x >= 10_0").collect();
1917        assert_eq!(
1918            t,
1919            ["exp", "IsInf", "this", "=", "e", "this", "copy", "x", ">=", "10_0"]
1920        );
1921    }
1922
1923    #[test]
1924    fn newlines_follow_the_sides() {
1925        assert_eq!(follow_newlines("a".into(), &["x\n", "y\n"]), "a\n");
1926        assert_eq!(follow_newlines("a\n".into(), &["x", "y"]), "a");
1927        assert_eq!(
1928            follow_newlines("a\nb\n".into(), &["x\r\n", "y\r\n"]),
1929            "a\r\nb\r\n"
1930        );
1931    }
1932
1933    // An element union inside a class: both sides add an entry at one point of a
1934    // registry dict. The union is admitted only by the `elem_union` check; a
1935    // result with one side's entry removed, or base order changed, is not
1936    // admitted by anything, so it could never land without the resolver.
1937    #[test]
1938    fn certify_admits_an_element_union_and_refuses_a_tampered_one() {
1939        let base = "class Gen:\n    TRANSFORMS = {\n        exp.Abs: rename_func(\"ABS\"),\n        exp.IntDiv: rename_func(\"DIV\"),\n        exp.Mod: rename_func(\"MOD\"),\n    }\n";
1940        let at = "        exp.IntDiv: rename_func(\"DIV\"),\n";
1941        let ins = |x: &str| base.replace(at, &format!("{at}{x}"));
1942        let (fin, unix) = (
1943            "        exp.IsFinite: rename_func(\"IS_FINITE\"),\n",
1944            "        exp.TimeToUnix: rename_func(\"UNIX_SECONDS\"),\n",
1945        );
1946        let (ours, theirs) = (ins(fin), ins(unix));
1947        let u = unit("gen.py", base, &ours, &theirs, "");
1948        let union = ins(&format!("{fin}{unix}"));
1949        assert_eq!(certify(&u, &union), Ok((vec!["elem_union"], true)));
1950        // and weave's own merge is that union, or the other order
1951        let host = Host::default();
1952        let merged = entity_merge_fmt(
1953            base,
1954            &ours,
1955            &theirs,
1956            "gen.py",
1957            &MarkerFormat::default(),
1958            &host,
1959        );
1960        assert!(merged.is_clean());
1961        assert_eq!(certify(&u, &merged.content), Ok((vec!["elem_union"], true)));
1962        let abs = "        exp.Abs: rename_func(\"ABS\"),\n";
1963        for (what, bad) in [
1964            ("ours dropped", union.replace(fin, "")),
1965            ("theirs dropped", union.replace(unix, "")),
1966            (
1967                "base order changed",
1968                union.replace(&format!("{abs}{at}"), &format!("{at}{abs}")),
1969            ),
1970            (
1971                "an entry rewritten",
1972                union.replace("UNIX_SECONDS", "UNIX_MILLIS"),
1973            ),
1974        ] {
1975            assert!(
1976                certify(&u, &bad).is_err(),
1977                "{what}: {:?}",
1978                certify(&u, &bad)
1979            );
1980        }
1981    }
1982
1983    // Two features each add `case 3:` to one switch. git
1984    // merges it line-cleanly, the result does not compile.
1985    const SW_BASE: &str = "package p\n\nfunc F(x int) int {\n\tswitch x {\n\tcase 1:\n\t\treturn 10\n\tcase 2:\n\t\treturn 20\n\t}\n\treturn 0\n}\n";
1986    const SW_OURS: &str = "package p\n\nfunc F(x int) int {\n\tswitch x {\n\tcase 1:\n\t\treturn 10\n\tcase 3:\n\t\treturn 31\n\tcase 2:\n\t\treturn 20\n\t}\n\treturn 0\n}\n";
1987    const SW_THEIRS: &str = "package p\n\nfunc F(x int) int {\n\tswitch x {\n\tcase 1:\n\t\treturn 10\n\tcase 2:\n\t\treturn 20\n\tcase 3:\n\t\treturn 32\n\t}\n\treturn 0\n}\n";
1988
1989    #[test]
1990    fn a_line_clean_duplicate_case_fails_the_merge_check_and_the_gate() {
1991        let (clean, merged) = line_merge(SW_BASE, SW_OURS, SW_THEIRS).unwrap();
1992        assert!(clean, "git merges it line-cleanly");
1993        assert!(!line_clean_holds(
1994            "p.go", SW_BASE, SW_OURS, SW_THEIRS, &merged
1995        ));
1996        let u = Unit {
1997            present: Some(Candidate::File(merged.clone())),
1998            unverified_git_merge: true,
1999            ..unit("p.go", SW_BASE, SW_OURS, SW_THEIRS, &merged)
2000        };
2001        let v = gate(&u, &Candidate::File(merged));
2002        assert!(
2003            v.findings
2004                .iter()
2005                .any(|f| f.class == "DUP" && f.detail.contains("case `3`")),
2006            "{v:?}"
2007        );
2008        let r = land_unit(&u, &Host::default(), None).unwrap();
2009        assert_eq!(r.status, Status::Refused, "{r:?}");
2010        // left conflicted, with markers, not as git's (compiling-looking) merge
2011        match &r.landed {
2012            Landed::Conflicted(Some(t)) => assert!(!markers(t).is_empty(), "{t}"),
2013            other => panic!("{other:?}"),
2014        }
2015        // A resolution must state `case 3` once, so it differs from git's
2016        // merge; keeping both sides' cases (one renumbered) passes, dropping
2017        // one side's case does not.
2018        let renumbered = SW_OURS
2019            .replace("case 3:\n\t\treturn 31", "case 4:\n\t\treturn 31")
2020            .replace(
2021                "\t}\n\treturn 0",
2022                "\tcase 3:\n\t\treturn 32\n\t}\n\treturn 0",
2023            );
2024        assert!(gate(&u, &Candidate::File(renumbered)).pass());
2025        let v = gate(&u, &Candidate::File(SW_OURS.into()));
2026        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
2027    }
2028
2029    #[test]
2030    fn the_answer_in_the_tree_is_judged_before_the_resolver() {
2031        // a both-sides edit git conflicts on; the tree already holds a merge
2032        // that keeps both changes
2033        let base = "def f():\n    return 1\n\n\ndef g():\n    return 2\n";
2034        let ours = "def f():\n    return 10\n\n\ndef g():\n    return 2\n";
2035        let theirs = "def f():\n    return 1\n\n\ndef g():\n    return 20\n";
2036        let (_, gm) = line_merge(base, ours, theirs).unwrap();
2037        let both = "def f():\n    return 10\n\n\ndef g():\n    return 20\n";
2038        let u = Unit {
2039            present: Some(Candidate::File(both.into())),
2040            ..unit("m.py", base, ours, theirs, &gm)
2041        };
2042        let r = land_unit(&u, &Host::default(), None).unwrap();
2043        assert_ne!(r.status, Status::Refused, "{r:?}");
2044        assert_eq!(r.landed, Landed::File(both.into()));
2045        // one side's file as the answer drops the other's change (git merged
2046        // this file line-cleanly: the change outside any block must survive)
2047        let u = Unit {
2048            present: Some(Candidate::File(ours.into())),
2049            ..unit("m.py", base, ours, theirs, &gm)
2050        };
2051        let r = land_unit(&u, &Host::default(), None).unwrap();
2052        assert_eq!(r.status, Status::Refused, "{r:?}");
2053    }
2054
2055    #[test]
2056    fn answers() {
2057        assert_eq!(parse_answer("DELETE\n".into()), Answer::Delete);
2058        assert_eq!(parse_answer(" KEEP ".into()), Answer::Keep);
2059        assert_eq!(
2060            parse_answer("CANNOT: too big\n".into()),
2061            Answer::Cannot("too big".into())
2062        );
2063        assert_eq!(
2064            parse_answer("CANNOT\n".into()),
2065            Answer::Cannot(String::new())
2066        );
2067        assert_eq!(
2068            parse_answer("CANNOTATION\n".into()),
2069            Answer::File("CANNOTATION\n".into())
2070        );
2071        assert_eq!(
2072            parse_answer("DELETE\nx\n".into()),
2073            Answer::File("DELETE\nx\n".into())
2074        );
2075    }
2076}