1use std::collections::{BTreeMap, BTreeSet, HashMap};
24use std::path::Path;
25
26use serde::Serialize;
27
28use crate::gitscan;
29
30type R<T> = Result<T, Box<dyn std::error::Error>>;
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct Violation {
35 pub path: String,
36 pub side: &'static str,
38 pub detail: String,
39}
40
41type Entry = Option<(String, String)>;
43
44fn changes(dir: &Path, from: &str, to: &str) -> R<BTreeMap<String, Entry>> {
46 let raw = gitscan::git(
47 dir,
48 &[
49 "diff",
50 "--raw",
51 "-z",
52 "--no-renames",
53 "--no-abbrev",
54 "--no-ext-diff",
55 "--no-textconv",
56 from,
57 to,
58 ],
59 )?;
60 let mut out = BTreeMap::new();
61 let mut parts = raw.split('\0').filter(|p| !p.is_empty());
62 while let Some(meta) = parts.next() {
63 let Some(path) = parts.next() else { break };
64 let f: Vec<&str> = meta.trim_start_matches(':').split(' ').collect();
66 if f.len() < 5 {
67 return Err(format!("could not read `git diff --raw` line `{meta}`").into());
68 }
69 let entry = if f[4].starts_with('D') {
70 None
71 } else {
72 Some((f[1].to_string(), f[3].to_string()))
73 };
74 out.insert(path.to_string(), entry);
75 }
76 Ok(out)
77}
78
79fn cat_file(dir: &Path, args: &[&str]) -> (bool, Vec<u8>) {
81 let mut cmd = std::process::Command::new("git");
82 cmd.arg("cat-file").args(args).current_dir(dir);
83 match gitscan::run_bounded(
84 cmd,
85 "git cat-file",
86 None,
87 std::time::Duration::from_secs(120),
88 ) {
89 Ok((status, out, _)) => (status.success(), out),
90 Err(_) => (false, Vec::new()),
91 }
92}
93
94fn blob(dir: &Path, rev: &str, path: &str) -> Option<String> {
96 match cat_file(dir, &["blob", &format!("{rev}:{path}")]) {
97 (true, bytes) => String::from_utf8(bytes).ok(),
98 _ => None,
99 }
100}
101
102pub fn check(dir: &Path, base: &str, ours: &str, theirs: &str, result: &str) -> R<Vec<Violation>> {
105 let o = changes(dir, base, ours)?;
106 let t = changes(dir, base, theirs)?;
107 let r = changes(dir, base, result)?;
108 let mut out = Vec::new();
109
110 for (path, te) in t.iter().filter(|(p, _)| !o.contains_key(*p)) {
111 let re = r.get(path);
113 if re == Some(te) {
114 continue;
115 }
116 let detail = match (te, re) {
117 (None, _) => Some("theirs deleted the file; the merge still has it".to_string()),
118 (Some(_), None) => Some(
119 "the merge has the file exactly as the merge base has it: theirs' change to it \
120 is gone"
121 .to_string(),
122 ),
123 (Some(_), Some(None)) => {
124 Some("the merge deletes the file, which theirs changed".to_string())
125 }
126 (Some(_), Some(Some(_))) => {
127 let b = blob(dir, base, path).or_else(|| {
128 (!rev_has(dir, base, path)).then(String::new)
130 });
131 match (b, blob(dir, theirs, path), blob(dir, result, path)) {
132 (Some(b), Some(s), Some(m)) => contains_change(&b, &s, &m),
133 _ => Some(
134 "the merge's version differs from theirs and is not text weave can \
135 compare"
136 .to_string(),
137 ),
138 }
139 }
140 };
141 if let Some(detail) = detail {
142 out.push(Violation {
143 path: path.clone(),
144 side: "theirs",
145 detail,
146 });
147 }
148 }
149
150 for (path, oe) in o.iter().filter(|(p, _)| !t.contains_key(*p)) {
151 let re = r.get(path);
152 if re == Some(oe) {
153 continue;
154 }
155 let detail = match (oe, re) {
156 (None, None) => Some(
157 "ours deleted the file; the merge has it back, as the merge base has it"
158 .to_string(),
159 ),
160 (Some(_), None) => Some(
161 "the merge has the file exactly as the merge base has it: ours' change to it is \
162 gone"
163 .to_string(),
164 ),
165 (Some(_), Some(None)) => {
166 Some("the merge deletes the file, which ours changed".to_string())
167 }
168 _ => None, };
170 if let Some(detail) = detail {
171 out.push(Violation {
172 path: path.clone(),
173 side: "ours",
174 detail,
175 });
176 }
177 }
178 Ok(out)
179}
180
181fn rev_has(dir: &Path, rev: &str, path: &str) -> bool {
182 cat_file(dir, &["-e", &format!("{rev}:{path}")]).0
183}
184
185pub fn index_tree(dir: &Path, gd: &Path) -> R<String> {
188 let scratch = gd.join(format!("weave-land-index-{}", std::process::id()));
189 std::fs::copy(gd.join("index"), &scratch)?;
190 let mut cmd = std::process::Command::new("git");
191 cmd.args(["write-tree"])
192 .current_dir(dir)
193 .env("GIT_INDEX_FILE", &scratch);
194 let run = gitscan::run_bounded(
195 cmd,
196 "git write-tree",
197 None,
198 std::time::Duration::from_secs(120),
199 );
200 let _ = std::fs::remove_file(&scratch);
201 let (status, stdout, stderr) = run?;
202 if !status.success() {
203 return Err(format!(
204 "git write-tree failed: {}",
205 String::from_utf8_lossy(&stderr).trim()
206 )
207 .into());
208 }
209 Ok(String::from_utf8_lossy(&stdout).trim().to_string())
210}
211
212pub fn merge_in_tree(dir: &Path) -> R<Option<String>> {
216 let gd =
217 std::path::PathBuf::from(gitscan::git(dir, &["rev-parse", "--absolute-git-dir"])?.trim());
218 if !gd.join("MERGE_HEAD").exists()
219 || !gitscan::git(dir, &["diff", "--name-only", "--diff-filter=U"])?
220 .trim()
221 .is_empty()
222 {
223 return Ok(None);
224 }
225 Ok(Some(index_tree(dir, &gd)?))
226}
227
228pub fn check_now(
231 dir: &Path,
232 base: &str,
233 ours: &str,
234 theirs: &str,
235 result: &str,
236 now: &str,
237) -> R<Vec<Violation>> {
238 let at_merge = check(dir, base, ours, theirs, result)?;
239 if at_merge.is_empty() {
240 return Ok(at_merge);
241 }
242 let at_now: BTreeSet<(String, &'static str)> = check(dir, base, ours, theirs, now)?
243 .into_iter()
244 .map(|v| (v.path, v.side))
245 .collect();
246 Ok(at_merge
247 .into_iter()
248 .filter(|v| at_now.contains(&(v.path.clone(), v.side)))
249 .collect())
250}
251
252pub fn contains_change(base: &str, side: &str, merged: &str) -> Option<String> {
257 let (b, s, m) = (counts(base), counts(side), counts(merged));
258 let get = |c: &HashMap<&str, usize>, k: &str| c.get(k).copied().unwrap_or(0);
259 let mut missing: Vec<&str> = Vec::new();
260 let mut back: Vec<&str> = Vec::new();
261 for (k, &n) in &s {
262 if n > get(&b, k) && get(&m, k) < n {
263 missing.push(k);
264 }
265 }
266 for (k, &n) in &b {
267 let ns = get(&s, k);
268 if ns < n && get(&m, k) > ns {
269 back.push(k);
270 }
271 }
272 if missing.is_empty() && back.is_empty() {
273 return None;
274 }
275 let order = |v: &mut Vec<&str>| {
276 v.sort_by_key(|k| {
277 let word = k
278 .chars()
279 .next()
280 .is_some_and(|c| c.is_alphanumeric() || c == '_');
281 (!word, std::cmp::Reverse(k.len()), k.to_string())
282 })
283 };
284 order(&mut missing);
285 order(&mut back);
286 let names = |v: &[&str]| {
287 v.iter()
288 .take(5)
289 .map(|k| format!("`{k}`"))
290 .collect::<Vec<_>>()
291 .join(", ")
292 };
293 let mut parts = Vec::new();
294 if !missing.is_empty() {
295 parts.push(format!(
296 "{} token(s) theirs added are missing, e.g. {}",
297 missing.len(),
298 names(&missing)
299 ));
300 }
301 if !back.is_empty() {
302 parts.push(format!(
303 "{} token(s) theirs deleted are back, e.g. {}",
304 back.len(),
305 names(&back)
306 ));
307 }
308 Some(format!(
309 "the merge's version does not contain theirs' change: {}",
310 parts.join("; ")
311 ))
312}
313
314fn tokens(line: &str) -> Vec<&str> {
317 const OPS: &str = "+-*/%=<>!&|^~@?";
318 let class = |c: char| {
319 if c.is_alphanumeric() || c == '_' {
320 1
321 } else if OPS.contains(c) {
322 2
323 } else {
324 0
325 }
326 };
327 let mut out = Vec::new();
328 let mut start: Option<(usize, u8)> = None;
329 for (i, c) in line
330 .char_indices()
331 .chain(std::iter::once((line.len(), ' ')))
332 {
333 let k = class(c);
334 match start {
335 Some((s, sk)) if sk != k => {
336 out.push(&line[s..i]);
337 start = (k != 0).then_some((i, k));
338 }
339 None if k != 0 => start = Some((i, k)),
340 _ => {}
341 }
342 }
343 out
344}
345
346fn counts(text: &str) -> HashMap<&str, usize> {
347 let mut m = HashMap::new();
348 for l in text.lines() {
349 for t in tokens(l) {
350 *m.entry(t).or_insert(0) += 1;
351 }
352 }
353 m
354}
355
356pub fn render(v: &[Violation], ours: &str, theirs: &str) -> String {
358 let width = v.iter().map(|x| x.path.len()).max().unwrap_or(0);
359 v.iter()
360 .map(|x| {
361 let who = if x.side == "theirs" { theirs } else { ours };
362 format!(
363 " {:<width$} (changed only by {who}; that change is lost: {})",
364 x.path, x.detail
365 )
366 })
367 .collect::<Vec<_>>()
368 .join("\n")
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn a_revert_to_base_does_not_contain_the_change() {
377 let base = "package p\n\nfunc A() {}\n";
378 let theirs = "package p\n\nfunc A() {}\n\nfunc Shift(x int) int { return x << 1 }\n";
379 assert!(contains_change(base, theirs, theirs).is_none());
380 let why = contains_change(base, theirs, base).expect("a revert loses the change");
381 assert!(why.contains("`Shift`"), "{why}");
382 let plus = theirs.replace("func A() {}", "func A() { _ = 1 }");
384 assert!(contains_change(base, theirs, &plus).is_none());
385 assert!(contains_change(theirs, base, theirs).is_some());
387 }
388}