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. With none of
421/// those in progress, a `HEAD` that is itself a merge commit is the merge just
422/// committed: ours defaults to `HEAD^1` and theirs to `HEAD^2`, the same shape
423/// [`merge_scope`] verifies.
424pub(crate) fn resolve_revs(
425    dir: &Path,
426    base: Option<&str>,
427    ours: Option<&str>,
428    theirs: Option<&str>,
429) -> R<(String, String, String)> {
430    if !rev_exists(dir, "HEAD") {
431        return Err(
432            "not a git repository (or no commits yet) — run inside a git repository with at \
433             least one commit"
434                .into(),
435        );
436    }
437    let mut ours_default = "HEAD";
438    let theirs = match theirs {
439        Some(t) => t.to_string(),
440        None => match [
441            "MERGE_HEAD",
442            "REBASE_HEAD",
443            "CHERRY_PICK_HEAD",
444            "REVERT_HEAD",
445        ]
446        .into_iter()
447        .find(|r| rev_exists(dir, r))
448        {
449            Some(r) => r.to_string(),
450            None if rev_exists(dir, "HEAD^2") => {
451                ours_default = "HEAD^1";
452                "HEAD^2".to_string()
453            }
454            None => {
455                return Err(
456                    "no theirs revision given (`--theirs`, or `theirs` over MCP) and no \
457                     merge, rebase, cherry-pick or revert in progress, and HEAD is not a merge \
458                     commit"
459                        .into(),
460                )
461            }
462        },
463    };
464    let ours = ours.unwrap_or(ours_default).to_string();
465    let base = match base {
466        Some(b) => b.to_string(),
467        None if theirs == "REBASE_HEAD" || theirs == "CHERRY_PICK_HEAD" => {
468            replay_base(dir, &theirs)?
469        }
470        None => git(dir, &["merge-base", &ours, &theirs])?
471            .trim()
472            .to_string(),
473    };
474    Ok((base, ours, theirs))
475}
476
477pub fn trees(
478    dir: &Path,
479    base: Option<&str>,
480    ours: Option<&str>,
481    theirs: Option<&str>,
482) -> R<(Tree, Tree, Tree)> {
483    let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
484    Ok((
485        read_rev_tree(dir, &base)?,
486        read_rev_tree(dir, &ours)?,
487        read_rev_tree(dir, &theirs)?,
488    ))
489}
490
491/// Everything the working-tree check needs: the three merge stages, the bytes
492/// on disk, the files the merge actually had to decide, and a sentence naming
493/// what was compared.
494pub struct MergeScope {
495    pub base: Tree,
496    pub ours: Tree,
497    pub theirs: Tree,
498    /// The working tree, as it is right now.
499    pub work: Tree,
500    /// Files BOTH sides changed, plus anything git still has unmerged.
501    pub subjects: Vec<String>,
502    /// Subjects a merge stage has but whose bytes could not be read, and why.
503    /// They are not in `subjects`: a file checked against a stage it could not
504    /// see would be checked against a guess. Each still gets a verdict.
505    pub unreadable: BTreeMap<String, String>,
506    /// Subjects a merge stage has as a symlink or a submodule: not source, and
507    /// not in `subjects`.
508    pub irregular: BTreeSet<String>,
509    pub scope: String,
510}
511
512/// Find the merge/rebase this repository is in — or has just finished — and
513/// read it.
514///
515/// Three shapes, in order, because they are the moments an agent asks:
516///
517/// * **mid-merge**: `MERGE_HEAD` exists. Ours is `HEAD`, theirs is
518///   `MERGE_HEAD`. This is the state right after `git merge` exits 1, and it
519///   survives `git add` — which is exactly why the index's unmerged list alone
520///   is not enough to find the subjects.
521/// * **mid-rebase / cherry-pick / revert**: there is no `MERGE_HEAD`, but git
522///   records the commit being applied as `REBASE_HEAD` / `CHERRY_PICK_HEAD` /
523///   `REVERT_HEAD`. Ours is `HEAD` (the side built so far), theirs is that
524///   commit. Without this, `weave check` fell through to "nothing was checked"
525///   during every rebase, while still suggesting itself (issue #157). For a
526///   rebase or cherry-pick the base is that commit's parent (`REBASE_HEAD^`),
527///   not `merge-base(ours, theirs)`: a replay applies one commit as a patch,
528///   and the merge base would smear the whole branch's divergence into it.
529/// * **just committed**: `HEAD` has two parents. Ours is `HEAD^1`, theirs is
530///   `HEAD^2`. An agent that committed and then wants to know what it did.
531///
532/// No shape present is not an error and must not be reported as one — it is
533/// the sentence "there is no merge here", which the caller prints.
534pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
535    if !rev_exists(dir, "HEAD") {
536        return Ok(None);
537    }
538    let (ours_rev, theirs_rev, moment, use_replay_base) = if rev_exists(dir, "MERGE_HEAD") {
539        (
540            "HEAD".to_string(),
541            "MERGE_HEAD".to_string(),
542            "merge in progress",
543            false,
544        )
545    } else if let Some(op_head) = ["REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]
546        .into_iter()
547        .find(|r| rev_exists(dir, r))
548    {
549        // A rebase, cherry-pick or revert conflict has no MERGE_HEAD; git
550        // records the commit being applied as *_HEAD, and the unmerged index
551        // stages are the real three-way. HEAD is the side built so far, so
552        // `weave check` can verify a resolution here too (issue #157). A
553        // replayed commit's base is its own parent; a revert keeps the merge
554        // base, as before.
555        let moment = match op_head {
556            "REBASE_HEAD" => "rebase in progress",
557            "CHERRY_PICK_HEAD" => "cherry-pick in progress",
558            _ => "revert in progress",
559        };
560        (
561            "HEAD".to_string(),
562            op_head.to_string(),
563            moment,
564            op_head != "REVERT_HEAD",
565        )
566    } else if rev_exists(dir, "HEAD^2") {
567        (
568            "HEAD^1".to_string(),
569            "HEAD^2".to_string(),
570            "merge just committed",
571            false,
572        )
573    } else {
574        return Ok(None);
575    };
576    let base_rev = if use_replay_base {
577        replay_base(dir, &theirs_rev)?
578    } else {
579        git(dir, &["merge-base", &ours_rev, &theirs_rev])?
580            .trim()
581            .to_string()
582    };
583
584    // The subjects are every file this merge PRODUCED: whatever either side
585    // moved. Restricting it to files both sides moved was the obvious-looking
586    // choice and it was wrong — the merge's most dangerous breakage is the
587    // cross-file one, where one side renames a definition in `a.py` and the
588    // other adds a caller in `b.py`, and neither file is contested. A checker
589    // that only looks where git had to choose cannot see it.
590    //
591    // This is asked of git by NAME — a name-only diff of each side against the
592    // base — and never by reading the two whole trees and comparing them in
593    // process. On a large monorepo that whole-tree read is the check's dominant
594    // cost, and it buys nothing: the answer is exactly the set git already has
595    // as the merge's changed paths.
596    //
597    // `--no-renames`: rename detection reads blobs, which in a partial clone
598    // means fetching them, and a rename's OLD path is a path the merge
599    // produced too.
600    let mut subjects: BTreeSet<String> = BTreeSet::new();
601    for side in [&ours_rev, &theirs_rev] {
602        let list = git(
603            dir,
604            &["diff", "--name-only", "--no-renames", "-z", &base_rev, side],
605        )?;
606        subjects.extend(
607            list.split('\0')
608                .filter(|p| !p.is_empty() && is_supported(p))
609                .map(str::to_string),
610        );
611    }
612    // …plus whatever git still calls unmerged, whether or not both sides moved
613    // it: git's own verdict about what is unresolved outranks ours.
614    if let Ok(list) = git(
615        dir,
616        &[
617            "diff",
618            "--name-only",
619            "--no-renames",
620            "--diff-filter=U",
621            "-z",
622        ],
623    ) {
624        subjects.extend(
625            list.split('\0')
626                .filter(|p| !p.is_empty() && is_supported(p))
627                .map(str::to_string),
628        );
629    }
630    let subjects: Vec<String> = subjects.into_iter().collect();
631
632    // Read the three merge stages for the SUBJECTS ONLY — one `ls-tree` and one
633    // `cat-file --batch` per rev, not a `git show` per file over the whole tree. The
634    // dangling pass proves this is exact: a name is only ever "gone" from a file
635    // both a stage and the working tree disagree about, which is a subject; an
636    // untouched file's stage and its working-tree copy are identical, so it can
637    // neither create a dangling finding nor host the definition that resolves
638    // one from stage data. Suppression by an untouched file's *surviving*
639    // definition is the working tree's job, and `work` below stays repo-wide for
640    // exactly that.
641    let stages = [
642        read_paths_at_rev(dir, &base_rev, &subjects)?,
643        read_paths_at_rev(dir, &ours_rev, &subjects)?,
644        read_paths_at_rev(dir, &theirs_rev, &subjects)?,
645    ];
646    let work = read_worktree(dir)?;
647    let scope = format!(
648        "working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
649         (base {}, {moment}) — {} file(s) either side changed",
650        &base_rev[..base_rev.len().min(8)],
651        subjects.len()
652    );
653    // A symlink or submodule is not source: git's guarantees stand for it, as
654    // for any file weave has no grammar for.
655    let mut unreadable: BTreeMap<String, String> = BTreeMap::new();
656    let mut irregular: BTreeSet<String> = BTreeSet::new();
657    for stage in &stages {
658        for (path, why) in &stage.unreadable {
659            unreadable
660                .entry(path.clone())
661                .or_insert_with(|| why.clone());
662        }
663        irregular.extend(stage.irregular.iter().cloned());
664    }
665    let subjects = subjects
666        .into_iter()
667        .filter(|p| !irregular.contains(p) && !unreadable.contains_key(p))
668        .collect();
669    irregular.retain(|p| !unreadable.contains_key(p));
670    let [base, ours, theirs] = stages.map(|s| s.tree);
671    Ok(Some(MergeScope {
672        base,
673        ours,
674        theirs,
675        work,
676        subjects,
677        unreadable,
678        irregular,
679        scope,
680    }))
681}
682
683/// Every tracked, supported, regular file as it exists on disk right now.
684///
685/// Regular only: reading through a tracked symlink reads whatever it points
686/// at, and a link to a FIFO or a device never returns.
687pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
688    let listing = git(dir, &["ls-files", "-z"])?;
689    let mut tree = Tree::new();
690    for rel in listing.split('\0').filter(|p| !p.is_empty()) {
691        if !is_supported(rel) {
692            continue;
693        }
694        let path = dir.join(rel);
695        if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_file()) {
696            continue;
697        }
698        if let Ok(content) = std::fs::read_to_string(path) {
699            tree.insert(rel.to_string(), content);
700        }
701    }
702    Ok(tree)
703}
704
705/// The three merge stages of ONE path, straight out of the index
706/// (`:1:`/`:2:`/`:3:`) while the file is unmerged, falling back to the merge
707/// revisions once it has been staged.
708///
709/// `weave explain` needs the triple for a single file and must keep working
710/// after `git add` — the index stages disappear at that moment, and an
711/// explanation that stops being available the instant you stage is an
712/// explanation nobody can use twice.
713pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
714    let stage = |n: u8| git(dir, &["show", &format!(":{n}:{path}")]).ok();
715    if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
716        return Ok((b, o, t));
717    }
718    let scope = merge_scope(dir)?.ok_or(
719        "no merge, rebase, cherry-pick or revert in progress and HEAD is not a merge \
720                commit — there is no three-way context to explain this file against",
721    )?;
722    let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
723    if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
724        return Err(format!("`{path}` is in neither side of this merge").into());
725    }
726    Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
727}
728
729/// The WHOLE tree at `rev`, restricted to files weave has a grammar for.
730/// Unsupported / binary / oversize files inherit git's guarantees wholesale
731/// and are not scanned.
732pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
733    let listing = git(
734        dir,
735        &["ls-tree", "-r", "--name-only", "-z", "--full-tree", rev],
736    )?;
737    let paths: Vec<String> = listing
738        .split('\0')
739        .filter(|p| !p.is_empty() && is_supported(p))
740        .map(str::to_string)
741        .collect();
742    // An unreadable blob is skipped here, as an unreadable `git show` always
743    // was: this pass reports cross-file findings, not per-file verdicts.
744    Ok(read_paths_at_rev(dir, rev, &paths)?.tree)
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use std::fs;
751    use std::path::PathBuf;
752    use std::process::Command;
753    use std::sync::atomic::{AtomicU32, Ordering};
754
755    static FIXTURE_COUNTER: AtomicU32 = AtomicU32::new(0);
756
757    fn git_ok(dir: &Path, args: &[&str]) -> String {
758        let out = Command::new("git")
759            .args(args)
760            .current_dir(dir)
761            .output()
762            .expect("run git");
763        assert!(
764            out.status.success(),
765            "git {} failed: {}",
766            args.join(" "),
767            String::from_utf8_lossy(&out.stderr).trim()
768        );
769        String::from_utf8_lossy(&out.stdout).to_string()
770    }
771
772    fn git_fails(dir: &Path, args: &[&str]) {
773        let status = Command::new("git")
774            .args(args)
775            .current_dir(dir)
776            .status()
777            .expect("run git");
778        assert!(
779            !status.success(),
780            "git {} unexpectedly succeeded",
781            args.join(" ")
782        );
783    }
784
785    fn git_fixture(name: &str) -> PathBuf {
786        let n = FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed);
787        let root = std::env::temp_dir().join(format!(
788            "weave-gitscan-test-{}-{}-{}",
789            std::process::id(),
790            name,
791            n
792        ));
793        fs::create_dir_all(&root).expect("create fixture dir");
794        git_ok(&root, &["init", "-q"]);
795        git_ok(&root, &["checkout", "-b", "main"]);
796        root
797    }
798
799    /// The batch read must return even when git's replies are far larger than
800    /// one pipe buffer. Writing every request before reading any reply
801    /// deadlocked here: git blocked on a full stdout, stopped reading stdin,
802    /// and the request write never returned. It runs on a watchdog thread so a
803    /// regression fails the test instead of hanging the suite.
804    #[test]
805    fn batch_read_of_large_blobs_does_not_deadlock() {
806        // Both pipes have to overflow for the deadlock: enough requests that
807        // they outgrow the stdin buffer, and enough reply bytes to fill stdout
808        // before git has read them all.
809        let root = git_fixture("cat-file-large");
810        let deep = "a_directory_name_long_enough_to_fatten_every_request/and_one_more_level";
811        fs::create_dir_all(root.join(deep)).expect("mkdir");
812        let mut paths = Vec::new();
813        for i in 0..3000 {
814            let name = format!("{deep}/module_number_{i:05}.py");
815            fs::write(root.join(&name), format!("value_{i} = {i}\n").repeat(8)).expect("write");
816            paths.push(name);
817        }
818        paths.push("absent.py".to_string());
819        git_ok(&root, &["add", "."]);
820        git_ok(&root, &["commit", "-m", "big"]);
821
822        let (tx, rx) = std::sync::mpsc::channel();
823        let dir = root.clone();
824        let asked = paths.clone();
825        std::thread::spawn(move || {
826            let _ = tx.send(
827                read_paths_at_rev(&dir, "HEAD", &asked)
828                    .map(|s| s.tree)
829                    .map_err(|e| e.to_string()),
830            );
831        });
832        let tree = rx
833            .recv_timeout(std::time::Duration::from_secs(60))
834            .expect("read_paths_at_rev hung on a large batch")
835            .expect("read_paths_at_rev failed");
836        assert_eq!(
837            tree.len(),
838            3000,
839            "every present path read, the absent one skipped"
840        );
841        assert_eq!(
842            tree[&format!("{deep}/module_number_02999.py")],
843            "value_2999 = 2999\n".repeat(8)
844        );
845    }
846
847    /// Rebasing a diverged branch onto main leaves REBASE_HEAD at the replayed
848    /// commit; merge_scope must recognize that shape so `weave check` works.
849    #[test]
850    fn merge_scope_during_rebase_conflict() {
851        let root = git_fixture("rebase-conflict");
852        let base_content = "def a():\n    return 1\n\ndef keep():\n    return 'stable'\n";
853
854        fs::write(root.join("m.py"), base_content).expect("write base");
855        git_ok(&root, &["add", "m.py"]);
856        git_ok(&root, &["commit", "-m", "initial"]);
857
858        git_ok(&root, &["checkout", "-b", "feature"]);
859        let feature_content = base_content.replace("return 1", "return 2");
860        fs::write(root.join("m.py"), &feature_content).expect("write feature");
861        git_ok(&root, &["add", "m.py"]);
862        git_ok(&root, &["commit", "-m", "feature"]);
863        let replayed_commit = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
864
865        git_ok(&root, &["checkout", "main"]);
866        let main_content = base_content.replace("return 1", "return 3");
867        fs::write(root.join("m.py"), &main_content).expect("write main");
868        git_ok(&root, &["add", "m.py"]);
869        git_ok(&root, &["commit", "-m", "main"]);
870
871        git_ok(&root, &["checkout", "feature"]);
872        git_fails(&root, &["rebase", "main"]);
873
874        let scope = merge_scope(&root)
875            .expect("merge_scope should not error")
876            .expect("merge_scope should recognize mid-rebase state");
877
878        assert!(
879            scope.scope.contains("rebase in progress"),
880            "scope should name the rebase moment: {}",
881            scope.scope
882        );
883
884        let rebase_head = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
885            .trim()
886            .to_string();
887        assert_eq!(
888            rebase_head, replayed_commit,
889            "REBASE_HEAD should be the commit being replayed"
890        );
891
892        let theirs_rev = git_ok(&root, &["rev-parse", "REBASE_HEAD"])
893            .trim()
894            .to_string();
895        assert_eq!(
896            scope.theirs.get("m.py").map(String::as_str),
897            Some(feature_content.as_str()),
898            "theirs stage should come from REBASE_HEAD"
899        );
900        assert!(
901            scope.subjects.iter().any(|p| p == "m.py"),
902            "subjects should include the conflicted file: {:?}",
903            scope.subjects
904        );
905
906        // Sanity: ours is HEAD (main's tip), not the replayed commit.
907        let head_rev = git_ok(&root, &["rev-parse", "HEAD"]).trim().to_string();
908        assert_ne!(head_rev, replayed_commit);
909        assert_eq!(
910            scope.ours.get("m.py").map(String::as_str),
911            Some(main_content.as_str()),
912            "ours stage should come from HEAD"
913        );
914        assert_eq!(theirs_rev, replayed_commit);
915    }
916
917    /// A blobless clone whose blobs are not local: the source, and a bare
918    /// partial clone of it that has every tree and no blob.
919    fn partial_clone(name: &str, files: &[(&str, &str)]) -> (PathBuf, PathBuf) {
920        let src = git_fixture(name);
921        git_ok(&src, &["config", "uploadpack.allowFilter", "true"]);
922        git_ok(&src, &["config", "uploadpack.allowAnySHA1InWant", "true"]);
923        for (path, text) in files {
924            if let Some(dir) = Path::new(path).parent() {
925                fs::create_dir_all(src.join(dir)).expect("mkdir");
926            }
927            fs::write(src.join(path), text).expect("write");
928        }
929        git_ok(&src, &["add", "."]);
930        git_ok(
931            &src,
932            &[
933                "-c",
934                "user.name=t",
935                "-c",
936                "user.email=t@t",
937                "commit",
938                "-qm",
939                "c",
940            ],
941        );
942        let clone = src.with_extension("partial.git");
943        let url = format!("file://{}", src.display());
944        let parent = clone.parent().expect("parent");
945        git_ok(
946            parent,
947            &[
948                "clone",
949                "-q",
950                "--bare",
951                "--filter=blob:none",
952                &url,
953                clone.to_str().expect("utf8"),
954            ],
955        );
956        (src, clone)
957    }
958
959    /// Runs `f` on a watchdog thread, so a hang fails the test instead of the
960    /// suite.
961    fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
962        let (tx, rx) = std::sync::mpsc::channel();
963        std::thread::spawn(move || {
964            let _ = tx.send(f());
965        });
966        rx.recv_timeout(std::time::Duration::from_secs(secs))
967            .expect("hung past its deadline")
968    }
969
970    /// In a partial clone, every blob the stage read needs arrives in ONE
971    /// fetch — not one `git fetch` per object, which is what `cat-file --batch`
972    /// does on its own, and which left `weave check` idle for hours on a merge
973    /// of a few thousand files.
974    #[test]
975    fn a_partial_clone_fetches_missing_stage_blobs_in_one_batch() {
976        let files: Vec<(String, String)> = (0..40)
977            .map(|i| {
978                (
979                    format!("pkg/m{i}.py"),
980                    format!("def f{i}():\n    return {i}\n"),
981                )
982            })
983            .collect();
984        let refs: Vec<(&str, &str)> = files
985            .iter()
986            .map(|(p, t)| (p.as_str(), t.as_str()))
987            .collect();
988        let (_src, clone) = partial_clone("partial-fetch", &refs);
989        let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect();
990        let packs = |dir: &Path| {
991            fs::read_dir(dir.join("objects/pack"))
992                .expect("pack dir")
993                .filter(|e| {
994                    e.as_ref()
995                        .is_ok_and(|e| e.path().extension().is_some_and(|x| x == "pack"))
996                })
997                .count()
998        };
999        assert_eq!(packs(&clone), 1, "the clone's own pack: trees, no blobs");
1000        let dir = clone.clone();
1001        let stage = within(60, move || {
1002            read_paths_at_rev(&dir, "HEAD", &paths).map_err(|e| e.to_string())
1003        })
1004        .expect("read");
1005        assert!(stage.unreadable.is_empty(), "{:?}", stage.unreadable);
1006        assert_eq!(stage.tree.len(), 40);
1007        assert_eq!(stage.tree["pkg/m7.py"], "def f7():\n    return 7\n");
1008        // One fetch writes one pack. A fetch per object would write forty.
1009        assert_eq!(packs(&clone), 2, "all forty blobs arrived in one fetch");
1010    }
1011
1012    /// When the promisor remote cannot answer, a blob that is not local is
1013    /// reported UNREADABLE — promptly, per path — and never confused with a
1014    /// path the rev does not have.
1015    #[test]
1016    fn an_unfetchable_blob_is_unreadable_not_absent_and_not_a_hang() {
1017        let (_src, clone) = partial_clone("partial-dead", &[("a.py", "def a():\n    return 1\n")]);
1018        git_ok(
1019            &clone,
1020            &["remote", "set-url", "origin", "/nonexistent/weave-remote"],
1021        );
1022        let asked = vec!["a.py".to_string(), "never_existed.py".to_string()];
1023        let stage = within(60, move || {
1024            read_paths_at_rev(&clone, "HEAD", &asked).map_err(|e| e.to_string())
1025        })
1026        .expect("a dead remote is a per-path answer, not an error");
1027        assert!(stage.tree.is_empty());
1028        assert!(
1029            stage.unreadable.contains_key("a.py"),
1030            "{:?}",
1031            stage.unreadable
1032        );
1033        assert!(
1034            !stage.unreadable.contains_key("never_existed.py"),
1035            "absent is not unreadable"
1036        );
1037    }
1038
1039    /// A symlink or a submodule is not a source file: it is set aside, not read
1040    /// as one (a symlink's blob is its target path) and not read through.
1041    #[cfg(unix)]
1042    #[test]
1043    fn a_symlink_is_irregular_not_source() {
1044        let root = git_fixture("symlink");
1045        fs::write(root.join("real.py"), "x = 1\n").expect("write");
1046        std::os::unix::fs::symlink("real.py", root.join("link.py")).expect("symlink");
1047        git_ok(&root, &["add", "."]);
1048        git_ok(
1049            &root,
1050            &[
1051                "-c",
1052                "user.name=t",
1053                "-c",
1054                "user.email=t@t",
1055                "commit",
1056                "-qm",
1057                "c",
1058            ],
1059        );
1060        let stage =
1061            read_paths_at_rev(&root, "HEAD", &["real.py".into(), "link.py".into()]).expect("read");
1062        assert_eq!(stage.tree.keys().collect::<Vec<_>>(), vec!["real.py"]);
1063        assert!(stage.irregular.contains("link.py"));
1064        let work = read_worktree(&root).expect("worktree");
1065        assert!(
1066            !work.contains_key("link.py"),
1067            "a symlink is never read through"
1068        );
1069    }
1070
1071    /// Past its deadline a git child is killed and the answer is an error that
1072    /// names it — even when a grandchild still holds the output pipe open.
1073    #[test]
1074    fn a_git_that_never_answers_is_an_error_not_a_wait() {
1075        let root = git_fixture("slow");
1076        let started = std::time::Instant::now();
1077        let err = within(30, move || {
1078            bounded(
1079                &root,
1080                &["-c", "alias.stall=!sleep 20", "stall"],
1081                None,
1082                std::time::Duration::from_secs(1),
1083            )
1084            .map_err(|e| e.to_string())
1085        })
1086        .expect_err("a stalled git must not succeed");
1087        assert!(err.contains("did not finish within 1s"), "{err}");
1088        assert!(started.elapsed() < std::time::Duration::from_secs(10));
1089    }
1090
1091    /// With no merge in progress, a merge commit at HEAD is the merge just
1092    /// committed: the revisions default to its two parents, as `merge_scope`
1093    /// reads them, instead of failing for want of a MERGE_HEAD.
1094    #[test]
1095    fn resolve_revs_defaults_to_a_committed_merge() {
1096        let root = git_fixture("committed-merge");
1097        let commit = |msg: &str| {
1098            git_ok(
1099                &root,
1100                &[
1101                    "-c",
1102                    "user.name=t",
1103                    "-c",
1104                    "user.email=t@t",
1105                    "commit",
1106                    "-qm",
1107                    msg,
1108                ],
1109            );
1110        };
1111        fs::write(root.join("a.py"), "x = 1\n").expect("write");
1112        git_ok(&root, &["add", "."]);
1113        commit("base");
1114        git_ok(&root, &["checkout", "-qb", "side"]);
1115        fs::write(root.join("b.py"), "y = 1\n").expect("write");
1116        git_ok(&root, &["add", "."]);
1117        commit("side");
1118        git_ok(&root, &["checkout", "-q", "main"]);
1119        fs::write(root.join("c.py"), "z = 1\n").expect("write");
1120        git_ok(&root, &["add", "."]);
1121        commit("main");
1122        git_ok(
1123            &root,
1124            &[
1125                "-c",
1126                "user.name=t",
1127                "-c",
1128                "user.email=t@t",
1129                "merge",
1130                "-q",
1131                "--no-edit",
1132                "side",
1133            ],
1134        );
1135
1136        let (_, ours, theirs) = resolve_revs(&root, None, None, None).expect("committed merge");
1137        assert_eq!((ours.as_str(), theirs.as_str()), ("HEAD^1", "HEAD^2"));
1138        let _ = fs::remove_dir_all(&root);
1139    }
1140}