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
723pub 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#[derive(Debug, Clone, PartialEq, Eq)]
747pub enum Answer {
748 File(String),
749 Delete,
750 Keep,
751 Cannot(String),
752 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 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
835fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
858#[serde(rename_all = "UPPERCASE")]
859pub enum Status {
860 Proven,
861 Verified,
862 Refused,
863}
864
865#[derive(Debug, Clone, PartialEq, Eq)]
867pub enum Landed {
868 File(String),
869 Deleted,
870 Conflicted(Option<String>),
873}
874
875#[derive(Debug, Clone, Serialize)]
877pub struct FileReport {
878 pub path: String,
879 pub kind: Kind,
880 pub status: Status,
881 pub rule: &'static str,
884 pub reason: String,
886 pub reasons: Vec<&'static str>,
888 pub findings: Vec<GateFinding>,
889 pub attempts: usize,
891 pub weave: &'static str,
893 pub sha256: Option<String>,
895 #[serde(skip)]
896 pub landed: Landed,
897}
898
899pub 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 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 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 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 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
1120fn 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
1139fn 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(®ISTRY, &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(®ISTRY, &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 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
1169fn 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#[derive(Debug, Clone, PartialEq, Eq)]
1212pub enum Present {
1213 WorkingTree { unmerged: bool },
1218 Rev(String),
1220}
1221
1222pub struct Plan {
1224 pub base: String,
1225 pub ours: String,
1226 pub theirs: String,
1227 pub units: Vec<Unit>,
1228 pub not_text: Vec<FileReport>,
1231 pub not_examined: Vec<String>,
1235 pub stages: BTreeMap<String, [Option<(String, String)>; 3]>,
1238}
1239
1240pub 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; }
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 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
1377fn 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, ®ISTRY, &[])
1389 }));
1390 matches!(run, Ok(None))
1391}
1392
1393fn 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
1455fn line_merge(base: &str, ours: &str, theirs: &str) -> R<(bool, String)> {
1458 line_merge_with(base, ours, theirs, &[])
1459}
1460
1461fn 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 .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
1508pub 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 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
1563pub struct Run<'a> {
1568 pub base: Option<&'a str>,
1569 pub ours: Option<&'a str>,
1570 pub theirs: Option<&'a str>,
1571 pub result: Option<&'a str>,
1573 pub resolver: Option<Resolver>,
1574 pub dry_run: bool,
1576}
1577
1578pub struct Landing {
1580 pub doc: serde_json::Value,
1582 pub text: String,
1584 pub refused: bool,
1586 pub mode: &'static str,
1588}
1589
1590pub 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 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
1655pub 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
1681pub 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 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 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 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 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 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 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 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 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 let v = gate_py(base, ours, theirs, theirs);
1865 assert!(classes(&v).contains(&"UNDELETED"), "{v:?}");
1866 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 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 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 #[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 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 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 match &r.landed {
2016 Landed::Conflicted(Some(t)) => assert!(!markers(t).is_empty(), "{t}"),
2017 other => panic!("{other:?}"),
2018 }
2019 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 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 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}