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/// How long one resolver call may take by default, in seconds: the CLI's
736/// `--resolver-timeout` and the MCP `weave_land` tool share it.
737pub const DEFAULT_RESOLVER_TIMEOUT_SECS: u64 = 900;
738
739#[derive(Debug, Clone)]
740pub struct Resolver {
741    pub command: String,
742    pub timeout: Duration,
743}
744
745/// What the resolver said.
746#[derive(Debug, Clone, PartialEq, Eq)]
747pub enum Answer {
748    File(String),
749    Delete,
750    Keep,
751    Cannot(String),
752    /// Exited non-zero, timed out, or wrote something that is not UTF-8.
753    Failed(String),
754}
755
756impl Resolver {
757    pub fn ask(
758        &self,
759        u: &Unit,
760        attempt: usize,
761        previous: Option<&Candidate>,
762        findings: &[String],
763    ) -> R<Answer> {
764        let job = serde_json::json!({
765            "path": u.path,
766            "kind": u.kind,
767            "base": u.base,
768            "ours": u.ours,
769            "theirs": u.theirs,
770            "conflicted": u.gitmerged,
771            "attempt": attempt,
772            "previous": previous.map(|p| match p {
773                Candidate::File(c) => c.as_str(),
774                Candidate::Delete => "DELETE",
775            }),
776            "findings": findings,
777        });
778        let mut cmd = Command::new("sh");
779        cmd.arg("-c").arg(&self.command);
780        let run = gitscan::run_bounded(
781            cmd,
782            "the resolver",
783            Some(serde_json::to_vec(&job)?),
784            self.timeout,
785        );
786        let (status, stdout, stderr) = match run {
787            Ok(r) => r,
788            Err(e) => return Ok(Answer::Failed(e.to_string())),
789        };
790        // 126/127: the shell could not run the command at all. That is the
791        // invocation being wrong, not one file's answer.
792        if matches!(status.code(), Some(126) | Some(127)) {
793            return Err(format!(
794                "the resolver command could not be run: {}",
795                String::from_utf8_lossy(&stderr).trim()
796            )
797            .into());
798        }
799        if !status.success() {
800            let tail = String::from_utf8_lossy(&stderr);
801            let tail: String = tail.trim().chars().rev().take(300).collect();
802            return Ok(Answer::Failed(format!(
803                "the resolver exited {}: {}",
804                status
805                    .code()
806                    .map_or("on a signal".into(), |c| c.to_string()),
807                tail.chars().rev().collect::<String>()
808            )));
809        }
810        let Ok(out) = String::from_utf8(stdout) else {
811            return Ok(Answer::Failed("the resolver's answer is not UTF-8".into()));
812        };
813        Ok(parse_answer(out))
814    }
815}
816
817fn parse_answer(out: String) -> Answer {
818    let t = out.trim();
819    if !t.contains('\n') {
820        if t == "DELETE" {
821            return Answer::Delete;
822        }
823        if t == "KEEP" {
824            return Answer::Keep;
825        }
826        if let Some(rest) = t.strip_prefix("CANNOT") {
827            if rest.is_empty() || rest.starts_with(':') || rest.starts_with(' ') {
828                return Answer::Cannot(rest.trim_start_matches(':').trim().to_string());
829            }
830        }
831    }
832    Answer::File(out)
833}
834
835/// The resolved text's newline conventions follow the inputs': a trailing
836/// newline when every side ends in one, none when no side does, CRLF when
837/// every side is CRLF throughout.
838fn follow_newlines(mut body: String, sides: &[&str]) -> String {
839    if sides.is_empty() {
840        return body;
841    }
842    if sides.iter().all(|s| s.ends_with('\n')) && !body.is_empty() && !body.ends_with('\n') {
843        body.push('\n');
844    }
845    if !sides.iter().any(|s| s.ends_with('\n')) && body.ends_with('\n') {
846        body.pop();
847    }
848    let crlf = |s: &str| s.contains("\r\n") && s.matches("\r\n").count() == s.matches('\n').count();
849    if sides.iter().all(|s| crlf(s)) {
850        body = body.replace("\r\n", "\n").replace('\n', "\r\n");
851    }
852    body
853}
854
855// ----------------------------------------------------------------- landing
856
857#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
858#[serde(rename_all = "UPPERCASE")]
859pub enum Status {
860    Proven,
861    Verified,
862    Refused,
863}
864
865/// What to leave in the working tree for a file.
866#[derive(Debug, Clone, PartialEq, Eq)]
867pub enum Landed {
868    File(String),
869    Deleted,
870    /// Keep it conflicted: write these bytes (conflict markers), or leave the
871    /// working-tree file as git left it (`None`).
872    Conflicted(Option<String>),
873}
874
875/// One file's line in the report.
876#[derive(Debug, Clone, Serialize)]
877pub struct FileReport {
878    pub path: String,
879    pub kind: Kind,
880    pub status: Status,
881    /// `certificate` | `elem_union` | `present` | `gate` | `resolver` |
882    /// `no-resolver` | `not-text` | `keys`.
883    pub rule: &'static str,
884    /// One sentence.
885    pub reason: String,
886    /// The gate's reasons (empty unless the gate ran and failed).
887    pub reasons: Vec<&'static str>,
888    pub findings: Vec<GateFinding>,
889    /// Resolver calls made (0, 1 or 2).
890    pub attempts: usize,
891    /// What weave's merge did: `clean` | `conflicted` | `not-run`.
892    pub weave: &'static str,
893    /// sha256 of the bytes landed (PROVEN / VERIFIED file), else `null`.
894    pub sha256: Option<String>,
895    #[serde(skip)]
896    pub landed: Landed,
897}
898
899/// Land one file: weave, then the certificate, then the resolver behind the
900/// gate. `Err` only for a failure of the whole run (the resolver cannot be
901/// started at all).
902///
903/// A clean weave merge the certificate admits with its v2 rule set is PROVEN.
904/// One it admits only by also allowing [`UNION_ALLOWANCES`] — weave united
905/// elements two sides inserted into one collection, and the certificate's
906/// independent `elem_union` check, reading the four parse trees, found every
907/// inserted element verbatim, base order kept, no key twice, a construct the
908/// policy calls a set, and a result that parses — lands VERIFIED, after the
909/// same gate a resolver's answer must pass (DROPPED / UNDELETED included),
910/// with no resolver call. When the region-by-region verdict fails, the
911/// check also reads the whole file as one statement list (both sides
912/// appending named top-level declarations at one point). Not PROVEN: PROVEN means the certificate's
913/// fixed proof rule set, measured before it was adopted; `elem_union` is a
914/// policy about which collections are sets, not a three-way selection, and
915/// it has not been measured that way. Any doubt — the check declines or
916/// crashes, the gate fails — sends the file on to the resolver, as before.
917pub fn land_unit(u: &Unit, host: &Host, resolver: Option<&Resolver>) -> R<FileReport> {
918    let mut report = FileReport {
919        path: u.path.clone(),
920        kind: u.kind,
921        status: Status::Refused,
922        rule: "gate",
923        reason: String::new(),
924        reasons: Vec::new(),
925        findings: Vec::new(),
926        attempts: 0,
927        weave: "not-run",
928        sha256: None,
929        landed: Landed::Conflicted(u.gitmerged.as_deref().map(|g| {
930            if markers(g).is_empty() {
931                whole_file_conflict(u)
932            } else {
933                g.to_string()
934            }
935        })),
936    };
937
938    // ---- 1. weave, and 2. the certificate on a clean result
939    let mut unproven = String::new();
940    if let (Kind::Content | Kind::AddAdd, Some(ours), Some(theirs)) =
941        (u.kind, u.ours.as_deref(), u.theirs.as_deref())
942    {
943        let fmt = MarkerFormat::default().for_file(&u.path);
944        let base = u.base.as_deref().unwrap_or("");
945        let merged = entity_merge_fmt(base, ours, theirs, &u.path, &fmt, host);
946        // weave's labels describe weave's merge; when the tree already holds
947        // a different answer, that answer is the one to judge (step 3).
948        let describes_tree = match &u.present {
949            None => true,
950            Some(Candidate::File(p)) => *p == merged.content,
951            Some(Candidate::Delete) => false,
952        };
953        if merged.is_clean() && !describes_tree {
954            report.weave = "clean";
955            unproven = "the tree's answer is not weave's merge; ".into();
956        } else if merged.is_clean() {
957            report.weave = "clean";
958            let cert = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
959                certify(u, &merged.content)
960            }))
961            .unwrap_or_else(|_| Err("the certificate crashed on this file".into()));
962            match cert {
963                Ok((admitted, true)) => {
964                    let v = gate(u, &Candidate::File(merged.content.clone()));
965                    if v.pass() {
966                        report.status = Status::Verified;
967                        report.rule = "elem_union";
968                        report.reason = format!(
969                            "weave's element union passed the independent element check \
970                             (both-changed regions admitted by: {}) and the gate; no resolver \
971                             was asked",
972                            admitted.join(", ")
973                        );
974                        report.sha256 = Some(sha256(&merged.content));
975                        report.landed = Landed::File(merged.content);
976                        return Ok(report);
977                    }
978                    unproven = format!(
979                        "weave's element union passed the element check but not the gate ({}); ",
980                        v.reasons.join(", ")
981                    );
982                }
983                Ok((admitted, false)) => {
984                    report.status = Status::Proven;
985                    report.rule = "certificate";
986                    report.reason = if admitted.is_empty() {
987                        "weave's merge is the three-way selection of every region".into()
988                    } else {
989                        format!(
990                            "weave's merge is the three-way selection (both-changed regions \
991                             admitted by: {})",
992                            admitted.join(", ")
993                        )
994                    };
995                    report.sha256 = Some(sha256(&merged.content));
996                    report.landed = Landed::File(merged.content);
997                    return Ok(report);
998                }
999                Err(why) => unproven = format!("weave's clean merge is not certified ({why}); "),
1000            }
1001        } else {
1002            report.weave = "conflicted";
1003            report.landed = Landed::Conflicted(Some(weave_core::conflict::teach_on_marker(
1004                &merged.content,
1005                &u.path,
1006                fmt.marker_length,
1007            )));
1008        }
1009    }
1010
1011    // ---- 3. the answer the tree already holds, behind the gate
1012    if let Some(present) = &u.present {
1013        let v = gate_with(u, present, true);
1014        if v.pass() {
1015            report.status = Status::Verified;
1016            report.rule = "present";
1017            report.reason = format!(
1018                "{unproven}the merge's own result (the merge driver's, or the result \
1019                 revision's) passed the gate; no resolver was asked"
1020            );
1021            report.landed = match present {
1022                Candidate::File(c) => {
1023                    report.sha256 = Some(sha256(c));
1024                    Landed::File(c.clone())
1025                }
1026                Candidate::Delete => Landed::Deleted,
1027            };
1028            return Ok(report);
1029        }
1030        unproven = format!(
1031            "{unproven}the merge's own result failed the gate ({}); ",
1032            v.reasons.join(", ")
1033        );
1034        report.reasons = v.reasons;
1035        report.findings = v.findings;
1036    }
1037
1038    // ---- 4. the resolver, behind 5. the gate, with at most one retry
1039    let Some(resolver) = resolver else {
1040        report.rule = "no-resolver";
1041        report.reason = format!("{unproven}no resolver was given (--resolver)");
1042        return Ok(report);
1043    };
1044    let mut previous: Option<Candidate> = None;
1045    let mut feedback: Vec<String> = Vec::new();
1046    for attempt in 1..=2 {
1047        report.attempts = attempt;
1048        let answer = resolver.ask(u, attempt, previous.as_ref(), &feedback)?;
1049        let modify_delete = u.kind == Kind::ModifyDelete;
1050        let (cand, verdict) = match answer {
1051            Answer::Cannot(why) => {
1052                report.rule = "resolver";
1053                report.reason = format!(
1054                    "{unproven}the resolver declined: {}",
1055                    if why.is_empty() {
1056                        "no reason given"
1057                    } else {
1058                        &why
1059                    }
1060                );
1061                return Ok(report);
1062            }
1063            Answer::Failed(why) => {
1064                report.rule = "resolver";
1065                report.reason = format!("{unproven}{why}");
1066                return Ok(report);
1067            }
1068            Answer::Delete if modify_delete => (Candidate::Delete, None),
1069            Answer::Keep if modify_delete => {
1070                let kept = u
1071                    .ours
1072                    .clone()
1073                    .or_else(|| u.theirs.clone())
1074                    .unwrap_or_default();
1075                (Candidate::File(kept), None)
1076            }
1077            Answer::Delete | Answer::Keep => (
1078                Candidate::File(String::new()),
1079                Some(GateVerdict::malformed(
1080                    "DELETE and KEEP answer a modify/delete conflict only; this file needs \
1081                     its complete resolved content",
1082                )),
1083            ),
1084            Answer::File(text) if text.trim().is_empty() => (
1085                Candidate::File(text),
1086                Some(GateVerdict::malformed("the answer was empty")),
1087            ),
1088            Answer::File(text) => (Candidate::File(follow_newlines(text, &u.sides())), None),
1089        };
1090        let verdict = verdict.unwrap_or_else(|| gate(u, &cand));
1091        if verdict.pass() {
1092            report.status = Status::Verified;
1093            report.rule = "gate";
1094            report.reason =
1095                format!("{unproven}the resolver's answer passed the gate on attempt {attempt}");
1096            report.reasons.clear();
1097            report.findings.clear();
1098            report.landed = match cand {
1099                Candidate::File(c) => {
1100                    report.sha256 = Some(sha256(&c));
1101                    Landed::File(c)
1102                }
1103                Candidate::Delete => Landed::Deleted,
1104            };
1105            return Ok(report);
1106        }
1107        feedback = verdict.feedback();
1108        report.reasons = verdict.reasons;
1109        report.findings = verdict.findings;
1110        previous = Some(cand);
1111    }
1112    report.rule = "gate";
1113    report.reason = format!(
1114        "{unproven}the resolver's answer failed the gate twice: {}",
1115        report.reasons.join(", ")
1116    );
1117    Ok(report)
1118}
1119
1120/// One conflict box over the whole file, ours against theirs: what a
1121/// refused file git merged line-cleanly is left as, so that it reads as
1122/// unresolved and not as git's merge.
1123fn whole_file_conflict(u: &Unit) -> String {
1124    let side = |t: Option<&str>| {
1125        let t = t.unwrap_or("");
1126        if t.is_empty() || t.ends_with('\n') {
1127            t.to_string()
1128        } else {
1129            format!("{t}\n")
1130        }
1131    };
1132    format!(
1133        "<<<<<<< ours\n{}=======\n{}>>>>>>> theirs\n",
1134        side(u.ours.as_deref()),
1135        side(u.theirs.as_deref())
1136    )
1137}
1138
1139/// The certificate on weave's clean merge: `Ok((allowances that admitted a
1140/// both-changed region, whether one needed [`UNION_ALLOWANCES`]))` when every
1141/// both-changed region is admitted — or, failing that, when `elem_union`
1142/// admits the whole file as one statement list — and `Err(why not)`
1143/// otherwise.
1144fn certify(u: &Unit, merged: &str) -> Result<(Vec<&'static str>, bool), String> {
1145    use weave_certify::{check, decompose, normalize};
1146    let version = |t: Option<&str>| t.map(|t| decompose(&REGISTRY, &u.path, &normalize(t)));
1147    let (o, a, b) = (
1148        version(u.base.as_deref()),
1149        version(u.ours.as_deref()),
1150        version(u.theirs.as_deref()),
1151    );
1152    let m = decompose(&REGISTRY, &u.path, &normalize(merged));
1153    let r = check(&u.path, o.as_ref(), a.as_ref(), b.as_ref(), &m);
1154    by_region(&r).or_else(|why| {
1155        // Both sides inserting top-level declarations at one point leaves the
1156        // text after an inserted entity a region neither side wrote; the
1157        // whole file read as one statement list may still be a union.
1158        match (a.as_ref(), b.as_ref()) {
1159            (Some(a), Some(b))
1160                if weave_certify::elem_union_file(&u.path, o.as_ref(), a, b, &m).is_ok() =>
1161            {
1162                Ok((vec!["elem_union (whole file)"], true))
1163            }
1164            _ => Err(why),
1165        }
1166    })
1167}
1168
1169/// The certificate's verdict region by region (see [`certify`]).
1170fn by_region(r: &weave_certify::Report) -> Result<(Vec<&'static str>, bool), String> {
1171    if let Some((rule, key)) = r.hard.first() {
1172        return Err(format!("{rule} at `{key}`"));
1173    }
1174    let mut admitted: Vec<&'static str> = Vec::new();
1175    let mut union = false;
1176    for (key, verdicts) in &r.both {
1177        let admits = |set: &[&str]| {
1178            verdicts
1179                .iter()
1180                .find(|(a, s)| set.contains(a) && *s == "admit")
1181                .map(|(a, _)| *a)
1182        };
1183        let by = admits(&PROOF_ALLOWANCES).or_else(|| {
1184            let a = admits(&UNION_ALLOWANCES);
1185            union |= a.is_some();
1186            a
1187        });
1188        match by {
1189            Some(a) if !admitted.contains(&a) => admitted.push(a),
1190            Some(_) => {}
1191            None => {
1192                return Err(format!(
1193                    "both sides changed `{key}` and no allowance admits the result"
1194                ))
1195            }
1196        }
1197    }
1198    Ok((admitted, union))
1199}
1200
1201fn sha256(text: &str) -> String {
1202    Sha256::digest(text.as_bytes())
1203        .iter()
1204        .map(|b| format!("{b:02x}"))
1205        .collect()
1206}
1207
1208// ----------------------------------------------------------------- the plan
1209
1210/// Where the answer already in the tree is read, for [`plan`].
1211#[derive(Debug, Clone, PartialEq, Eq)]
1212pub enum Present {
1213    /// The working tree, for every path the index holds at stage 0 (a file
1214    /// the merge driver resolved, or git merged) — and, with `unmerged`, for
1215    /// a conflicted path too once the file on disk has no conflict-marker
1216    /// line left (someone resolved it in place and did not stage it).
1217    WorkingTree { unmerged: bool },
1218    /// The tree of this revision (a merge commit being re-checked).
1219    Rev(String),
1220}
1221
1222/// The merge to land, read out of git.
1223pub struct Plan {
1224    pub base: String,
1225    pub ours: String,
1226    pub theirs: String,
1227    pub units: Vec<Unit>,
1228    /// Files git could not merge that are not text weave can read (binary,
1229    /// symlink, submodule, unreadable blob): refused before anything runs.
1230    pub not_text: Vec<FileReport>,
1231    /// Files both sides changed that git's line merge merges cleanly, and
1232    /// whose merge — the answer in the tree, else git's — is git's own and
1233    /// passes weave's merge check ([`weave_core::verify::verify`]).
1234    pub not_examined: Vec<String>,
1235    /// Each conflicted path's index entries at base / ours / theirs, for
1236    /// restoring a refused file's unmerged stages.
1237    pub stages: BTreeMap<String, [Option<(String, String)>; 3]>,
1238}
1239
1240/// Read the merge `base` × `ours` × `theirs` (each defaulting as `weave check`
1241/// does: HEAD, the operation in progress, their merge base) and classify every
1242/// file both sides changed.
1243///
1244/// A file git's line merge merges cleanly is still examined: when the answer
1245/// in the tree (`present`) is git's merge, that merge must pass weave's own
1246/// merge check — no line both sides kept lost, nothing stated twice, no
1247/// `case` or map key twice ([`weave_core::verify::verify`]); git calling it
1248/// clean is not enough (two features each adding `case 3:` to one switch is
1249/// line-clean). When it fails, or the tree holds something else, the file is
1250/// a unit like any conflicted one.
1251pub fn plan(
1252    dir: &Path,
1253    base: Option<&str>,
1254    ours: Option<&str>,
1255    theirs: Option<&str>,
1256    present: Option<&Present>,
1257) -> R<Plan> {
1258    let (base, ours, theirs) = gitscan::resolve_revs(dir, base, ours, theirs)?;
1259    let oid = |rev: &str| -> R<String> {
1260        Ok(gitscan::git(dir, &["rev-parse", "--verify", rev])?
1261            .trim()
1262            .to_string())
1263    };
1264    let (base, ours, theirs) = (oid(&base)?, oid(&ours)?, oid(&theirs)?);
1265    let changed = |side: &str| -> R<BTreeSet<String>> {
1266        Ok(gitscan::git(
1267            dir,
1268            &["diff", "--name-only", "--no-renames", "-z", &base, side],
1269        )?
1270        .split('\0')
1271        .filter(|p| !p.is_empty())
1272        .map(str::to_string)
1273        .collect())
1274    };
1275    let both: Vec<String> = changed(&ours)?
1276        .intersection(&changed(&theirs)?)
1277        .cloned()
1278        .collect();
1279    let [eb, eo, et] = [&base, &ours, &theirs].map(|rev| gitscan::entries_at_rev(dir, rev, &both));
1280    let (eb, eo, et) = (eb?, eo?, et?);
1281    let (present_of, unmerged) = present_answers(dir, present, &both)?;
1282
1283    let mut plan = Plan {
1284        base,
1285        ours,
1286        theirs,
1287        units: Vec::new(),
1288        not_text: Vec::new(),
1289        not_examined: Vec::new(),
1290        stages: BTreeMap::new(),
1291    };
1292    for path in both {
1293        let (b, o, t) = (eb.get(&path), eo.get(&path), et.get(&path));
1294        let same = |x: Option<&Entry>, y: Option<&Entry>| {
1295            x.map(|e| (&e.mode, &e.oid)) == y.map(|e| (&e.mode, &e.oid))
1296        };
1297        if same(o, t) {
1298            continue; // both sides made the same change
1299        }
1300        let kind = match (b, o, t) {
1301            (_, None, _) | (_, _, None) => Kind::ModifyDelete,
1302            (None, _, _) => Kind::AddAdd,
1303            _ => Kind::Content,
1304        };
1305        let entry = |e: Option<&Entry>| e.map(|e| (e.mode.clone(), e.oid.clone()));
1306        let stages = [entry(b), entry(o), entry(t)];
1307        let odd = [b, o, t].into_iter().flatten().find_map(|e| match &e.body {
1308            Body::Text(_) => None,
1309            Body::Binary => Some("a merge stage is binary (not UTF-8)".to_string()),
1310            Body::Irregular => Some("a merge stage is a symlink or a submodule".to_string()),
1311            Body::Unreadable(why) => Some(format!("a merge stage could not be read: {why}")),
1312        });
1313        if let Some(why) = odd {
1314            plan.stages.insert(path.clone(), stages);
1315            plan.not_text.push(FileReport {
1316                path,
1317                kind,
1318                status: Status::Refused,
1319                rule: "not-text",
1320                reason: format!("{why}; not attempted"),
1321                reasons: Vec::new(),
1322                findings: Vec::new(),
1323                attempts: 0,
1324                weave: "not-run",
1325                sha256: None,
1326                landed: Landed::Conflicted(None),
1327            });
1328            continue;
1329        }
1330        let text = |e: Option<&Entry>| match e.map(|e| &e.body) {
1331            Some(Body::Text(t)) => Some(t.clone()),
1332            _ => None,
1333        };
1334        let (base_text, ours_text, theirs_text) = (text(b), text(o), text(t));
1335        let present_answer = present_of.get(&path).cloned();
1336        let mut unverified_git_merge = false;
1337        let gitmerged = match (kind, &ours_text, &theirs_text) {
1338            (Kind::ModifyDelete, _, _) => None,
1339            (_, Some(o), Some(t)) => {
1340                let base_t = base_text.as_deref().unwrap_or("");
1341                let (clean, merged) = line_merge(base_t, o, t)?;
1342                if clean {
1343                    // A path the index holds unmerged was refused by the
1344                    // merge driver: never waved through on git's line merge.
1345                    let holds =
1346                        !unmerged.contains(&path) && line_clean_holds(&path, base_t, o, t, &merged);
1347                    let answer = match &present_answer {
1348                        Some(Candidate::File(p)) => Some(p.as_str()),
1349                        Some(Candidate::Delete) => None,
1350                        None => Some(merged.as_str()),
1351                    };
1352                    if holds && answer == Some(merged.as_str()) {
1353                        plan.not_examined.push(path);
1354                        continue;
1355                    }
1356                    unverified_git_merge = !holds;
1357                }
1358                Some(merged)
1359            }
1360            _ => None,
1361        };
1362        plan.stages.insert(path.clone(), stages);
1363        plan.units.push(Unit {
1364            path,
1365            kind,
1366            base: base_text,
1367            ours: ours_text,
1368            theirs: theirs_text,
1369            gitmerged,
1370            present: present_answer,
1371            unverified_git_merge,
1372        });
1373    }
1374    Ok(plan)
1375}
1376
1377/// git's clean line merge of a file passes the check weave's own merge must
1378/// pass before it may call a merge clean. Files too large or binary for
1379/// structure keep git's guarantee, as weave's merge does.
1380fn line_clean_holds(path: &str, base: &str, ours: &str, theirs: &str, merged: &str) -> bool {
1381    if [base, ours, theirs, merged]
1382        .iter()
1383        .any(|t| t.len() > weave_core::merge::STRUCTURE_LIMIT_BYTES)
1384    {
1385        return true;
1386    }
1387    let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1388        weave_core::verify::verify(base, ours, theirs, merged, path, &REGISTRY, &[])
1389    }));
1390    matches!(run, Ok(None))
1391}
1392
1393/// The answer already in the tree for each path, per [`Present`]: a file's
1394/// text, or `Delete` when the tree does not have it. A path the working
1395/// tree's index holds unmerged has no answer here unless `unmerged` asks for
1396/// in-place resolutions — a conflicted file is the resolver's to answer.
1397/// Also: the paths the index holds unmerged (working-tree mode only).
1398fn present_answers(
1399    dir: &Path,
1400    present: Option<&Present>,
1401    paths: &[String],
1402) -> R<(BTreeMap<String, Candidate>, BTreeSet<String>)> {
1403    let mut out = BTreeMap::new();
1404    let mut unmerged_paths = BTreeSet::new();
1405    match present {
1406        None => {}
1407        Some(Present::Rev(rev)) => {
1408            let at = gitscan::entries_at_rev(dir, rev, paths)?;
1409            for p in paths {
1410                match at.get(p).map(|e| &e.body) {
1411                    Some(Body::Text(t)) => {
1412                        out.insert(p.clone(), Candidate::File(t.clone()));
1413                    }
1414                    Some(_) => {}
1415                    None => {
1416                        out.insert(p.clone(), Candidate::Delete);
1417                    }
1418                }
1419            }
1420        }
1421        Some(Present::WorkingTree {
1422            unmerged: take_unmerged,
1423        }) => {
1424            let unmerged: BTreeSet<String> =
1425                gitscan::git(dir, &["diff", "--name-only", "--diff-filter=U", "-z"])?
1426                    .split('\0')
1427                    .filter(|p| !p.is_empty())
1428                    .map(str::to_string)
1429                    .collect();
1430            unmerged_paths = unmerged.clone();
1431            for p in paths
1432                .iter()
1433                .filter(|p| *take_unmerged || !unmerged.contains(*p))
1434            {
1435                let on_disk = dir.join(p);
1436                match std::fs::read(&on_disk) {
1437                    Ok(bytes) => {
1438                        if let Ok(t) = String::from_utf8(bytes) {
1439                            if !unmerged.contains(p) || markers(&t).is_empty() {
1440                                out.insert(p.clone(), Candidate::File(t));
1441                            }
1442                        }
1443                    }
1444                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1445                        out.insert(p.clone(), Candidate::Delete);
1446                    }
1447                    Err(_) => {}
1448                }
1449            }
1450        }
1451    }
1452    Ok((out, unmerged_paths))
1453}
1454
1455/// git's own line merge of one file, as `git merge` writes it (plain `merge`
1456/// conflict style): `(clean, text)`.
1457fn line_merge(base: &str, ours: &str, theirs: &str) -> R<(bool, String)> {
1458    line_merge_with(base, ours, theirs, &[])
1459}
1460
1461/// [`line_merge`] with extra `git merge-file` options (a conflict style).
1462fn line_merge_with(base: &str, ours: &str, theirs: &str, opts: &[&str]) -> R<(bool, String)> {
1463    use std::sync::atomic::{AtomicUsize, Ordering};
1464    static SEQ: AtomicUsize = AtomicUsize::new(0);
1465    let dir = std::env::temp_dir().join(format!(
1466        "weave-land-{}-{}",
1467        std::process::id(),
1468        SEQ.fetch_add(1, Ordering::Relaxed)
1469    ));
1470    std::fs::create_dir_all(&dir)?;
1471    let result = (|| -> R<(bool, String)> {
1472        for (name, text) in [("ours", ours), ("base", base), ("theirs", theirs)] {
1473            std::fs::write(dir.join(name), text)?;
1474        }
1475        let mut cmd = Command::new("git");
1476        cmd.args([
1477            "merge-file",
1478            "-p",
1479            "-L",
1480            "ours",
1481            "-L",
1482            "base",
1483            "-L",
1484            "theirs",
1485        ])
1486        .args(opts)
1487        .args(["ours", "base", "theirs"])
1488        .current_dir(&dir)
1489        // No user config: `merge.conflictStyle` would change which lines
1490        // count as merged automatically.
1491        .env("GIT_CONFIG_NOSYSTEM", "1")
1492        .env("GIT_CONFIG_GLOBAL", "/dev/null");
1493        let (status, stdout, stderr) =
1494            gitscan::run_bounded(cmd, "git merge-file", None, Duration::from_secs(120))?;
1495        match status.code() {
1496            Some(c @ 0..=127) => Ok((c == 0, String::from_utf8(stdout)?)),
1497            _ => Err(format!(
1498                "git merge-file failed: {}",
1499                String::from_utf8_lossy(&stderr).trim()
1500            )
1501            .into()),
1502        }
1503    })();
1504    let _ = std::fs::remove_dir_all(&dir);
1505    result
1506}
1507
1508// ------------------------------------------------------------ the write-back
1509
1510/// Write every file's outcome into the working tree and index at `dir`:
1511/// PROVEN / VERIFIED files are written and staged (a verified DELETE is
1512/// removed); a REFUSED file keeps its conflict markers and is put back to
1513/// unmerged, so `git commit` refuses until someone resolves it.
1514pub fn write_back(dir: &Path, plan: &Plan, reports: &[FileReport]) -> R<()> {
1515    for r in reports {
1516        let on_disk = dir.join(&r.path);
1517        match &r.landed {
1518            Landed::File(text) => {
1519                if let Some(parent) = on_disk.parent() {
1520                    std::fs::create_dir_all(parent)?;
1521                }
1522                std::fs::write(&on_disk, text)?;
1523                gitscan::git(dir, &["add", "--", &r.path])?;
1524            }
1525            Landed::Deleted => {
1526                gitscan::git(
1527                    dir,
1528                    &["rm", "-q", "--cached", "--ignore-unmatch", "--", &r.path],
1529                )?;
1530                if on_disk.symlink_metadata().is_ok() {
1531                    std::fs::remove_file(&on_disk)?;
1532                }
1533            }
1534            Landed::Conflicted(text) => {
1535                if let Some(text) = text {
1536                    if let Some(parent) = on_disk.parent() {
1537                        std::fs::create_dir_all(parent)?;
1538                    }
1539                    std::fs::write(&on_disk, text)?;
1540                }
1541                let Some(stages) = plan.stages.get(&r.path) else {
1542                    continue;
1543                };
1544                // Drop whatever stage-0 entry a merge driver left, and put the
1545                // three merge stages back.
1546                let mut info = format!("0 {}\t{}\n", "0".repeat(40), r.path);
1547                for (n, entry) in stages.iter().enumerate() {
1548                    if let Some((mode, oid)) = entry {
1549                        info.push_str(&format!("{mode} {oid} {}\t{}\n", n + 1, r.path));
1550                    }
1551                }
1552                gitscan::git_input(
1553                    dir,
1554                    &["update-index", "--index-info"],
1555                    Some(info.into_bytes()),
1556                )?;
1557            }
1558        }
1559    }
1560    Ok(())
1561}
1562
1563// ------------------------------------------------------------------- the run
1564
1565/// A non-publishing landing in `dir`: the merge `weave land` (without
1566/// `--onto`/`--queue`) runs, shared by the CLI and the MCP server.
1567pub struct Run<'a> {
1568    pub base: Option<&'a str>,
1569    pub ours: Option<&'a str>,
1570    pub theirs: Option<&'a str>,
1571    /// Judge this revision's files as the merge's answer.
1572    pub result: Option<&'a str>,
1573    pub resolver: Option<Resolver>,
1574    /// In the working tree: label every file but write nothing.
1575    pub dry_run: bool,
1576}
1577
1578/// What a [`run`] decided.
1579pub struct Landing {
1580    /// The JSON report (`--json`, `--certificate`).
1581    pub doc: serde_json::Value,
1582    /// The human summary.
1583    pub text: String,
1584    /// Some file REFUSED, or the result loses a one-sided change.
1585    pub refused: bool,
1586    /// `revisions`, `dry-run` or `working-tree`; only the last writes.
1587    pub mode: &'static str,
1588}
1589
1590/// Plan the merge in `dir`, land every unit, and in the working tree write
1591/// the PROVEN and VERIFIED files back.
1592pub fn run(dir: &Path, args: &Run<'_>, host: &Host) -> R<Landing> {
1593    let revisions = args.base.is_some() || args.ours.is_some() || args.theirs.is_some();
1594    let present = match (revisions, args.result) {
1595        (_, Some(rev)) => Some(Present::Rev(rev.to_string())),
1596        (false, None) => Some(Present::WorkingTree { unmerged: false }),
1597        (true, None) => None,
1598    };
1599    let plan = plan(dir, args.base, args.ours, args.theirs, present.as_ref())?;
1600
1601    let mut reports = plan.not_text.clone();
1602    for unit in &plan.units {
1603        reports.push(land_unit(unit, host, args.resolver.as_ref())?);
1604    }
1605    reports.sort_by(|a, b| a.path.cmp(&b.path));
1606
1607    let mode = if revisions {
1608        "revisions"
1609    } else if args.dry_run {
1610        "dry-run"
1611    } else {
1612        "working-tree"
1613    };
1614    if mode == "working-tree" {
1615        write_back(dir, &plan, &reports)?;
1616    }
1617    // A merge as committed (`result`): every change only one side made must
1618    // be in it too — files the gate never reads, since nothing in them
1619    // conflicts. In the working tree, once nothing is left unmerged: the tree
1620    // the merge commit would hold, however it came about.
1621    let judged = match args.result {
1622        Some(result) => Some(result.to_string()),
1623        None if mode == "working-tree" => crate::preserve::merge_in_tree(dir)?,
1624        None => None,
1625    };
1626    let lost = match &judged {
1627        Some(result) => crate::preserve::check(dir, &plan.base, &plan.ours, &plan.theirs, result)?,
1628        None => Vec::new(),
1629    };
1630    let mut doc = document(&plan, &reports, mode);
1631    if judged.is_some() {
1632        doc["one_sided"] = serde_json::to_value(&lost)?;
1633    }
1634    let mut text = render(&plan, &reports);
1635    if !lost.is_empty() {
1636        text.push_str(&format!(
1637            "REFUSED: the result loses changes only one side made:\n{}\n",
1638            crate::preserve::render(&lost, "ours", "theirs")
1639        ));
1640    }
1641    if mode != "working-tree" {
1642        text.push_str(&format!(
1643            "({mode}: nothing was written to the working tree or the index)\n"
1644        ));
1645    }
1646    let refused = !lost.is_empty() || reports.iter().any(|r| r.status == Status::Refused);
1647    Ok(Landing {
1648        doc,
1649        text,
1650        refused,
1651        mode,
1652    })
1653}
1654
1655// ----------------------------------------------------------------- the report
1656
1657/// The whole run, as `--json` prints it and `--certificate` writes it.
1658pub fn document(plan: &Plan, reports: &[FileReport], mode: &str) -> serde_json::Value {
1659    let count = |s: Status| reports.iter().filter(|r| r.status == s).count();
1660    serde_json::json!({
1661        "schema": SCHEMA,
1662        "merge": {
1663            "base": plan.base,
1664            "ours": plan.ours,
1665            "theirs": plan.theirs,
1666            "mode": mode,
1667        },
1668        "proof_allowances": PROOF_ALLOWANCES,
1669        "union_allowances": UNION_ALLOWANCES,
1670        "summary": {
1671            "proven": count(Status::Proven),
1672            "verified": count(Status::Verified),
1673            "refused": count(Status::Refused),
1674            "not_examined": plan.not_examined.len(),
1675        },
1676        "files": reports,
1677        "not_examined": plan.not_examined,
1678    })
1679}
1680
1681/// The human summary.
1682pub fn render(plan: &Plan, reports: &[FileReport]) -> String {
1683    let count = |s: Status| reports.iter().filter(|r| r.status == s).count();
1684    let mut out = format!(
1685        "weave land: {} file(s) git could not merge — {} PROVEN, {} VERIFIED, {} REFUSED",
1686        reports.len(),
1687        count(Status::Proven),
1688        count(Status::Verified),
1689        count(Status::Refused),
1690    );
1691    if !plan.not_examined.is_empty() {
1692        out.push_str(&format!(
1693            " ({} more that both sides changed git merged line-cleanly, and the merge check passed)",
1694            plan.not_examined.len()
1695        ));
1696    }
1697    out.push('\n');
1698    let width = reports.iter().map(|r| r.path.len()).max().unwrap_or(0);
1699    for r in reports {
1700        let status = match r.status {
1701            Status::Proven => "PROVEN",
1702            Status::Verified => "VERIFIED",
1703            Status::Refused => "REFUSED",
1704        };
1705        out.push_str(&format!(
1706            "  {status:<8}  {:<width$}  {}: {}\n",
1707            r.path, r.rule, r.reason
1708        ));
1709        for f in &r.findings {
1710            out.push_str(&format!("              - {}: {}\n", f.class, f.detail));
1711        }
1712    }
1713    out
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718    use super::*;
1719
1720    fn unit(path: &str, base: &str, ours: &str, theirs: &str, gitmerged: &str) -> Unit {
1721        Unit {
1722            path: path.into(),
1723            kind: Kind::Content,
1724            base: Some(base.into()),
1725            ours: Some(ours.into()),
1726            theirs: Some(theirs.into()),
1727            gitmerged: Some(gitmerged.into()),
1728            present: None,
1729            unverified_git_merge: false,
1730        }
1731    }
1732
1733    #[test]
1734    fn finding_pattern_strips_names_and_digits() {
1735        assert_eq!(
1736            finding_pattern("2 name(s) are defined more than once: `f` (2x)"),
1737            " name(s) are defined more than once:  (x)"
1738        );
1739        assert_eq!(backticked("a `x` b `y` c `z"), vec!["x", "y"]);
1740    }
1741
1742    #[test]
1743    fn markers_are_a_set_difference_with_the_sides() {
1744        let u = unit("a.txt", "x\n", "=======\n", "y\n", "x\n");
1745        // ours verbatim: no MARKERS (ours has the line), though theirs' `y`
1746        // is DROPPED
1747        let v = gate(&u, &Candidate::File("=======\n".into()));
1748        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1749        let v = gate(&u, &Candidate::File("<<<<<<< ours\n".into()));
1750        assert!(v.reasons.contains(&"MARKERS"));
1751    }
1752
1753    #[test]
1754    fn automerged_lines_must_survive() {
1755        let gm =
1756            "keep_this_line_git_merged()\n<<<<<<< ours\na = 1\n=======\na = 2\n>>>>>>> theirs\n";
1757        assert!(automerged(Some("a = 0\n"), Some(gm), "a = 3\n").is_some());
1758        assert!(automerged(
1759            Some("a = 0\n"),
1760            Some(gm),
1761            "keep_this_line_git_merged()\na = 3\n"
1762        )
1763        .is_none());
1764        // a clean git merge (a unit only because the tree holds another
1765        // answer, or git's failed the merge check): every line it wrote that
1766        // base does not have must survive
1767        assert!(automerged(None, Some("keep_this_line_git_merged()\n"), "").is_some());
1768        assert!(automerged(
1769            Some("keep_this_line_git_merged()\n"),
1770            Some("keep_this_line_git_merged()\n"),
1771            ""
1772        )
1773        .is_none());
1774    }
1775
1776    /// The gate on a real three-way merge of `m.py`.
1777    fn gate_py(base: &str, ours: &str, theirs: &str, cand: &str) -> GateVerdict {
1778        let (clean, gm) = line_merge(base, ours, theirs).unwrap();
1779        assert!(!clean, "the fixture must conflict");
1780        gate(
1781            &unit("m.py", base, ours, theirs, &gm),
1782            &Candidate::File(cand.into()),
1783        )
1784    }
1785
1786    fn classes(v: &GateVerdict) -> Vec<&str> {
1787        v.findings.iter().map(|f| f.class.as_str()).collect()
1788    }
1789
1790    // Both sides append a different entry to one dict, at the same place.
1791    const D_BASE: &str = "T = {\n    'alpha': 1,\n    'omega': 9,\n}\n";
1792    const D_OURS: &str = "T = {\n    'alpha': 1,\n    'beta': 2,\n    'omega': 9,\n}\n";
1793    const D_THEIRS: &str = "T = {\n    'alpha': 1,\n    'gamma': 3,\n    'omega': 9,\n}\n";
1794
1795    #[test]
1796    fn one_sided_resolutions_are_dropped() {
1797        for (cand, side) in [(D_OURS, "theirs"), (D_THEIRS, "ours")] {
1798            let v = gate_py(D_BASE, D_OURS, D_THEIRS, cand);
1799            assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1800            assert!(v.findings[0].detail.starts_with(side), "{v:?}");
1801        }
1802    }
1803
1804    #[test]
1805    fn a_union_in_either_order_or_rewrapped_passes() {
1806        for cand in [
1807            "T = {\n    'alpha': 1,\n    'beta': 2,\n    'gamma': 3,\n    'omega': 9,\n}\n",
1808            "T = {\n    'alpha': 1,\n    'gamma': 3,\n    'beta': 2,\n    'omega': 9,\n}\n",
1809            "T = {\n    'alpha': 1,\n    'beta': 2, 'gamma': 3,\n    'omega': 9,\n}\n",
1810        ] {
1811            let v = gate_py(D_BASE, D_OURS, D_THEIRS, cand);
1812            assert!(v.pass(), "{cand}: {v:?}");
1813        }
1814    }
1815
1816    #[test]
1817    fn an_identical_change_inside_a_block_is_owed_once() {
1818        // both add `'beta': 2`; each adds one more entry of its own
1819        let ours = "T = {\n    'alpha': 1,\n    'beta': 2,\n    'delta': 4,\n    'omega': 9,\n}\n";
1820        let theirs =
1821            "T = {\n    'alpha': 1,\n    'beta': 2,\n    'gamma': 3,\n    'omega': 9,\n}\n";
1822        let union = "T = {\n    'alpha': 1,\n    'beta': 2,\n    'delta': 4,\n    'gamma': 3,\n    'omega': 9,\n}\n";
1823        assert!(gate_py(D_BASE, ours, theirs, union).pass());
1824        let v = gate_py(D_BASE, ours, theirs, ours);
1825        assert_eq!(classes(&v), vec!["DROPPED"], "{v:?}");
1826        assert!(v.findings[0].detail.contains("`gamma`"), "{v:?}");
1827    }
1828
1829    #[test]
1830    fn a_side_that_subsumes_the_other_passes_verbatim() {
1831        // theirs adds `beta`; ours adds `beta` and `gamma`: ours holds both changes
1832        let ours = "T = {\n    'alpha': 1,\n    'beta': 2,\n    'gamma': 3,\n    'omega': 9,\n}\n";
1833        let v = gate_py(D_BASE, ours, D_OURS, ours);
1834        assert!(v.pass(), "{v:?}");
1835        // the subsumed side is not enough
1836        let v = gate_py(D_BASE, ours, D_OURS, D_OURS);
1837        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1838    }
1839
1840    #[test]
1841    fn two_edits_of_one_line_folded_into_one_line_pass() {
1842        let base = "def f():\n    return os.sep\n";
1843        let ours = "def f():\n    return os.sep + 'a'\n";
1844        let theirs = "def f():\n    return os.sep + 'b'\n";
1845        assert!(gate_py(
1846            base,
1847            ours,
1848            theirs,
1849            "def f():\n    return os.sep + 'a' + 'b'\n"
1850        )
1851        .pass());
1852        // incompatible edits of one value: picking one is a dropped change
1853        let (o, t) = ("X = 2\n", "X = 3\n");
1854        let v = gate_py("X = 1\n", o, t, o);
1855        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1856    }
1857
1858    #[test]
1859    fn delete_vs_modify_inside_a_block_is_refused_either_way() {
1860        let base = "def keep():\n    return 0\n\n\ndef f():\n    return 1\n";
1861        let ours = "def keep():\n    return 0\n";
1862        let theirs = "def keep():\n    return 0\n\n\ndef f():\n    return 2\n";
1863        // keep theirs' edit: ours' deletion is undone
1864        let v = gate_py(base, ours, theirs, theirs);
1865        assert!(classes(&v).contains(&"UNDELETED"), "{v:?}");
1866        // keep ours' deletion: theirs' edit is dropped
1867        let v = gate_py(base, ours, theirs, ours);
1868        assert!(classes(&v).contains(&"DROPPED"), "{v:?}");
1869    }
1870
1871    #[test]
1872    fn a_one_sided_deletion_must_stay_deleted() {
1873        // ours deletes `old`, theirs adds `new` next to it
1874        let base = "A = [\n    'first_entry',\n    'old_entry',\n]\n";
1875        let ours = "A = [\n    'first_entry',\n]\n";
1876        let theirs = "A = [\n    'first_entry',\n    'old_entry',\n    'new_entry',\n]\n";
1877        let v = gate_py(base, ours, theirs, theirs);
1878        assert_eq!(classes(&v), vec!["UNDELETED"], "{v:?}");
1879        assert!(v.findings[0].detail.contains("`old_entry`"), "{v:?}");
1880        let both = "A = [\n    'first_entry',\n    'new_entry',\n]\n";
1881        assert!(gate_py(base, ours, theirs, both).pass());
1882    }
1883
1884    #[test]
1885    fn add_add_keeps_both_files_content() {
1886        let ours = "def a():\n    return 'from ours'\n";
1887        let theirs = "def b():\n    return 'from theirs'\n";
1888        let (_, gm) = line_merge("", ours, theirs).unwrap();
1889        let u = Unit {
1890            kind: Kind::AddAdd,
1891            base: None,
1892            ..unit("m.py", "", ours, theirs, &gm)
1893        };
1894        let v = gate(&u, &Candidate::File(ours.into()));
1895        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1896        let both = format!("{ours}\n\n{theirs}");
1897        assert!(gate(&u, &Candidate::File(both)).pass());
1898    }
1899
1900    #[test]
1901    fn non_ascii_lines_near_a_block_are_read() {
1902        // a 41-byte prefix that ends inside a multi-byte char must not panic
1903        let base = "x = 1\n        # Test NULL literal: Snowflake \u{2192} DuckDB\n";
1904        let ours = "x = 2\n        # Test NULL literal: Snowflake \u{2192} DuckDB\n";
1905        let theirs = "x = 3\n        # Test NULL literal: Snowflake \u{2192} DuckDB\n";
1906        let v = gate_py(base, ours, theirs, ours);
1907        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
1908        let marker_like = "=".repeat(41) + " \u{2192}\n";
1909        let d = diff3_blocks(
1910            &marker_like,
1911            &format!("a\n{marker_like}"),
1912            &format!("b\n{marker_like}"),
1913        )
1914        .unwrap();
1915        assert_eq!(d.regions.len(), 1);
1916    }
1917
1918    #[test]
1919    fn tokens_ignore_layout_and_brackets() {
1920        let t: Vec<&str> = tokens("  exp.IsInf(this=e.this.copy()), x >= 10_0").collect();
1921        assert_eq!(
1922            t,
1923            ["exp", "IsInf", "this", "=", "e", "this", "copy", "x", ">=", "10_0"]
1924        );
1925    }
1926
1927    #[test]
1928    fn newlines_follow_the_sides() {
1929        assert_eq!(follow_newlines("a".into(), &["x\n", "y\n"]), "a\n");
1930        assert_eq!(follow_newlines("a\n".into(), &["x", "y"]), "a");
1931        assert_eq!(
1932            follow_newlines("a\nb\n".into(), &["x\r\n", "y\r\n"]),
1933            "a\r\nb\r\n"
1934        );
1935    }
1936
1937    // An element union inside a class: both sides add an entry at one point of a
1938    // registry dict. The union is admitted only by the `elem_union` check; a
1939    // result with one side's entry removed, or base order changed, is not
1940    // admitted by anything, so it could never land without the resolver.
1941    #[test]
1942    fn certify_admits_an_element_union_and_refuses_a_tampered_one() {
1943        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";
1944        let at = "        exp.IntDiv: rename_func(\"DIV\"),\n";
1945        let ins = |x: &str| base.replace(at, &format!("{at}{x}"));
1946        let (fin, unix) = (
1947            "        exp.IsFinite: rename_func(\"IS_FINITE\"),\n",
1948            "        exp.TimeToUnix: rename_func(\"UNIX_SECONDS\"),\n",
1949        );
1950        let (ours, theirs) = (ins(fin), ins(unix));
1951        let u = unit("gen.py", base, &ours, &theirs, "");
1952        let union = ins(&format!("{fin}{unix}"));
1953        assert_eq!(certify(&u, &union), Ok((vec!["elem_union"], true)));
1954        // and weave's own merge is that union, or the other order
1955        let host = Host::default();
1956        let merged = entity_merge_fmt(
1957            base,
1958            &ours,
1959            &theirs,
1960            "gen.py",
1961            &MarkerFormat::default(),
1962            &host,
1963        );
1964        assert!(merged.is_clean());
1965        assert_eq!(certify(&u, &merged.content), Ok((vec!["elem_union"], true)));
1966        let abs = "        exp.Abs: rename_func(\"ABS\"),\n";
1967        for (what, bad) in [
1968            ("ours dropped", union.replace(fin, "")),
1969            ("theirs dropped", union.replace(unix, "")),
1970            (
1971                "base order changed",
1972                union.replace(&format!("{abs}{at}"), &format!("{at}{abs}")),
1973            ),
1974            (
1975                "an entry rewritten",
1976                union.replace("UNIX_SECONDS", "UNIX_MILLIS"),
1977            ),
1978        ] {
1979            assert!(
1980                certify(&u, &bad).is_err(),
1981                "{what}: {:?}",
1982                certify(&u, &bad)
1983            );
1984        }
1985    }
1986
1987    // Two features each add `case 3:` to one switch. git
1988    // merges it line-cleanly, the result does not compile.
1989    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";
1990    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";
1991    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";
1992
1993    #[test]
1994    fn a_line_clean_duplicate_case_fails_the_merge_check_and_the_gate() {
1995        let (clean, merged) = line_merge(SW_BASE, SW_OURS, SW_THEIRS).unwrap();
1996        assert!(clean, "git merges it line-cleanly");
1997        assert!(!line_clean_holds(
1998            "p.go", SW_BASE, SW_OURS, SW_THEIRS, &merged
1999        ));
2000        let u = Unit {
2001            present: Some(Candidate::File(merged.clone())),
2002            unverified_git_merge: true,
2003            ..unit("p.go", SW_BASE, SW_OURS, SW_THEIRS, &merged)
2004        };
2005        let v = gate(&u, &Candidate::File(merged));
2006        assert!(
2007            v.findings
2008                .iter()
2009                .any(|f| f.class == "DUP" && f.detail.contains("case `3`")),
2010            "{v:?}"
2011        );
2012        let r = land_unit(&u, &Host::default(), None).unwrap();
2013        assert_eq!(r.status, Status::Refused, "{r:?}");
2014        // left conflicted, with markers, not as git's (compiling-looking) merge
2015        match &r.landed {
2016            Landed::Conflicted(Some(t)) => assert!(!markers(t).is_empty(), "{t}"),
2017            other => panic!("{other:?}"),
2018        }
2019        // A resolution must state `case 3` once, so it differs from git's
2020        // merge; keeping both sides' cases (one renumbered) passes, dropping
2021        // one side's case does not.
2022        let renumbered = SW_OURS
2023            .replace("case 3:\n\t\treturn 31", "case 4:\n\t\treturn 31")
2024            .replace(
2025                "\t}\n\treturn 0",
2026                "\tcase 3:\n\t\treturn 32\n\t}\n\treturn 0",
2027            );
2028        assert!(gate(&u, &Candidate::File(renumbered)).pass());
2029        let v = gate(&u, &Candidate::File(SW_OURS.into()));
2030        assert_eq!(v.reasons, vec!["DROPPED"], "{v:?}");
2031    }
2032
2033    #[test]
2034    fn the_answer_in_the_tree_is_judged_before_the_resolver() {
2035        // a both-sides edit git conflicts on; the tree already holds a merge
2036        // that keeps both changes
2037        let base = "def f():\n    return 1\n\n\ndef g():\n    return 2\n";
2038        let ours = "def f():\n    return 10\n\n\ndef g():\n    return 2\n";
2039        let theirs = "def f():\n    return 1\n\n\ndef g():\n    return 20\n";
2040        let (_, gm) = line_merge(base, ours, theirs).unwrap();
2041        let both = "def f():\n    return 10\n\n\ndef g():\n    return 20\n";
2042        let u = Unit {
2043            present: Some(Candidate::File(both.into())),
2044            ..unit("m.py", base, ours, theirs, &gm)
2045        };
2046        let r = land_unit(&u, &Host::default(), None).unwrap();
2047        assert_ne!(r.status, Status::Refused, "{r:?}");
2048        assert_eq!(r.landed, Landed::File(both.into()));
2049        // one side's file as the answer drops the other's change (git merged
2050        // this file line-cleanly: the change outside any block must survive)
2051        let u = Unit {
2052            present: Some(Candidate::File(ours.into())),
2053            ..unit("m.py", base, ours, theirs, &gm)
2054        };
2055        let r = land_unit(&u, &Host::default(), None).unwrap();
2056        assert_eq!(r.status, Status::Refused, "{r:?}");
2057    }
2058
2059    #[test]
2060    fn answers() {
2061        assert_eq!(parse_answer("DELETE\n".into()), Answer::Delete);
2062        assert_eq!(parse_answer(" KEEP ".into()), Answer::Keep);
2063        assert_eq!(
2064            parse_answer("CANNOT: too big\n".into()),
2065            Answer::Cannot("too big".into())
2066        );
2067        assert_eq!(
2068            parse_answer("CANNOT\n".into()),
2069            Answer::Cannot(String::new())
2070        );
2071        assert_eq!(
2072            parse_answer("CANNOTATION\n".into()),
2073            Answer::File("CANNOTATION\n".into())
2074        );
2075        assert_eq!(
2076            parse_answer("DELETE\nx\n".into()),
2077            Answer::File("DELETE\nx\n".into())
2078        );
2079    }
2080}