Skip to main content

weave_cli/
gitscan.rs

1//! Reading whole trees out of git, for the repo-scope pass.
2//!
3//! Deliberately shells out instead of reusing weave-core's git module: the
4//! repo-scope pass must keep working while weave-core's internals move, and the
5//! only thing it needs from git is "give me every supported file at this rev".
6
7use std::collections::BTreeSet;
8use std::io::Write;
9use std::path::Path;
10use std::process::{Command, Stdio};
11
12use crate::repo_scope::{is_supported, Tree};
13
14type R<T> = Result<T, Box<dyn std::error::Error>>;
15
16fn git(dir: &Path, args: &[&str]) -> R<String> {
17    let out = Command::new("git").args(args).current_dir(dir).output()?;
18    if !out.status.success() {
19        return Err(format!(
20            "git {} failed: {}",
21            args.join(" "),
22            String::from_utf8_lossy(&out.stderr).trim()
23        )
24        .into());
25    }
26    Ok(String::from_utf8_lossy(&out.stdout).to_string())
27}
28
29/// The blobs at `<rev>:<path>` for a *bounded* set of paths, fetched in ONE
30/// `git cat-file --batch` process instead of a `git show` fork per path.
31///
32/// This is what lets the working-tree check scope to the files a merge touched:
33/// `read_rev_tree` reads the WHOLE tree at a rev with a subprocess per file, an
34/// O(repo) cost that dominates on a large monorepo. Here the caller names the
35/// handful of paths it actually needs and pays one process for all of them.
36///
37/// A path absent at that rev (git answers `… missing`) is simply not inserted —
38/// the same silent skip `read_rev_tree` gives an unreadable blob.
39fn read_paths_at_rev(dir: &Path, rev: &str, paths: &[String]) -> R<Tree> {
40    let mut tree = Tree::new();
41    if paths.is_empty() {
42        return Ok(tree);
43    }
44    let mut child = Command::new("git")
45        .args(["cat-file", "--batch"])
46        .current_dir(dir)
47        .stdin(Stdio::piped())
48        .stdout(Stdio::piped())
49        .stderr(Stdio::null())
50        .spawn()?;
51
52    // Feed every request, then close stdin so git flushes and exits. The order
53    // of requests is the order of replies, so we replay `paths` to label them.
54    {
55        let mut stdin = child.stdin.take().ok_or("cat-file stdin")?;
56        let mut buf = String::new();
57        for p in paths {
58            buf.push_str(rev);
59            buf.push(':');
60            buf.push_str(p);
61            buf.push('\n');
62        }
63        stdin.write_all(buf.as_bytes())?;
64        // stdin dropped here → EOF to git.
65    }
66    let out = child.wait_with_output()?;
67    if !out.status.success() {
68        return Err("git cat-file --batch failed".into());
69    }
70
71    // Reply framing: `<oid> <type> <size>\n<size bytes>\n`, or `<spec> missing\n`.
72    let bytes = &out.stdout;
73    let mut pos = 0usize;
74    for p in paths {
75        // Read one header line.
76        let Some(nl) = bytes[pos..].iter().position(|&b| b == b'\n') else {
77            break;
78        };
79        let header = String::from_utf8_lossy(&bytes[pos..pos + nl]).to_string();
80        pos += nl + 1;
81        let fields: Vec<&str> = header.rsplitn(3, ' ').collect();
82        // `rsplitn(3)` yields [size, type, oid] for a found object; a `missing`
83        // line has no size to parse.
84        if fields.len() == 3 {
85            if let Ok(size) = fields[0].parse::<usize>() {
86                let content = &bytes[pos..pos + size];
87                if let Ok(s) = std::str::from_utf8(content) {
88                    tree.insert(p.clone(), s.to_string());
89                }
90                pos += size + 1; // trailing newline after the payload
91                continue;
92            }
93        }
94        // `missing` / malformed: nothing consumed past the header, skip the path.
95    }
96    Ok(tree)
97}
98
99pub(crate) fn rev_exists(dir: &Path, rev: &str) -> bool {
100    Command::new("git")
101        .args(["rev-parse", "--verify", "--quiet", rev])
102        .current_dir(dir)
103        .output()
104        .map(|o| o.status.success())
105        .unwrap_or(false)
106}
107
108/// Resolve the merge triple, defaulting to the merge in progress.
109///
110/// `ours` defaults to `HEAD`; `theirs` defaults to `MERGE_HEAD` (so a bare
111/// `weave check` mid-merge does the obvious thing); `base` defaults to the
112/// merge base of the two.
113pub(crate) fn resolve_revs(
114    dir: &Path,
115    base: Option<&str>,
116    ours: Option<&str>,
117    theirs: Option<&str>,
118) -> R<(String, String, String)> {
119    if !rev_exists(dir, "HEAD") {
120        return Err(
121            "not a git repository (or no commits yet) — use the directory mode instead".into(),
122        );
123    }
124    let ours = ours.unwrap_or("HEAD").to_string();
125    let theirs = match theirs {
126        Some(t) => t.to_string(),
127        None if rev_exists(dir, "MERGE_HEAD") => "MERGE_HEAD".to_string(),
128        None => {
129            return Err("no --theirs given and no merge in progress (MERGE_HEAD absent)".into())
130        }
131    };
132    let base = match base {
133        Some(b) => b.to_string(),
134        None => git(dir, &["merge-base", &ours, &theirs])?
135            .trim()
136            .to_string(),
137    };
138    Ok((base, ours, theirs))
139}
140
141pub fn trees(
142    dir: &Path,
143    base: Option<&str>,
144    ours: Option<&str>,
145    theirs: Option<&str>,
146) -> R<(Tree, Tree, Tree)> {
147    let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
148    Ok((
149        read_rev_tree(dir, &base)?,
150        read_rev_tree(dir, &ours)?,
151        read_rev_tree(dir, &theirs)?,
152    ))
153}
154
155/// Everything the working-tree check needs: the three merge stages, the bytes
156/// on disk, the files the merge actually had to decide, and a sentence naming
157/// what was compared.
158pub struct MergeScope {
159    pub base: Tree,
160    pub ours: Tree,
161    pub theirs: Tree,
162    /// The working tree, as it is right now.
163    pub work: Tree,
164    /// Files BOTH sides changed, plus anything git still has unmerged.
165    pub subjects: Vec<String>,
166    pub scope: String,
167}
168
169/// Find the merge this repository is in — or has just finished — and read it.
170///
171/// Two shapes, in order, because they are the two moments an agent asks:
172///
173/// * **mid-merge**: `MERGE_HEAD` exists. Ours is `HEAD`, theirs is
174///   `MERGE_HEAD`. This is the state right after `git merge` exits 1, and it
175///   survives `git add` — which is exactly why the index's unmerged list alone
176///   is not enough to find the subjects.
177/// * **just committed**: `HEAD` has two parents. Ours is `HEAD^1`, theirs is
178///   `HEAD^2`. An agent that committed and then wants to know what it did.
179///
180/// Neither shape present is not an error and must not be reported as one — it
181/// is the sentence "there is no merge here", which the caller prints.
182pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
183    if !rev_exists(dir, "HEAD") {
184        return Ok(None);
185    }
186    let (ours_rev, theirs_rev, moment) = if rev_exists(dir, "MERGE_HEAD") {
187        ("HEAD".to_string(), "MERGE_HEAD".to_string(), "in progress")
188    } else if rev_exists(dir, "HEAD^2") {
189        ("HEAD^1".to_string(), "HEAD^2".to_string(), "just committed")
190    } else {
191        return Ok(None);
192    };
193    let base_rev = git(dir, &["merge-base", &ours_rev, &theirs_rev])?
194        .trim()
195        .to_string();
196
197    // The subjects are every file this merge PRODUCED: whatever either side
198    // moved. Restricting it to files both sides moved was the obvious-looking
199    // choice and it was wrong — the merge's most dangerous breakage is the
200    // cross-file one, where one side renames a definition in `a.py` and the
201    // other adds a caller in `b.py`, and neither file is contested. A checker
202    // that only looks where git had to choose cannot see it.
203    //
204    // This is asked of git by NAME — a name-only diff of each side against the
205    // base — and never by reading the two whole trees and comparing them in
206    // process. On a large monorepo that whole-tree read is the check's dominant
207    // cost, and it buys nothing: the answer is exactly the set git already has
208    // as the merge's changed paths.
209    let mut subjects: BTreeSet<String> = BTreeSet::new();
210    for side in [&ours_rev, &theirs_rev] {
211        let list = git(dir, &["diff", "--name-only", "-z", &base_rev, side])?;
212        subjects.extend(
213            list.split('\0')
214                .filter(|p| !p.is_empty() && is_supported(p))
215                .map(str::to_string),
216        );
217    }
218    // …plus whatever git still calls unmerged, whether or not both sides moved
219    // it: git's own verdict about what is unresolved outranks ours.
220    if let Ok(list) = git(dir, &["diff", "--name-only", "--diff-filter=U", "-z"]) {
221        subjects.extend(
222            list.split('\0')
223                .filter(|p| !p.is_empty() && is_supported(p))
224                .map(str::to_string),
225        );
226    }
227    let subjects: Vec<String> = subjects.into_iter().collect();
228
229    // Read the three merge stages for the SUBJECTS ONLY — one `cat-file --batch`
230    // process per rev, not a `git show` per file over the whole tree. The
231    // dangling pass proves this is exact: a name is only ever "gone" from a file
232    // both a stage and the working tree disagree about, which is a subject; an
233    // untouched file's stage and its working-tree copy are identical, so it can
234    // neither create a dangling finding nor host the definition that resolves
235    // one from stage data. Suppression by an untouched file's *surviving*
236    // definition is the working tree's job, and `work` below stays repo-wide for
237    // exactly that.
238    let base = read_paths_at_rev(dir, &base_rev, &subjects)?;
239    let ours = read_paths_at_rev(dir, &ours_rev, &subjects)?;
240    let theirs = read_paths_at_rev(dir, &theirs_rev, &subjects)?;
241    let work = read_worktree(dir)?;
242    let scope = format!(
243        "working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
244         (base {}, merge {moment}) — {} file(s) either side changed",
245        &base_rev[..base_rev.len().min(8)],
246        subjects.len()
247    );
248    Ok(Some(MergeScope {
249        base,
250        ours,
251        theirs,
252        work,
253        subjects,
254        scope,
255    }))
256}
257
258/// Every tracked, supported file as it exists on disk right now.
259pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
260    let listing = git(dir, &["ls-files", "-z"])?;
261    let mut tree = Tree::new();
262    for rel in listing.split('\0').filter(|p| !p.is_empty()) {
263        if !is_supported(rel) {
264            continue;
265        }
266        if let Ok(content) = std::fs::read_to_string(dir.join(rel)) {
267            tree.insert(rel.to_string(), content);
268        }
269    }
270    Ok(tree)
271}
272
273/// The three merge stages of ONE path, straight out of the index
274/// (`:1:`/`:2:`/`:3:`) while the file is unmerged, falling back to the merge
275/// revisions once it has been staged.
276///
277/// `weave explain` needs the triple for a single file and must keep working
278/// after `git add` — the index stages disappear at that moment, and an
279/// explanation that stops being available the instant you stage is an
280/// explanation nobody can use twice.
281pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
282    let stage = |n: u8| -> Option<String> {
283        let out = Command::new("git")
284            .args(["show", &format!(":{n}:{path}")])
285            .current_dir(dir)
286            .output()
287            .ok()?;
288        out.status
289            .success()
290            .then(|| String::from_utf8_lossy(&out.stdout).to_string())
291    };
292    if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
293        return Ok((b, o, t));
294    }
295    let scope = merge_scope(dir)?.ok_or(
296        "no merge in progress and HEAD is not a merge commit — there is no three-way \
297                context to explain this file against",
298    )?;
299    let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
300    if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
301        return Err(format!("`{path}` is in neither side of this merge").into());
302    }
303    Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
304}
305
306/// The WHOLE tree at `rev`, restricted to files weave has a grammar for.
307/// Unsupported / binary / oversize files inherit git's guarantees wholesale
308/// and are not scanned.
309pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
310    let listing = git(dir, &["ls-tree", "-r", "--name-only", "-z", rev])?;
311    let mut tree = Tree::new();
312    for path in listing.split('\0').filter(|p| !p.is_empty()) {
313        if !is_supported(path) {
314            continue;
315        }
316        let out = Command::new("git")
317            .args(["show", &format!("{rev}:{path}")])
318            .current_dir(dir)
319            .output()?;
320        if !out.status.success() {
321            continue;
322        }
323        if let Ok(content) = String::from_utf8(out.stdout) {
324            tree.insert(path.to_string(), content);
325        }
326    }
327    Ok(tree)
328}