1use 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
53pub const PROOF_ALLOWANCES: [&str; 3] = ["imp_used", "subsume_ins", "nest"];
58
59pub const UNION_ALLOWANCES: [&str; 2] = ["elem_union", "nest_eu"];
65
66pub const SCHEMA: &str = "weave-land/1";
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
73pub enum Kind {
74 #[serde(rename = "content")]
76 Content,
77 #[serde(rename = "add/add")]
79 AddAdd,
80 #[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#[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 pub gitmerged: Option<String>,
108 pub present: Option<Candidate>,
114 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#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum Candidate {
135 File(String),
136 Delete,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct GateFinding {
144 pub class: String,
145 pub detail: String,
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
150pub struct GateVerdict {
151 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 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
194pub fn gate(u: &Unit, cand: &Candidate) -> GateVerdict {
214 gate_with(u, cand, false)
215}
216
217fn 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
225fn 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 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
333fn 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
342fn 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
348fn 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
377fn 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
391fn 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
404fn 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
425fn 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
461fn 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 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 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 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
630struct 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 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 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 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
680fn 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#[derive(Debug, Clone)]
736pub struct Resolver {
737 pub command: String,
738 pub timeout: Duration,
739}
740
741#[derive(Debug, Clone, PartialEq, Eq)]
743pub enum Answer {
744 File(String),
745 Delete,
746 Keep,
747 Cannot(String),
748 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 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
831fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
854#[serde(rename_all = "UPPERCASE")]
855pub enum Status {
856 Proven,
857 Verified,
858 Refused,
859}
860
861#[derive(Debug, Clone, PartialEq, Eq)]
863pub enum Landed {
864 File(String),
865 Deleted,
866 Conflicted(Option<String>),
869}
870
871#[derive(Debug, Clone, Serialize)]
873pub struct FileReport {
874 pub path: String,
875 pub kind: Kind,
876 pub status: Status,
877 pub rule: &'static str,
880 pub reason: String,
882 pub reasons: Vec<&'static str>,
884 pub findings: Vec<GateFinding>,
885 pub attempts: usize,
887 pub weave: &'static str,
889 pub sha256: Option<String>,
891 #[serde(skip)]
892 pub landed: Landed,
893}
894
895pub 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 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 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 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 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
1116fn 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
1135fn 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(®ISTRY, &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(®ISTRY, &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 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
1165fn 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#[derive(Debug, Clone, PartialEq, Eq)]
1208pub enum Present {
1209 WorkingTree { unmerged: bool },
1214 Rev(String),
1216}
1217
1218pub struct Plan {
1220 pub base: String,
1221 pub ours: String,
1222 pub theirs: String,
1223 pub units: Vec<Unit>,
1224 pub not_text: Vec<FileReport>,
1227 pub not_examined: Vec<String>,
1231 pub stages: BTreeMap<String, [Option<(String, String)>; 3]>,
1234}
1235
1236pub 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; }
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 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
1373fn 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, ®ISTRY, &[])
1385 }));
1386 matches!(run, Ok(None))
1387}
1388
1389fn 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
1451fn line_merge(base: &str, ours: &str, theirs: &str) -> R<(bool, String)> {
1454 line_merge_with(base, ours, theirs, &[])
1455}
1456
1457fn 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 .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
1504pub 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 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
1559pub struct Run<'a> {
1564 pub base: Option<&'a str>,
1565 pub ours: Option<&'a str>,
1566 pub theirs: Option<&'a str>,
1567 pub result: Option<&'a str>,
1569 pub resolver: Option<Resolver>,
1570 pub dry_run: bool,
1572}
1573
1574pub struct Landing {
1576 pub doc: serde_json::Value,
1578 pub text: String,
1580 pub refused: bool,
1582 pub mode: &'static str,
1584}
1585
1586pub 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 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
1651pub 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
1677pub 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 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 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 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 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 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 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 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 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 let v = gate_py(base, ours, theirs, theirs);
1861 assert!(classes(&v).contains(&"UNDELETED"), "{v:?}");
1862 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 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 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 #[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 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 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 match &r.landed {
2012 Landed::Conflicted(Some(t)) => assert!(!markers(t).is_empty(), "{t}"),
2013 other => panic!("{other:?}"),
2014 }
2015 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 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 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}