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::path::Path;
9use std::process::Command;
10
11use crate::repo_scope::{is_supported, Tree};
12
13type R<T> = Result<T, Box<dyn std::error::Error>>;
14
15fn git(dir: &Path, args: &[&str]) -> R<String> {
16    let out = Command::new("git").args(args).current_dir(dir).output()?;
17    if !out.status.success() {
18        return Err(format!(
19            "git {} failed: {}",
20            args.join(" "),
21            String::from_utf8_lossy(&out.stderr).trim()
22        )
23        .into());
24    }
25    Ok(String::from_utf8_lossy(&out.stdout).to_string())
26}
27
28pub(crate) fn rev_exists(dir: &Path, rev: &str) -> bool {
29    Command::new("git")
30        .args(["rev-parse", "--verify", "--quiet", rev])
31        .current_dir(dir)
32        .output()
33        .map(|o| o.status.success())
34        .unwrap_or(false)
35}
36
37/// Resolve the merge triple, defaulting to the merge in progress.
38///
39/// `ours` defaults to `HEAD`; `theirs` defaults to `MERGE_HEAD` (so a bare
40/// `weave check` mid-merge does the obvious thing); `base` defaults to the
41/// merge base of the two.
42pub(crate) fn resolve_revs(
43    dir: &Path,
44    base: Option<&str>,
45    ours: Option<&str>,
46    theirs: Option<&str>,
47) -> R<(String, String, String)> {
48    if !rev_exists(dir, "HEAD") {
49        return Err(
50            "not a git repository (or no commits yet) — use the directory mode instead".into(),
51        );
52    }
53    let ours = ours.unwrap_or("HEAD").to_string();
54    let theirs = match theirs {
55        Some(t) => t.to_string(),
56        None if rev_exists(dir, "MERGE_HEAD") => "MERGE_HEAD".to_string(),
57        None => {
58            return Err("no --theirs given and no merge in progress (MERGE_HEAD absent)".into())
59        }
60    };
61    let base = match base {
62        Some(b) => b.to_string(),
63        None => git(dir, &["merge-base", &ours, &theirs])?
64            .trim()
65            .to_string(),
66    };
67    Ok((base, ours, theirs))
68}
69
70pub fn trees(
71    dir: &Path,
72    base: Option<&str>,
73    ours: Option<&str>,
74    theirs: Option<&str>,
75) -> R<(Tree, Tree, Tree)> {
76    let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
77    Ok((
78        read_rev_tree(dir, &base)?,
79        read_rev_tree(dir, &ours)?,
80        read_rev_tree(dir, &theirs)?,
81    ))
82}
83
84/// Everything the working-tree check needs: the three merge stages, the bytes
85/// on disk, the files the merge actually had to decide, and a sentence naming
86/// what was compared.
87pub struct MergeScope {
88    pub base: Tree,
89    pub ours: Tree,
90    pub theirs: Tree,
91    /// The working tree, as it is right now.
92    pub work: Tree,
93    /// Files BOTH sides changed, plus anything git still has unmerged.
94    pub subjects: Vec<String>,
95    pub scope: String,
96}
97
98/// Find the merge this repository is in — or has just finished — and read it.
99///
100/// Two shapes, in order, because they are the two moments an agent asks:
101///
102/// * **mid-merge**: `MERGE_HEAD` exists. Ours is `HEAD`, theirs is
103///   `MERGE_HEAD`. This is the state right after `git merge` exits 1, and it
104///   survives `git add` — which is exactly why the index's unmerged list alone
105///   is not enough to find the subjects.
106/// * **just committed**: `HEAD` has two parents. Ours is `HEAD^1`, theirs is
107///   `HEAD^2`. An agent that committed and then wants to know what it did.
108///
109/// Neither shape present is not an error and must not be reported as one — it
110/// is the sentence "there is no merge here", which the caller prints.
111pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
112    if !rev_exists(dir, "HEAD") {
113        return Ok(None);
114    }
115    let (ours_rev, theirs_rev, moment) = if rev_exists(dir, "MERGE_HEAD") {
116        ("HEAD".to_string(), "MERGE_HEAD".to_string(), "in progress")
117    } else if rev_exists(dir, "HEAD^2") {
118        ("HEAD^1".to_string(), "HEAD^2".to_string(), "just committed")
119    } else {
120        return Ok(None);
121    };
122    let base_rev = git(dir, &["merge-base", &ours_rev, &theirs_rev])?
123        .trim()
124        .to_string();
125    let base = read_rev_tree(dir, &base_rev)?;
126    let ours = read_rev_tree(dir, &ours_rev)?;
127    let theirs = read_rev_tree(dir, &theirs_rev)?;
128    let work = read_worktree(dir)?;
129
130    // The subjects are every file this merge PRODUCED: whatever either side
131    // moved. Restricting it to files both sides moved was the obvious-looking
132    // choice and it was wrong — the merge's most dangerous breakage is the
133    // cross-file one, where one side renames a definition in `a.py` and the
134    // other adds a caller in `b.py`, and neither file is contested. A checker
135    // that only looks where git had to choose cannot see it.
136    let mut subjects: BTreeSet<String> = base
137        .keys()
138        .chain(ours.keys())
139        .chain(theirs.keys())
140        .filter(|p| ours.get(*p) != base.get(*p) || theirs.get(*p) != base.get(*p))
141        .cloned()
142        .collect();
143    // …plus whatever git still calls unmerged, whether or not both sides moved
144    // it: git's own verdict about what is unresolved outranks ours.
145    if let Ok(list) = git(dir, &["diff", "--name-only", "--diff-filter=U", "-z"]) {
146        subjects.extend(
147            list.split('\0')
148                .filter(|p| !p.is_empty() && is_supported(p))
149                .map(str::to_string),
150        );
151    }
152    let subjects: Vec<String> = subjects.into_iter().collect();
153    let scope = format!(
154        "working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
155         (base {}, merge {moment}) — {} file(s) either side changed",
156        &base_rev[..base_rev.len().min(8)],
157        subjects.len()
158    );
159    Ok(Some(MergeScope {
160        base,
161        ours,
162        theirs,
163        work,
164        subjects,
165        scope,
166    }))
167}
168
169/// Every tracked, supported file as it exists on disk right now.
170pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
171    let listing = git(dir, &["ls-files", "-z"])?;
172    let mut tree = Tree::new();
173    for rel in listing.split('\0').filter(|p| !p.is_empty()) {
174        if !is_supported(rel) {
175            continue;
176        }
177        if let Ok(content) = std::fs::read_to_string(dir.join(rel)) {
178            tree.insert(rel.to_string(), content);
179        }
180    }
181    Ok(tree)
182}
183
184/// The three merge stages of ONE path, straight out of the index
185/// (`:1:`/`:2:`/`:3:`) while the file is unmerged, falling back to the merge
186/// revisions once it has been staged.
187///
188/// `weave explain` needs the triple for a single file and must keep working
189/// after `git add` — the index stages disappear at that moment, and an
190/// explanation that stops being available the instant you stage is an
191/// explanation nobody can use twice.
192pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
193    let stage = |n: u8| -> Option<String> {
194        let out = Command::new("git")
195            .args(["show", &format!(":{n}:{path}")])
196            .current_dir(dir)
197            .output()
198            .ok()?;
199        out.status
200            .success()
201            .then(|| String::from_utf8_lossy(&out.stdout).to_string())
202    };
203    if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
204        return Ok((b, o, t));
205    }
206    let scope = merge_scope(dir)?.ok_or(
207        "no merge in progress and HEAD is not a merge commit — there is no three-way \
208                context to explain this file against",
209    )?;
210    let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
211    if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
212        return Err(format!("`{path}` is in neither side of this merge").into());
213    }
214    Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
215}
216
217/// The WHOLE tree at `rev`, restricted to files weave has a grammar for.
218/// Unsupported / binary / oversize files inherit git's guarantees wholesale
219/// and are not scanned.
220pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
221    let listing = git(dir, &["ls-tree", "-r", "--name-only", "-z", rev])?;
222    let mut tree = Tree::new();
223    for path in listing.split('\0').filter(|p| !p.is_empty()) {
224        if !is_supported(path) {
225            continue;
226        }
227        let out = Command::new("git")
228            .args(["show", &format!("{rev}:{path}")])
229            .current_dir(dir)
230            .output()?;
231        if !out.status.success() {
232            continue;
233        }
234        if let Ok(content) = String::from_utf8(out.stdout) {
235            tree.insert(path.to_string(), content);
236        }
237    }
238    Ok(tree)
239}