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//!
7//! Every `git` here runs through [`bounded`]: a deadline, both output pipes
8//! drained on their own threads, and lazy fetching off. A check that waits on
9//! git forever is a check that never says anything, which is worse than one
10//! that says "git did not answer".
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::io::{Read, Write};
14use std::path::Path;
15use std::process::{Command, Stdio};
16use std::sync::mpsc;
17use std::time::{Duration, Instant};
18
19use crate::repo_scope::{is_supported, Tree};
20
21type R<T> = Result<T, Box<dyn std::error::Error>>;
22
23/// How long any one `git` may take before the check gives up on it.
24const GIT_DEADLINE: Duration = Duration::from_secs(120);
25
26/// Run `git <args>` in `dir`, feeding it `input`, and wait at most `limit`.
27///
28/// Three things make this unable to hang:
29///
30/// * stdin is written from its own thread and stdout/stderr are drained on
31///   theirs, so no pipe can fill while the other waits — the two-pipe
32///   deadlock that once hung `cat-file --batch`;
33/// * `GIT_NO_LAZY_FETCH=1`: in a partial (blobless) clone, reading a blob that
34///   is not local makes git fetch it from the promisor remote, ONE network
35///   round trip per object. A merge with two thousand changed files then sits
36///   idle for hours on a `git fetch` grandchild. Missing objects come back as
37///   missing instead, and [`read_paths_at_rev`] fetches them in one batch;
38/// * the deadline: past it the child is killed and the answer is an error
39///   that names the command, never a wait.
40fn bounded(dir: &Path, args: &[&str], input: Option<Vec<u8>>, limit: Duration) -> R<Vec<u8>> {
41    let mut cmd = Command::new("git");
42    cmd.args(args)
43        .current_dir(dir)
44        .env("GIT_NO_LAZY_FETCH", "1")
45        .env("GIT_TERMINAL_PROMPT", "0");
46    let (status, stdout, stderr) =
47        run_bounded(cmd, &format!("git {}", args.join(" ")), input, limit)?;
48    if !status.success() {
49        return Err(format!(
50            "git {} failed: {}",
51            args.join(" "),
52            String::from_utf8_lossy(&stderr).trim()
53        )
54        .into());
55    }
56    Ok(stdout)
57}
58
59/// Run `cmd`, feeding it `input`, and wait at most `limit` for it — any
60/// program, not only git (`weave land` runs its resolver through this). The
61/// exit status is the caller's to judge; only a spawn failure or the deadline
62/// is an error here. `what` names the command in that error.
63pub(crate) fn run_bounded(
64    mut cmd: Command,
65    what: &str,
66    input: Option<Vec<u8>>,
67    limit: Duration,
68) -> R<(std::process::ExitStatus, Vec<u8>, Vec<u8>)> {
69    let mut child = cmd
70        .stdin(if input.is_some() {
71            Stdio::piped()
72        } else {
73            Stdio::null()
74        })
75        .stdout(Stdio::piped())
76        .stderr(Stdio::piped())
77        .spawn()
78        .map_err(|e| format!("could not run {what}: {e}"))?;
79    if let (Some(bytes), Some(mut stdin)) = (input, child.stdin.take()) {
80        // A write error is the child having exited early; its status says why.
81        std::thread::spawn(move || {
82            let _ = stdin.write_all(&bytes);
83        });
84    }
85    let drain = |pipe: Option<Box<dyn Read + Send>>| {
86        let (tx, rx) = mpsc::channel();
87        std::thread::spawn(move || {
88            let mut buf = Vec::new();
89            if let Some(mut p) = pipe {
90                let _ = p.read_to_end(&mut buf);
91            }
92            let _ = tx.send(buf);
93        });
94        rx
95    };
96    let out = drain(
97        child
98            .stdout
99            .take()
100            .map(|p| Box::new(p) as Box<dyn Read + Send>),
101    );
102    let err = drain(
103        child
104            .stderr
105            .take()
106            .map(|p| Box::new(p) as Box<dyn Read + Send>),
107    );
108
109    let deadline = Instant::now() + limit;
110    let too_slow = || -> Box<dyn std::error::Error> {
111        format!(
112            "{what} did not finish within {}s — weave stopped waiting rather than hang",
113            limit.as_secs()
114        )
115        .into()
116    };
117    let status = loop {
118        if let Some(status) = child.try_wait()? {
119            break status;
120        }
121        if Instant::now() >= deadline {
122            let _ = child.kill();
123            let _ = child.wait();
124            return Err(too_slow());
125        }
126        std::thread::sleep(Duration::from_millis(5));
127    };
128    // A grandchild can outlive the child and hold a pipe open; the deadline
129    // covers that wait too.
130    let remaining = || deadline.saturating_duration_since(Instant::now());
131    let stdout = out.recv_timeout(remaining()).map_err(|_| too_slow())?;
132    let stderr = err.recv_timeout(remaining()).map_err(|_| too_slow())?;
133    Ok((status, stdout, stderr))
134}
135
136pub(crate) fn git(dir: &Path, args: &[&str]) -> R<String> {
137    git_input(dir, args, None)
138}
139
140/// [`git`], feeding `input` on stdin.
141pub(crate) fn git_input(dir: &Path, args: &[&str], input: Option<Vec<u8>>) -> R<String> {
142    let out = bounded(dir, args, input, GIT_DEADLINE)?;
143    Ok(String::from_utf8_lossy(&out).to_string())
144}
145
146/// One rev's copies of a set of paths.
147#[derive(Debug, Default)]
148pub struct Stage {
149    /// Path -> UTF-8 content, for every requested regular file the rev has.
150    pub tree: Tree,
151    /// Paths the rev has as a regular file whose bytes could not be read, and
152    /// why. Absent is not unreadable: a path the rev does not have is simply
153    /// not in `tree`, and that is a fact the checks reason about. An unreadable
154    /// one is a fact they cannot reason about, and must not be mistaken for
155    /// absence.
156    pub unreadable: BTreeMap<String, String>,
157    /// Paths the rev has as something other than a regular file — a symlink or
158    /// a submodule. Their bytes are not source, and git's guarantees stand.
159    pub irregular: BTreeSet<String>,
160}
161
162/// The blobs at `<rev>:<path>` for a *bounded* set of paths, in one
163/// `ls-tree` and one `cat-file --batch`.
164///
165/// The tree listing comes first because it is the only way to tell "this rev
166/// has no such file" from "this rev has the file but its bytes are not here"
167/// — in a partial clone, trees are local and blobs often are not. Asking
168/// `cat-file` for `<rev>:<path>` answers `missing` for both, which read a
169/// non-local base blob as "base has no such file" and quietly corrupted every
170/// line count built on it.
171///
172/// Blobs missing from a partial clone are then fetched in ONE `git fetch`
173/// (the command git itself runs for a lazy fetch, batched), instead of the one
174/// fetch per object that `cat-file` would otherwise make; whatever still is
175/// not there is reported per path in [`Stage::unreadable`].
176fn read_paths_at_rev(dir: &Path, rev: &str, paths: &[String]) -> R<Stage> {
177    let mut stage = Stage::default();
178    for (path, entry) in entries_at_rev(dir, rev, paths)? {
179        match entry.body {
180            Body::Text(text) => {
181                stage.tree.insert(path, text);
182            }
183            // Not UTF-8: a binary file, which no check here reads anyway.
184            Body::Binary => {}
185            Body::Irregular => {
186                stage.irregular.insert(path);
187            }
188            Body::Unreadable(why) => {
189                stage.unreadable.insert(path, why);
190            }
191        }
192    }
193    Ok(stage)
194}
195
196/// What one rev holds at one path: the index entry (`mode`, `oid`) and what
197/// its bytes are.
198#[derive(Debug, Clone)]
199pub(crate) struct Entry {
200    pub mode: String,
201    pub oid: String,
202    pub body: Body,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub(crate) enum Body {
207    /// A regular file whose bytes are UTF-8.
208    Text(String),
209    /// A regular file whose bytes are not UTF-8.
210    Binary,
211    /// A symlink or a submodule.
212    Irregular,
213    /// A regular file whose blob could not be read, and why.
214    Unreadable(String),
215}
216
217/// The entries at `<rev>:<path>` for a bounded set of paths; a path the rev
218/// does not have is simply absent from the map. The bytes are read the way
219/// [`read_paths_at_rev`] documents.
220pub(crate) fn entries_at_rev(
221    dir: &Path,
222    rev: &str,
223    paths: &[String],
224) -> R<BTreeMap<String, Entry>> {
225    let mut out = BTreeMap::new();
226    if paths.is_empty() {
227        return Ok(out);
228    }
229    let wanted: BTreeSet<&str> = paths.iter().map(String::as_str).collect();
230    // `<mode> SP <type> SP <oid> TAB <path> NUL`
231    let listing = bounded(
232        dir,
233        &["ls-tree", "-r", "-z", "--full-tree", rev],
234        None,
235        GIT_DEADLINE,
236    )?;
237    let mut blobs: Vec<(String, String, String)> = Vec::new(); // (path, mode, oid)
238    for entry in listing.split(|&b| b == 0).filter(|e| !e.is_empty()) {
239        let entry = String::from_utf8_lossy(entry);
240        let Some((meta, path)) = entry.split_once('\t') else {
241            continue;
242        };
243        if !wanted.contains(path) {
244            continue;
245        }
246        let mut meta = meta.split(' ');
247        let (mode, oid) = (meta.next().unwrap_or(""), meta.nth(1).unwrap_or(""));
248        if mode == "100644" || mode == "100755" {
249            blobs.push((path.to_string(), mode.to_string(), oid.to_string()));
250        } else {
251            out.insert(
252                path.to_string(),
253                Entry {
254                    mode: mode.to_string(),
255                    oid: oid.to_string(),
256                    body: Body::Irregular,
257                },
258            );
259        }
260    }
261
262    let oids: Vec<&str> = blobs.iter().map(|(_, _, oid)| oid.as_str()).collect();
263    let mut found = cat_blobs(dir, &oids)?;
264    let missing: Vec<&str> = oids
265        .iter()
266        .copied()
267        .filter(|oid| !found.contains_key(*oid))
268        .collect();
269    let mut why_missing = "the blob is not in the local object store".to_string();
270    if !missing.is_empty() {
271        match prefetch(dir, &missing) {
272            Ok(()) => found.extend(cat_blobs(dir, &missing)?),
273            Err(e) => why_missing = e.to_string(),
274        }
275    }
276    for (path, mode, oid) in blobs {
277        let body = match found.get(&oid) {
278            Some(Some(text)) => Body::Text(text.clone()),
279            Some(None) => Body::Binary,
280            None => Body::Unreadable(format!("{rev}: {why_missing}")),
281        };
282        out.insert(path, Entry { mode, oid, body });
283    }
284    Ok(out)
285}
286
287/// `oid -> Some(text)` for every blob the object store has (`None` when it is
288/// not UTF-8); an oid it does not have is left out.
289fn cat_blobs(dir: &Path, oids: &[&str]) -> R<BTreeMap<String, Option<String>>> {
290    let mut out = BTreeMap::new();
291    if oids.is_empty() {
292        return Ok(out);
293    }
294    let mut request = String::new();
295    for oid in oids {
296        request.push_str(oid);
297        request.push('\n');
298    }
299    let bytes = bounded(
300        dir,
301        &["cat-file", "--batch"],
302        Some(request.into_bytes()),
303        GIT_DEADLINE,
304    )?;
305    // Reply framing: `<oid> <type> <size>\n<size bytes>\n`, or `<oid> missing\n`.
306    // Requests are bare oids, so every header is space-separated and none can
307    // carry a newline.
308    let mut pos = 0usize;
309    while pos < bytes.len() {
310        let Some(nl) = bytes[pos..].iter().position(|&b| b == b'\n') else {
311            break;
312        };
313        let header = String::from_utf8_lossy(&bytes[pos..pos + nl]).to_string();
314        pos += nl + 1;
315        let fields: Vec<&str> = header.split(' ').collect();
316        let [oid, _kind, size] = fields[..] else {
317            continue; // `<oid> missing`: no payload follows
318        };
319        let size: usize = size.parse()?;
320        if pos + size > bytes.len() {
321            return Err("git cat-file --batch reply was truncated".into());
322        }
323        let text = std::str::from_utf8(&bytes[pos..pos + size])
324            .ok()
325            .map(str::to_string);
326        out.insert(oid.to_string(), text);
327        pos += size + 1; // trailing newline after the payload
328    }
329    Ok(out)
330}
331
332/// The remote a partial clone lazily fetches from: `remote.<name>.promisor`
333/// (what `git clone --filter` writes), or the older `extensions.partialClone`.
334fn promisor_remote(dir: &Path) -> Option<String> {
335    let flagged = git(dir, &["config", "--get-regexp", r"^remote\..*\.promisor$"]).ok();
336    flagged
337        .iter()
338        .flat_map(|s| s.lines())
339        .filter_map(|l| l.split_once(' '))
340        .filter(|(_, v)| v.trim() == "true")
341        .find_map(|(k, _)| {
342            k.strip_prefix("remote.")?
343                .strip_suffix(".promisor")
344                .map(str::to_string)
345        })
346        .or_else(|| {
347            git(dir, &["config", "--get", "extensions.partialClone"])
348                .ok()
349                .map(|s| s.trim().to_string())
350                .filter(|s| !s.is_empty())
351        })
352}
353
354/// Fetch `oids` from the partial clone's promisor remote in ONE request.
355///
356/// This is the fetch git runs for a single lazily-read object, given every
357/// object at once. It is an error — never a wait past the deadline — when the
358/// repository is not a partial clone or the remote does not answer.
359fn prefetch(dir: &Path, oids: &[&str]) -> R<()> {
360    let remote = promisor_remote(dir)
361        .ok_or("the blob is not in the local object store and this is not a partial clone")?;
362    let mut request = String::new();
363    for oid in oids {
364        request.push_str(oid);
365        request.push('\n');
366    }
367    bounded(
368        dir,
369        &[
370            "-c",
371            "fetch.negotiationAlgorithm=noop",
372            "fetch",
373            &remote,
374            "--no-tags",
375            "--no-write-fetch-head",
376            "--recurse-submodules=no",
377            "--filter=blob:none",
378            "--stdin",
379        ],
380        Some(request.into_bytes()),
381        GIT_DEADLINE,
382    )
383    .map_err(|e| {
384        format!(
385            "{} blob(s) are not local and could not be fetched: {e}",
386            oids.len()
387        )
388    })?;
389    Ok(())
390}
391
392pub(crate) fn rev_exists(dir: &Path, rev: &str) -> bool {
393    git(dir, &["rev-parse", "--verify", "--quiet", rev]).is_ok()
394}
395
396/// Git's canonical empty-tree object id — the tree with no entries.
397const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
398
399/// Parent of a replayed commit (`REBASE_HEAD` / `CHERRY_PICK_HEAD`), or the
400/// empty tree when the replayed commit is a root commit.
401fn replay_base(dir: &Path, theirs_rev: &str) -> R<String> {
402    let parent = format!("{theirs_rev}^");
403    if rev_exists(dir, &parent) {
404        Ok(git(dir, &["rev-parse", &parent])?.trim().to_string())
405    } else {
406        Ok(EMPTY_TREE.to_string())
407    }
408}
409
410/// Resolve the merge triple, defaulting to the operation in progress.
411///
412/// `ours` defaults to `HEAD`; `theirs` defaults to the commit of whatever
413/// three-way operation is underway. A plain merge records it as `MERGE_HEAD`,
414/// but a rebase, cherry-pick or revert conflict has no `MERGE_HEAD` — git
415/// records the commit being applied as `REBASE_HEAD` / `CHERRY_PICK_HEAD` /
416/// `REVERT_HEAD` instead, so a bare `weave check` can still verify a resolution
417/// against it (issue #157). `base` defaults to the merge base of the two for a
418/// merge or revert, and to the replayed commit's parent for a rebase or
419/// cherry-pick: a replay applies one commit as a patch, and the merge base
420/// would smear the whole branch divergence into the context.
421pub(crate) fn resolve_revs(
422    dir: &Path,
423    base: Option<&str>,
424    ours: Option<&str>,
425    theirs: Option<&str>,
426) -> R<(String, String, String)> {
427    if !rev_exists(dir, "HEAD") {
428        return Err(
429            "not a git repository (or no commits yet) — use the directory mode instead".into(),
430        );
431    }
432    let ours = ours.unwrap_or("HEAD").to_string();
433    let theirs = match theirs {
434        Some(t) => t.to_string(),
435        None => [
436            "MERGE_HEAD",
437            "REBASE_HEAD",
438            "CHERRY_PICK_HEAD",
439            "REVERT_HEAD",
440        ]
441        .into_iter()
442        .find(|r| rev_exists(dir, r))
443        .map(str::to_string)
444        .ok_or(
445            "no --theirs given and no merge, rebase, cherry-pick or revert in progress \
446                 (no MERGE_HEAD / REBASE_HEAD / CHERRY_PICK_HEAD / REVERT_HEAD)",
447        )?,
448    };
449    let base = match base {
450        Some(b) => b.to_string(),
451        None if theirs == "REBASE_HEAD" || theirs == "CHERRY_PICK_HEAD" => {
452            replay_base(dir, &theirs)?
453        }
454        None => git(dir, &["merge-base", &ours, &theirs])?
455            .trim()
456            .to_string(),
457    };
458    Ok((base, ours, theirs))
459}
460
461pub fn trees(
462    dir: &Path,
463    base: Option<&str>,
464    ours: Option<&str>,
465    theirs: Option<&str>,
466) -> R<(Tree, Tree, Tree)> {
467    let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
468    Ok((
469        read_rev_tree(dir, &base)?,
470        read_rev_tree(dir, &ours)?,
471        read_rev_tree(dir, &theirs)?,
472    ))
473}
474
475/// Everything the working-tree check needs: the three merge stages, the bytes
476/// on disk, the files the merge actually had to decide, and a sentence naming
477/// what was compared.
478pub struct MergeScope {
479    pub base: Tree,
480    pub ours: Tree,
481    pub theirs: Tree,
482    /// The working tree, as it is right now.
483    pub work: Tree,
484    /// Files BOTH sides changed, plus anything git still has unmerged.
485    pub subjects: Vec<String>,
486    /// Subjects a merge stage has but whose bytes could not be read, and why.
487    /// They are not in `subjects`: a file checked against a stage it could not
488    /// see would be checked against a guess. Each still gets a verdict.
489    pub unreadable: BTreeMap<String, String>,
490    /// Subjects a merge stage has as a symlink or a submodule: not source, and
491    /// not in `subjects`.
492    pub irregular: BTreeSet<String>,
493    pub scope: String,
494}
495
496/// Find the merge/rebase this repository is in — or has just finished — and
497/// read it.
498///
499/// Three shapes, in order, because they are the moments an agent asks:
500///
501/// * **mid-merge**: `MERGE_HEAD` exists. Ours is `HEAD`, theirs is
502///   `MERGE_HEAD`. This is the state right after `git merge` exits 1, and it
503///   survives `git add` — which is exactly why the index's unmerged list alone
504///   is not enough to find the subjects.
505/// * **mid-rebase / cherry-pick / revert**: there is no `MERGE_HEAD`, but git
506///   records the commit being applied as `REBASE_HEAD` / `CHERRY_PICK_HEAD` /
507///   `REVERT_HEAD`. Ours is `HEAD` (the side built so far), theirs is that
508///   commit. Without this, `weave check` fell through to "nothing was checked"
509///   during every rebase, while still suggesting itself (issue #157). For a
510///   rebase or cherry-pick the base is that commit's parent (`REBASE_HEAD^`),
511///   not `merge-base(ours, theirs)`: a replay applies one commit as a patch,
512///   and the merge base would smear the whole branch's divergence into it.
513/// * **just committed**: `HEAD` has two parents. Ours is `HEAD^1`, theirs is
514///   `HEAD^2`. An agent that committed and then wants to know what it did.
515///
516/// No shape present is not an error and must not be reported as one — it is
517/// the sentence "there is no merge here", which the caller prints.
518pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
519    if !rev_exists(dir, "HEAD") {
520        return Ok(None);
521    }
522    let (ours_rev, theirs_rev, moment, use_replay_base) = if rev_exists(dir, "MERGE_HEAD") {
523        (
524            "HEAD".to_string(),
525            "MERGE_HEAD".to_string(),
526            "merge in progress",
527            false,
528        )
529    } else if let Some(op_head) = ["REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]
530        .into_iter()
531        .find(|r| rev_exists(dir, r))
532    {
533        // A rebase, cherry-pick or revert conflict has no MERGE_HEAD; git
534        // records the commit being applied as *_HEAD, and the unmerged index
535        // stages are the real three-way. HEAD is the side built so far, so
536        // `weave check` can verify a resolution here too (issue #157). A
537        // replayed commit's base is its own parent; a revert keeps the merge
538        // base, as before.
539        let moment = match op_head {
540            "REBASE_HEAD" => "rebase in progress",
541            "CHERRY_PICK_HEAD" => "cherry-pick in progress",
542            _ => "revert in progress",
543        };
544        (
545            "HEAD".to_string(),
546            op_head.to_string(),
547            moment,
548            op_head != "REVERT_HEAD",
549        )
550    } else if rev_exists(dir, "HEAD^2") {
551        (
552            "HEAD^1".to_string(),
553            "HEAD^2".to_string(),
554            "merge just committed",
555            false,
556        )
557    } else {
558        return Ok(None);
559    };
560    let base_rev = if use_replay_base {
561        replay_base(dir, &theirs_rev)?
562    } else {
563        git(dir, &["merge-base", &ours_rev, &theirs_rev])?
564            .trim()
565            .to_string()
566    };
567
568    // The subjects are every file this merge PRODUCED: whatever either side
569    // moved. Restricting it to files both sides moved was the obvious-looking
570    // choice and it was wrong — the merge's most dangerous breakage is the
571    // cross-file one, where one side renames a definition in `a.py` and the
572    // other adds a caller in `b.py`, and neither file is contested. A checker
573    // that only looks where git had to choose cannot see it.
574    //
575    // This is asked of git by NAME — a name-only diff of each side against the
576    // base — and never by reading the two whole trees and comparing them in
577    // process. On a large monorepo that whole-tree read is the check's dominant
578    // cost, and it buys nothing: the answer is exactly the set git already has
579    // as the merge's changed paths.
580    //
581    // `--no-renames`: rename detection reads blobs, which in a partial clone
582    // means fetching them, and a rename's OLD path is a path the merge
583    // produced too.
584    let mut subjects: BTreeSet<String> = BTreeSet::new();
585    for side in [&ours_rev, &theirs_rev] {
586        let list = git(
587            dir,
588            &["diff", "--name-only", "--no-renames", "-z", &base_rev, side],
589        )?;
590        subjects.extend(
591            list.split('\0')
592                .filter(|p| !p.is_empty() && is_supported(p))
593                .map(str::to_string),
594        );
595    }
596    // …plus whatever git still calls unmerged, whether or not both sides moved
597    // it: git's own verdict about what is unresolved outranks ours.
598    if let Ok(list) = git(
599        dir,
600        &[
601            "diff",
602            "--name-only",
603            "--no-renames",
604            "--diff-filter=U",
605            "-z",
606        ],
607    ) {
608        subjects.extend(
609            list.split('\0')
610                .filter(|p| !p.is_empty() && is_supported(p))
611                .map(str::to_string),
612        );
613    }
614    let subjects: Vec<String> = subjects.into_iter().collect();
615
616    // Read the three merge stages for the SUBJECTS ONLY — one `ls-tree` and one
617    // `cat-file --batch` per rev, not a `git show` per file over the whole tree. The
618    // dangling pass proves this is exact: a name is only ever "gone" from a file
619    // both a stage and the working tree disagree about, which is a subject; an
620    // untouched file's stage and its working-tree copy are identical, so it can
621    // neither create a dangling finding nor host the definition that resolves
622    // one from stage data. Suppression by an untouched file's *surviving*
623    // definition is the working tree's job, and `work` below stays repo-wide for
624    // exactly that.
625    let stages = [
626        read_paths_at_rev(dir, &base_rev, &subjects)?,
627        read_paths_at_rev(dir, &ours_rev, &subjects)?,
628        read_paths_at_rev(dir, &theirs_rev, &subjects)?,
629    ];
630    let work = read_worktree(dir)?;
631    let scope = format!(
632        "working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
633         (base {}, {moment}) — {} file(s) either side changed",
634        &base_rev[..base_rev.len().min(8)],
635        subjects.len()
636    );
637    // A symlink or submodule is not source: git's guarantees stand for it, as
638    // for any file weave has no grammar for.
639    let mut unreadable: BTreeMap<String, String> = BTreeMap::new();
640    let mut irregular: BTreeSet<String> = BTreeSet::new();
641    for stage in &stages {
642        for (path, why) in &stage.unreadable {
643            unreadable
644                .entry(path.clone())
645                .or_insert_with(|| why.clone());
646        }
647        irregular.extend(stage.irregular.iter().cloned());
648    }
649    let subjects = subjects
650        .into_iter()
651        .filter(|p| !irregular.contains(p) && !unreadable.contains_key(p))
652        .collect();
653    irregular.retain(|p| !unreadable.contains_key(p));
654    let [base, ours, theirs] = stages.map(|s| s.tree);
655    Ok(Some(MergeScope {
656        base,
657        ours,
658        theirs,
659        work,
660        subjects,
661        unreadable,
662        irregular,
663        scope,
664    }))
665}
666
667/// Every tracked, supported, regular file as it exists on disk right now.
668///
669/// Regular only: reading through a tracked symlink reads whatever it points
670/// at, and a link to a FIFO or a device never returns.
671pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
672    let listing = git(dir, &["ls-files", "-z"])?;
673    let mut tree = Tree::new();
674    for rel in listing.split('\0').filter(|p| !p.is_empty()) {
675        if !is_supported(rel) {
676            continue;
677        }
678        let path = dir.join(rel);
679        if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_file()) {
680            continue;
681        }
682        if let Ok(content) = std::fs::read_to_string(path) {
683            tree.insert(rel.to_string(), content);
684        }
685    }
686    Ok(tree)
687}
688
689/// The three merge stages of ONE path, straight out of the index
690/// (`:1:`/`:2:`/`:3:`) while the file is unmerged, falling back to the merge
691/// revisions once it has been staged.
692///
693/// `weave explain` needs the triple for a single file and must keep working
694/// after `git add` — the index stages disappear at that moment, and an
695/// explanation that stops being available the instant you stage is an
696/// explanation nobody can use twice.
697pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
698    let stage = |n: u8| git(dir, &["show", &format!(":{n}:{path}")]).ok();
699    if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
700        return Ok((b, o, t));
701    }
702    let scope = merge_scope(dir)?.ok_or(
703        "no merge, rebase, cherry-pick or revert in progress and HEAD is not a merge \
704                commit — there is no three-way context to explain this file against",
705    )?;
706    let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
707    if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
708        return Err(format!("`{path}` is in neither side of this merge").into());
709    }
710    Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
711}
712
713/// The WHOLE tree at `rev`, restricted to files weave has a grammar for.
714/// Unsupported / binary / oversize files inherit git's guarantees wholesale
715/// and are not scanned.
716pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
717    let listing = git(
718        dir,
719        &["ls-tree", "-r", "--name-only", "-z", "--full-tree", rev],
720    )?;
721    let paths: Vec<String> = listing
722        .split('\0')
723        .filter(|p| !p.is_empty() && is_supported(p))
724        .map(str::to_string)
725        .collect();
726    // An unreadable blob is skipped here, as an unreadable `git show` always
727    // was: this pass reports cross-file findings, not per-file verdicts.
728    Ok(read_paths_at_rev(dir, rev, &paths)?.tree)
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use std::fs;
735    use std::path::PathBuf;
736    use std::process::Command;
737    use std::sync::atomic::{AtomicU32, Ordering};
738
739    static FIXTURE_COUNTER: AtomicU32 = AtomicU32::new(0);
740
741    fn git_ok(dir: &Path, args: &[&str]) -> String {
742        let out = Command::new("git")
743            .args(args)
744            .current_dir(dir)
745            .output()
746            .expect("run git");
747        assert!(
748            out.status.success(),
749            "git {} failed: {}",
750            args.join(" "),
751            String::from_utf8_lossy(&out.stderr).trim()
752        );
753        String::from_utf8_lossy(&out.stdout).to_string()
754    }
755
756    fn git_fails(dir: &Path, args: &[&str]) {
757        let status = Command::new("git")
758            .args(args)
759            .current_dir(dir)
760            .status()
761            .expect("run git");
762        assert!(
763            !status.success(),
764            "git {} unexpectedly succeeded",
765            args.join(" ")
766        );
767    }
768
769    fn git_fixture(name: &str) -> PathBuf {
770        let n = FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed);
771        let root = std::env::temp_dir().join(format!(
772            "weave-gitscan-test-{}-{}-{}",
773            std::process::id(),
774            name,
775            n
776        ));
777        fs::create_dir_all(&root).expect("create fixture dir");
778        git_ok(&root, &["init", "-q"]);
779        git_ok(&root, &["checkout", "-b", "main"]);
780        root
781    }
782
783    /// The batch read must return even when git's replies are far larger than
784    /// one pipe buffer. Writing every request before reading any reply
785    /// deadlocked here: git blocked on a full stdout, stopped reading stdin,
786    /// and the request write never returned. It runs on a watchdog thread so a
787    /// regression fails the test instead of hanging the suite.
788    #[test]
789    fn batch_read_of_large_blobs_does_not_deadlock() {
790        // Both pipes have to overflow for the deadlock: enough requests that
791        // they outgrow the stdin buffer, and enough reply bytes to fill stdout
792        // before git has read them all.
793        let root = git_fixture("cat-file-large");
794        let deep = "a_directory_name_long_enough_to_fatten_every_request/and_one_more_level";
795        fs::create_dir_all(root.join(deep)).expect("mkdir");
796        let mut paths = Vec::new();
797        for i in 0..3000 {
798            let name = format!("{deep}/module_number_{i:05}.py");
799            fs::write(root.join(&name), format!("value_{i} = {i}\n").repeat(8)).expect("write");
800            paths.push(name);
801        }
802        paths.push("absent.py".to_string());
803        git_ok(&root, &["add", "."]);
804        git_ok(&root, &["commit", "-m", "big"]);
805
806        let (tx, rx) = std::sync::mpsc::channel();
807        let dir = root.clone();
808        let asked = paths.clone();
809        std::thread::spawn(move || {
810            let _ = tx.send(
811                read_paths_at_rev(&dir, "HEAD", &asked)
812                    .map(|s| s.tree)
813                    .map_err(|e| e.to_string()),
814            );
815        });
816        let tree = rx
817            .recv_timeout(std::time::Duration::from_secs(60))
818            .expect("read_paths_at_rev hung on a large batch")
819            .expect("read_paths_at_rev failed");
820        assert_eq!(
821            tree.len(),
822            3000,
823            "every present path read, the absent one skipped"
824        );
825        assert_eq!(
826            tree[&format!("{deep}/module_number_02999.py")],
827            "value_2999 = 2999\n".repeat(8)
828        );
829    }
830
831    /// Rebasing a diverged branch onto main leaves REBASE_HEAD at the replayed
832    /// commit; merge_scope must recognize that shape so `weave check` works.
833    #[test]
834    fn merge_scope_during_rebase_conflict() {
835        let root = git_fixture("rebase-conflict");
836        let base_content = "def a():\n    return 1\n\ndef keep():\n    return 'stable'\n";
837
838        fs::write(root.join("m.py"), base_content).expect("write base");
839        git_ok(&root, &["add", "m.py"]);
840        git_ok(&root, &["commit", "-m", "initial"]);
841
842        git_ok(&root, &["checkout", "-b", "feature"]);
843        let feature_content = base_content.replace("return 1", "return 2");
844        fs::write(root.join("m.py"), &feature_content).expect("write feature");
845        git_ok(&root, &["add", "m.py"]);
846        git_ok(&root, &["commit", "-m", "feature"]);
847        let replayed_commit = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
848
849        git_ok(&root, &["checkout", "main"]);
850        let main_content = base_content.replace("return 1", "return 3");
851        fs::write(root.join("m.py"), &main_content).expect("write main");
852        git_ok(&root, &["add", "m.py"]);
853        git_ok(&root, &["commit", "-m", "main"]);
854
855        git_ok(&root, &["checkout", "feature"]);
856        git_fails(&root, &["rebase", "main"]);
857
858        let scope = merge_scope(&root)
859            .expect("merge_scope should not error")
860            .expect("merge_scope should recognize mid-rebase state");
861
862        assert!(
863            scope.scope.contains("rebase in progress"),
864            "scope should name the rebase moment: {}",
865            scope.scope
866        );
867
868        let rebase_head = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
869            .trim()
870            .to_string();
871        assert_eq!(
872            rebase_head, replayed_commit,
873            "REBASE_HEAD should be the commit being replayed"
874        );
875
876        let theirs_rev = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
877            .trim()
878            .to_string();
879        assert_eq!(
880            scope.theirs.get("m.py").map(String::as_str),
881            Some(feature_content.as_str()),
882            "theirs stage should come from REBASE_HEAD"
883        );
884        assert!(
885            scope.subjects.iter().any(|p| p == "m.py"),
886            "subjects should include the conflicted file: {:?}",
887            scope.subjects
888        );
889
890        // Sanity: ours is HEAD (main's tip), not the replayed commit.
891        let head_rev = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
892        assert_ne!(head_rev, replayed_commit);
893        assert_eq!(
894            scope.ours.get("m.py").map(String::as_str),
895            Some(main_content.as_str()),
896            "ours stage should come from HEAD"
897        );
898        assert_eq!(theirs_rev, replayed_commit);
899    }
900
901    /// A blobless clone whose blobs are not local: the source, and a bare
902    /// partial clone of it that has every tree and no blob.
903    fn partial_clone(name: &str, files: &[(&str, &str)]) -> (PathBuf, PathBuf) {
904        let src = git_fixture(name);
905        git_ok(&src, &["config", "uploadpack.allowFilter", "true"]);
906        git_ok(&src, &["config", "uploadpack.allowAnySHA1InWant", "true"]);
907        for (path, text) in files {
908            if let Some(dir) = Path::new(path).parent() {
909                fs::create_dir_all(src.join(dir)).expect("mkdir");
910            }
911            fs::write(src.join(path), text).expect("write");
912        }
913        git_ok(&src, &["add", "."]);
914        git_ok(
915            &src,
916            &[
917                "-c",
918                "user.name=t",
919                "-c",
920                "user.email=t@t",
921                "commit",
922                "-qm",
923                "c",
924            ],
925        );
926        let clone = src.with_extension("partial.git");
927        let url = format!("file://{}", src.display());
928        let parent = clone.parent().expect("parent");
929        git_ok(
930            parent,
931            &[
932                "clone",
933                "-q",
934                "--bare",
935                "--filter=blob:none",
936                &url,
937                clone.to_str().expect("utf8"),
938            ],
939        );
940        (src, clone)
941    }
942
943    /// Runs `f` on a watchdog thread, so a hang fails the test instead of the
944    /// suite.
945    fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
946        let (tx, rx) = std::sync::mpsc::channel();
947        std::thread::spawn(move || {
948            let _ = tx.send(f());
949        });
950        rx.recv_timeout(std::time::Duration::from_secs(secs))
951            .expect("hung past its deadline")
952    }
953
954    /// In a partial clone, every blob the stage read needs arrives in ONE
955    /// fetch — not one `git fetch` per object, which is what `cat-file --batch`
956    /// does on its own, and which left `weave check` idle for hours on a merge
957    /// of a few thousand files.
958    #[test]
959    fn a_partial_clone_fetches_missing_stage_blobs_in_one_batch() {
960        let files: Vec<(String, String)> = (0..40)
961            .map(|i| {
962                (
963                    format!("pkg/m{i}.py"),
964                    format!("def f{i}():\n    return {i}\n"),
965                )
966            })
967            .collect();
968        let refs: Vec<(&str, &str)> = files
969            .iter()
970            .map(|(p, t)| (p.as_str(), t.as_str()))
971            .collect();
972        let (_src, clone) = partial_clone("partial-fetch", &refs);
973        let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect();
974        let packs = |dir: &Path| {
975            fs::read_dir(dir.join("objects/pack"))
976                .expect("pack dir")
977                .filter(|e| {
978                    e.as_ref()
979                        .is_ok_and(|e| e.path().extension().is_some_and(|x| x == "pack"))
980                })
981                .count()
982        };
983        assert_eq!(packs(&clone), 1, "the clone's own pack: trees, no blobs");
984        let dir = clone.clone();
985        let stage = within(60, move || {
986            read_paths_at_rev(&dir, "HEAD", &paths).map_err(|e| e.to_string())
987        })
988        .expect("read");
989        assert!(stage.unreadable.is_empty(), "{:?}", stage.unreadable);
990        assert_eq!(stage.tree.len(), 40);
991        assert_eq!(stage.tree["pkg/m7.py"], "def f7():\n    return 7\n");
992        // One fetch writes one pack. A fetch per object would write forty.
993        assert_eq!(packs(&clone), 2, "all forty blobs arrived in one fetch");
994    }
995
996    /// When the promisor remote cannot answer, a blob that is not local is
997    /// reported UNREADABLE — promptly, per path — and never confused with a
998    /// path the rev does not have.
999    #[test]
1000    fn an_unfetchable_blob_is_unreadable_not_absent_and_not_a_hang() {
1001        let (_src, clone) = partial_clone("partial-dead", &[("a.py", "def a():\n    return 1\n")]);
1002        git_ok(
1003            &clone,
1004            &["remote", "set-url", "origin", "/nonexistent/weave-remote"],
1005        );
1006        let asked = vec!["a.py".to_string(), "never_existed.py".to_string()];
1007        let stage = within(60, move || {
1008            read_paths_at_rev(&clone, "HEAD", &asked).map_err(|e| e.to_string())
1009        })
1010        .expect("a dead remote is a per-path answer, not an error");
1011        assert!(stage.tree.is_empty());
1012        assert!(
1013            stage.unreadable.contains_key("a.py"),
1014            "{:?}",
1015            stage.unreadable
1016        );
1017        assert!(
1018            !stage.unreadable.contains_key("never_existed.py"),
1019            "absent is not unreadable"
1020        );
1021    }
1022
1023    /// A symlink or a submodule is not a source file: it is set aside, not read
1024    /// as one (a symlink's blob is its target path) and not read through.
1025    #[cfg(unix)]
1026    #[test]
1027    fn a_symlink_is_irregular_not_source() {
1028        let root = git_fixture("symlink");
1029        fs::write(root.join("real.py"), "x = 1\n").expect("write");
1030        std::os::unix::fs::symlink("real.py", root.join("link.py")).expect("symlink");
1031        git_ok(&root, &["add", "."]);
1032        git_ok(
1033            &root,
1034            &[
1035                "-c",
1036                "user.name=t",
1037                "-c",
1038                "user.email=t@t",
1039                "commit",
1040                "-qm",
1041                "c",
1042            ],
1043        );
1044        let stage =
1045            read_paths_at_rev(&root, "HEAD", &["real.py".into(), "link.py".into()]).expect("read");
1046        assert_eq!(stage.tree.keys().collect::<Vec<_>>(), vec!["real.py"]);
1047        assert!(stage.irregular.contains("link.py"));
1048        let work = read_worktree(&root).expect("worktree");
1049        assert!(
1050            !work.contains_key("link.py"),
1051            "a symlink is never read through"
1052        );
1053    }
1054
1055    /// Past its deadline a git child is killed and the answer is an error that
1056    /// names it — even when a grandchild still holds the output pipe open.
1057    #[test]
1058    fn a_git_that_never_answers_is_an_error_not_a_wait() {
1059        let root = git_fixture("slow");
1060        let started = std::time::Instant::now();
1061        let err = within(30, move || {
1062            bounded(
1063                &root,
1064                &["-c", "alias.stall=!sleep 20", "stall"],
1065                None,
1066                std::time::Duration::from_secs(1),
1067            )
1068            .map_err(|e| e.to_string())
1069        })
1070        .expect_err("a stalled git must not succeed");
1071        assert!(err.contains("did not finish within 1s"), "{err}");
1072        assert!(started.elapsed() < std::time::Duration::from_secs(10));
1073    }
1074}