Skip to main content

differential_engine/
gitio.rs

1//! Git subprocess runner. Bytes in, bytes out — UTF-8 decoding happens only at
2//! display boundaries (ADR 0002). Plumbing commands only (ADR 0011).
3
4use std::collections::HashMap;
5use std::ffi::OsStr;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8
9use crate::EngineError;
10use crate::ports;
11
12#[derive(Debug, Clone)]
13pub struct Repo {
14    root: PathBuf,
15}
16
17impl Repo {
18    /// Open the repository containing `dir`.
19    pub fn open(dir: &Path) -> Result<Self, EngineError> {
20        let probe = Repo {
21            root: dir.to_path_buf(),
22        };
23        let out = probe.run(["rev-parse", "--show-toplevel"], None)?;
24        let root = PathBuf::from(String::from_utf8_lossy(trim_newline(&out)).into_owned());
25        Ok(Repo { root })
26    }
27
28    pub fn root(&self) -> &Path {
29        &self.root
30    }
31
32    /// Run a git command with raw byte i/o. Non-zero exit is an error carrying
33    /// the command line and stderr.
34    ///
35    /// PRIVATE, and the point of the ports (ADR 0020): domain code cannot spell
36    /// an arbitrary git command, so each consumer's bounds are an honest
37    /// statement of what it touches.
38    fn run<I, S>(&self, args: I, stdin: Option<&[u8]>) -> Result<Vec<u8>, EngineError>
39    where
40        I: IntoIterator<Item = S>,
41        S: AsRef<OsStr>,
42    {
43        self.run_env(args, stdin, &[])
44    }
45
46    /// Like `run`, with extra environment variables (e.g. GIT_INDEX_FILE).
47    fn run_env<I, S>(
48        &self,
49        args: I,
50        stdin: Option<&[u8]>,
51        env: &[(&str, &OsStr)],
52    ) -> Result<Vec<u8>, EngineError>
53    where
54        I: IntoIterator<Item = S>,
55        S: AsRef<OsStr>,
56    {
57        let mut cmd = Command::new("git");
58        // Belt and braces on top of plumbing: no color, no external diff drivers,
59        // no path quoting surprises.
60        cmd.arg("-c")
61            .arg("core.quotepath=false")
62            .args(args)
63            .current_dir(&self.root)
64            .stdin(if stdin.is_some() {
65                Stdio::piped()
66            } else {
67                Stdio::null()
68            })
69            .stdout(Stdio::piped())
70            .stderr(Stdio::piped());
71        for (k, v) in env {
72            cmd.env(k, v);
73        }
74
75        let mut child = cmd
76            .spawn()
77            .map_err(|e| EngineError::GitSpawn { source: e })?;
78        // Stdin is written on its own thread while `wait_with_output` drains
79        // stdout and stderr. `cat-file --batch` and `check-attr --stdin`
80        // answer each line as it arrives, so writing ALL of stdin first
81        // deadlocked once the answer filled its pipe: git blocked writing,
82        // and this blocked writing to git. The engine's `subprocess` module
83        // solves the same problem, but with a polling watchdog and a deadline
84        // a local git call has no use for, on every one of hundreds of calls.
85        let pipe = stdin.map(|_| child.stdin.take().expect("stdin was requested"));
86        let (out, written) = std::thread::scope(|scope| {
87            let writer = pipe.zip(stdin).map(|(mut pipe, data)| {
88                scope.spawn(move || {
89                    use std::io::Write;
90                    pipe.write_all(data)
91                    // The pipe drops here, closing stdin.
92                })
93            });
94            let out = child.wait_with_output();
95            let written = writer.map_or(Ok(()), |w| w.join().expect("stdin writer panicked"));
96            (out, written)
97        });
98        let out = out.map_err(|e| EngineError::GitSpawn { source: e })?;
99        // A write that failed because git exited early is reported by git's
100        // own status below; one that failed while git succeeded is ours.
101        if out.status.success() {
102            written.map_err(|e| EngineError::GitSpawn { source: e })?;
103        }
104
105        if !out.status.success() {
106            return Err(EngineError::GitCommand {
107                command: describe(&cmd),
108                code: out.status.code(),
109                stderr: String::from_utf8_lossy(&out.stderr[..out.stderr.len().min(800)])
110                    .into_owned(),
111            });
112        }
113        Ok(out.stdout)
114    }
115
116    /// Run a git command for its exit status, which the caller treats as the
117    /// answer rather than as success or failure.
118    ///
119    /// Separate from `run` rather than loosening it: `run` turning a non-zero
120    /// exit into an error is what makes every other call site safe by default.
121    fn run_status<I, S>(&self, args: I) -> Result<std::process::ExitStatus, EngineError>
122    where
123        I: IntoIterator<Item = S>,
124        S: AsRef<OsStr>,
125    {
126        Command::new("git")
127            .arg("-c")
128            .arg("core.quotepath=false")
129            .args(args)
130            .current_dir(&self.root)
131            .stdin(Stdio::null())
132            .stdout(Stdio::null())
133            .stderr(Stdio::null())
134            .status()
135            .map_err(|e| EngineError::GitSpawn { source: e })
136    }
137
138    /// Blob content at `rev:path`. `Ok(None)` when the path does not exist at
139    /// that revision; any other failure is a real error.
140    fn blob(&self, rev: &str, path: &[u8]) -> Result<Option<Vec<u8>>, EngineError> {
141        Ok(self.blobs(&[(rev, path)])?.pop().flatten())
142    }
143
144    /// Several blobs, one process.
145    ///
146    /// `--batch` states absence as the word "missing" rather than as an exit
147    /// code, so the existence probe this used to spawn first is not merely
148    /// saved but replaced by something more explicit. And it reads a LIST from
149    /// stdin, which is what makes the batch free: the protocol was always
150    /// there, only the loop is new. A spawn costs milliseconds and the reviewer
151    /// reads two blobs for every file it draws (ADR 0021).
152    ///
153    /// The specs go in on stdin, which also keeps paths as raw bytes without an
154    /// `OsString` detour. `-z` is not optional: git paths may contain a
155    /// newline, and line-delimited input splits such a path into two specs that
156    /// both come back "missing" — a wrong answer with no error attached to it.
157    fn blobs(&self, specs: &[(&str, &[u8])]) -> Result<Vec<Option<Vec<u8>>>, EngineError> {
158        if specs.is_empty() {
159            return Ok(Vec::new());
160        }
161        let wire: Vec<Vec<u8>> = specs
162            .iter()
163            .map(|(rev, path)| {
164                let mut s = rev.as_bytes().to_vec();
165                s.push(b':');
166                s.extend_from_slice(path);
167                s
168            })
169            .collect();
170        let mut stdin = Vec::new();
171        for s in &wire {
172            stdin.extend_from_slice(s);
173            stdin.push(0);
174        }
175        let out = self.run(["cat-file", "--batch", "-z"], Some(&stdin))?;
176        parse_batch_blobs(&out, &wire).map_err(|msg| EngineError::GitCommand {
177            command: format!("cat-file --batch ({} specs)", specs.len()),
178            code: None,
179            stderr: msg,
180        })
181    }
182
183    /// Fully resolve a revision to a commit sha.
184    fn rev_parse(&self, rev: &str) -> Result<String, EngineError> {
185        let out = self.run(
186            ["rev-parse", "--verify", &format!("{rev}^{{commit}}")],
187            None,
188        )?;
189        Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
190    }
191
192    /// Resolve to a commit sha, or accept a raw tree oid — the endpoints of
193    /// an uncommitted-state review are synthesized trees (ADR 0017), and
194    /// everything downstream of resolution is tree-safe.
195    fn rev_parse_commit_or_tree(&self, rev: &str) -> Result<String, EngineError> {
196        self.rev_parse(rev)
197            .or_else(|_| self.rev_parse_raw(&format!("{rev}^{{tree}}")))
198    }
199
200    /// Resolve any rev expression (tree, `X^{tree}`, blob spec) to an object id.
201    fn rev_parse_raw(&self, expr: &str) -> Result<String, EngineError> {
202        let out = self.run(["rev-parse", "--verify", expr], None)?;
203        Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
204    }
205
206    fn merge_base(&self, a: &str, b: &str) -> Result<String, EngineError> {
207        let out = self.run(["merge-base", a, b], None)?;
208        Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
209    }
210
211    /// The shared git directory (worktree-safe). Per-repo state such as the
212    /// grouping cache lives under `<common-dir>/differential/`.
213    fn common_dir(&self) -> Result<PathBuf, EngineError> {
214        let out = self.run(["rev-parse", "--git-common-dir"], None)?;
215        let p = PathBuf::from(String::from_utf8_lossy(trim_newline(&out)).into_owned());
216        Ok(if p.is_absolute() {
217            p
218        } else {
219            self.root.join(p)
220        })
221    }
222}
223
224// ------------------------------------------------------------------ ports
225//
226// `Repo` is the ONLY implementation of these, and ADR 0020 forbids a second —
227// invariants 1-4 compare the engine against git's own answer, so a fake git
228// would compare the fake with the fake.
229
230impl ports::ObjectReader for Repo {
231    fn blob(&self, rev: &str, path: &[u8]) -> Result<Option<Vec<u8>>, EngineError> {
232        Repo::blob(self, rev, path)
233    }
234
235    fn blobs(&self, specs: &[(&str, &[u8])]) -> Result<Vec<Option<Vec<u8>>>, EngineError> {
236        Repo::blobs(self, specs)
237    }
238
239    fn require_object(&self, oid: &str) -> Result<(), EngineError> {
240        self.run(["cat-file", "-e", oid], None).map(|_| ())
241    }
242}
243
244impl ports::ObjectWriter for Repo {
245    fn write_blob(&self, content: &[u8]) -> Result<String, EngineError> {
246        let out = self.run(["hash-object", "-w", "--stdin"], Some(content))?;
247        Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
248    }
249}
250
251impl ports::RangeResolver for Repo {
252    fn merge_base(&self, a: &str, b: &str) -> Result<String, EngineError> {
253        Repo::merge_base(self, a, b)
254    }
255
256    fn resolve_endpoint(&self, rev: &str) -> Result<String, EngineError> {
257        self.rev_parse_commit_or_tree(rev)
258    }
259}
260
261impl ports::Ancestry for Repo {
262    fn commit_of(&self, spec: &str) -> Result<Option<String>, EngineError> {
263        // A spec that names nothing is a deleted branch, not a git failure:
264        // the caller skips that candidate rather than refusing to open.
265        Ok(self.rev_parse(spec).ok())
266    }
267
268    fn is_ancestor(&self, older: &str, newer: &str) -> Result<bool, EngineError> {
269        // Exit 0 is yes and exit 1 is no — both are answers, which is what
270        // `run_status` is for. Anything else is git failing to answer, and
271        // that must surface rather than read as "no": a review silently
272        // filing itself twice is exactly what this code exists to prevent.
273        let status = self.run_status(["merge-base", "--is-ancestor", older, newer])?;
274        match status.code() {
275            Some(0) => Ok(true),
276            Some(1) => Ok(false),
277            other => Err(EngineError::GitCommand {
278                command: format!("merge-base --is-ancestor {older} {newer}"),
279                code: other,
280                stderr: String::new(),
281            }),
282        }
283    }
284}
285
286impl ports::Fetcher for Repo {
287    fn fetch(&self, remote: &str, refspecs: &[&str]) -> Result<(), EngineError> {
288        let mut args = vec!["fetch", "--quiet", remote];
289        args.extend(refspecs);
290        self.run(args, None).map(|_| ())
291    }
292}
293
294impl ports::TreeResolver for Repo {
295    fn tree_of(&self, rev: &str) -> Result<String, EngineError> {
296        self.rev_parse_raw(&format!("{rev}^{{tree}}"))
297    }
298}
299
300impl ports::DiffSource for Repo {
301    // FROZEN ARGV — see the trait docs. Add a method, never edit one.
302    fn raw_records(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError> {
303        self.run(
304            [
305                "diff-tree",
306                "-r",
307                "-z",
308                "--raw",
309                "--full-index",
310                "--no-renames",
311                base,
312                head,
313            ],
314            None,
315        )
316    }
317
318    fn canonical_patch(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError> {
319        self.run(
320            [
321                "diff-tree",
322                "-r",
323                "-U0",
324                "--no-renames",
325                "--no-color",
326                "--no-ext-diff",
327                base,
328                head,
329            ],
330            None,
331        )
332    }
333
334    fn rename_records(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError> {
335        self.run(
336            ["diff-tree", "-r", "-M", "-z", "--name-status", base, head],
337            None,
338        )
339    }
340}
341
342impl ports::RecountSource for Repo {
343    /// Spelled out here rather than delegating to `canonical_patch`, and the
344    /// argv genuinely differs from it (no `--no-color --no-ext-diff`). Both
345    /// facts are deliberate: invariant 4's independence is the whole point, so
346    /// one edit to enumeration's flags must not move both sides of the
347    /// comparison. Do not "tidy" these two into one.
348    fn recount_patch(&self, from: &str, to: &str) -> Result<Vec<u8>, EngineError> {
349        self.run(["diff-tree", "-r", "-U0", "--no-renames", from, to], None)
350    }
351}
352
353impl ports::AttributeSource for Repo {
354    fn check_attr(
355        &self,
356        attr: &str,
357        paths: &[&[u8]],
358    ) -> Result<Vec<ports::AttrValue>, EngineError> {
359        if paths.is_empty() {
360            return Ok(Vec::new());
361        }
362        let mut stdin: Vec<u8> = Vec::new();
363        for p in paths {
364            stdin.extend_from_slice(p);
365            stdin.push(0);
366        }
367        let out = self.run(["check-attr", "-z", "--stdin", attr], Some(&stdin))?;
368        // -z output: path NUL attr NUL value NUL ...
369        let fields: Vec<&[u8]> = out.split(|&b| b == 0).collect();
370        Ok(fields
371            .chunks_exact(3)
372            .map(|triple| ports::AttrValue {
373                path: triple[0].to_vec(),
374                value: triple[2].to_vec(),
375            })
376            .collect())
377    }
378}
379
380/// A scratch index and its temp directory, alive together.
381///
382/// Owning the `TempDir` is what removes the `let _keep = idx;` hazards the
383/// callers used to need: `write_tree` borrows the session, so the index file
384/// provably outlives every use of it.
385pub struct ScratchIndex {
386    repo: Repo,
387    _dir: tempfile::TempDir,
388    index: std::ffi::OsString,
389}
390
391impl ScratchIndex {
392    /// A path that does NOT exist yet: git treats an existing empty file as a
393    /// corrupt index, so hand it a fresh name inside a temp dir rather than a
394    /// pre-created `NamedTempFile`.
395    fn open(repo: &Repo) -> Result<Self, EngineError> {
396        let dir = tempfile::TempDir::new().map_err(|e| EngineError::GitSpawn { source: e })?;
397        let index = dir.path().join("index").into_os_string();
398        Ok(ScratchIndex {
399            repo: repo.clone(),
400            _dir: dir,
401            index,
402        })
403    }
404
405    fn env(&self) -> [(&str, &OsStr); 1] {
406        [("GIT_INDEX_FILE", self.index.as_os_str())]
407    }
408}
409
410impl ports::TreeBuilder for Repo {
411    type Session = ScratchIndex;
412
413    fn begin_from_tree(&self, tree_ish: &str) -> Result<ScratchIndex, EngineError> {
414        let idx = ScratchIndex::open(self)?;
415        self.run_env(["read-tree", tree_ish], None, &idx.env())?;
416        Ok(idx)
417    }
418
419    fn begin_from_current_index(&self) -> Result<ScratchIndex, EngineError> {
420        // "<mode> <oid> <stage>\t<path>" records — exactly the second input
421        // format `update-index --index-info` accepts, so the seed is a byte
422        // pipe with both ends inside this adapter.
423        let entries = self.run(["ls-files", "-s", "-z"], None)?;
424        for record in entries.split(|&b| b == 0) {
425            let meta = record.split(|&b| b == b'\t').next().unwrap_or(record);
426            if meta.ends_with(b" 1") || meta.ends_with(b" 2") || meta.ends_with(b" 3") {
427                return Err(EngineError::Range(
428                    "index has unmerged entries — resolve conflicts before reviewing \
429                     uncommitted changes"
430                        .into(),
431                ));
432            }
433        }
434        let idx = ScratchIndex::open(self)?;
435        if !entries.is_empty() {
436            self.run_env(
437                ["update-index", "-z", "--index-info"],
438                Some(&entries),
439                &idx.env(),
440            )?;
441        }
442        Ok(idx)
443    }
444}
445
446impl ports::IndexSession for ScratchIndex {
447    fn stage(&mut self, entries: &[ports::IndexEntry]) -> Result<(), EngineError> {
448        if entries.is_empty() {
449            return Ok(());
450        }
451        let mut feed: Vec<u8> = Vec::new();
452        for e in entries {
453            feed.extend_from_slice(&index_record(e));
454            feed.push(0);
455        }
456        self.repo
457            .run_env(
458                ["update-index", "-z", "--index-info"],
459                Some(&feed),
460                &self.env(),
461            )
462            .map(|_| ())
463    }
464
465    fn stage_from_worktree(&mut self, nul_paths: &[u8]) -> Result<(), EngineError> {
466        if nul_paths.is_empty() {
467            return Ok(());
468        }
469        self.repo
470            .run_env(
471                ["update-index", "--add", "--remove", "-z", "--stdin"],
472                Some(nul_paths),
473                &self.env(),
474            )
475            .map(|_| ())
476    }
477
478    fn write_tree(&self) -> Result<String, EngineError> {
479        let out = self.repo.run_env(["write-tree"], None, &self.env())?;
480        Ok(String::from_utf8_lossy(&out).trim().to_string())
481    }
482}
483
484const ZERO_OID: &str = "0000000000000000000000000000000000000000";
485
486/// One `update-index --index-info` record: `<mode> <oid>\t<path>`, removal
487/// spelled as mode 0. Git wire format, so it lives with the adapter.
488fn index_record(e: &ports::IndexEntry) -> Vec<u8> {
489    let (mode, oid, path) = match e {
490        ports::IndexEntry::Set { mode, oid, path } => (mode.as_str(), oid.as_str(), path),
491        ports::IndexEntry::Remove { path } => ("0", ZERO_OID, path),
492    };
493    let mut line = format!("{mode} {oid}\t").into_bytes();
494    line.extend_from_slice(path);
495    line
496}
497
498impl ports::WorkingCopy for Repo {
499    fn tracked_paths(&self) -> Result<Vec<u8>, EngineError> {
500        self.run(["ls-files", "-z"], None)
501    }
502
503    /// `diff-index --quiet HEAD --`: exit 1 means differences, which is the
504    /// answer rather than a failure — hence `run_status` instead of `run`.
505    fn has_tracked_changes(&self) -> Result<bool, EngineError> {
506        let status = self.run_status(["diff-index", "--quiet", "HEAD", "--"])?;
507        Ok(!status.success())
508    }
509
510    fn untracked_paths(&self) -> Result<Vec<u8>, EngineError> {
511        self.run(["ls-files", "--others", "--exclude-standard", "-z"], None)
512    }
513}
514
515impl ports::CommitWriter for Repo {
516    fn commit_tree(
517        &self,
518        tree: &str,
519        parent: &str,
520        message: &[u8],
521        identity: ports::CommitIdentity<'_>,
522    ) -> Result<String, EngineError> {
523        let env: [(&str, &OsStr); 4] = [
524            ("GIT_AUTHOR_NAME", OsStr::new(identity.name)),
525            ("GIT_AUTHOR_EMAIL", OsStr::new(identity.email)),
526            ("GIT_COMMITTER_NAME", OsStr::new(identity.name)),
527            ("GIT_COMMITTER_EMAIL", OsStr::new(identity.email)),
528        ];
529        let out = self.run_env(
530            ["commit-tree", tree, "-p", parent, "-F", "-"],
531            Some(message),
532            &env,
533        )?;
534        Ok(String::from_utf8_lossy(trim_newline(&out)).into_owned())
535    }
536}
537
538impl ports::RefWriter for Repo {
539    fn update_ref(&self, name: &str, target: &str) -> Result<(), EngineError> {
540        self.run(["update-ref", name, target], None).map(|_| ())
541    }
542}
543
544impl ports::CommitHistory for Repo {
545    fn has_commits(&self) -> bool {
546        self.rev_parse("HEAD").is_ok()
547    }
548
549    fn recent_commits(
550        &self,
551        from: &str,
552        max: usize,
553    ) -> Result<Vec<ports::CommitSummary>, EngineError> {
554        let raw = self.run(
555            [
556                "rev-list",
557                &format!("--max-count={max}"),
558                "--no-commit-header",
559                "--format=%H%x00%h%x00%s%x00%an",
560                from,
561            ],
562            None,
563        )?;
564        Ok(parse_rev_list(&raw))
565    }
566
567    fn refs_by_commit(&self) -> HashMap<String, Vec<String>> {
568        self.run(
569            [
570                "for-each-ref",
571                "--format=%(objectname)%00%(*objectname)%00%(refname:short)",
572                "refs/heads",
573                "refs/tags",
574                "refs/remotes",
575            ],
576            None,
577        )
578        .map(|out| parse_refs(&out))
579        .unwrap_or_default()
580    }
581}
582
583impl ports::RepoLayout for Repo {
584    fn common_dir(&self) -> Result<PathBuf, EngineError> {
585        Repo::common_dir(self)
586    }
587}
588
589/// One NUL-separated record, split into its fields.
590///
591/// Both `--format` readers below want the same thing from a line, and both
592/// wrote out the same three-line split to get it. Lossy on purpose: these are
593/// subjects, author names and ref names on their way to a screen, and the
594/// byte-exact paths never come through here.
595fn nul_fields(line: &[u8]) -> Vec<String> {
596    line.split(|&b| b == 0)
597        .map(|f| String::from_utf8_lossy(f).into_owned())
598        .collect()
599}
600
601/// `rev-list --no-commit-header --format=%H%x00%h%x00%s%x00%an` output: one
602/// record per line, fields NUL-separated (subjects are single-line by
603/// definition, so the line split is safe; bytes decode lossily).
604fn parse_rev_list(bytes: &[u8]) -> Vec<ports::CommitSummary> {
605    bytes
606        .split(|&b| b == b'\n')
607        .filter(|l| !l.is_empty())
608        .filter_map(|line| {
609            let fields = nul_fields(line);
610            match fields.as_slice() {
611                [sha, short, subject, author] => Some(ports::CommitSummary {
612                    sha: sha.clone(),
613                    short: short.clone(),
614                    subject: subject.clone(),
615                    author: author.clone(),
616                }),
617                _ => None,
618            }
619        })
620        .collect()
621}
622
623/// `for-each-ref --format='%(objectname)%00%(*objectname)%00%(refname:short)'`
624/// output → sha -> ref names. Plumbing, so unaffected by log.decorate config;
625/// annotated tags carry the peeled commit in the second field.
626///
627/// NOTE the escape: for-each-ref's format language spells NUL `%00`. `%x00`
628/// is a rev-list/log spelling and passes through as literal text here, which
629/// is exactly how this silently produced no decorations at all.
630fn parse_refs(bytes: &[u8]) -> HashMap<String, Vec<String>> {
631    let mut out: HashMap<String, Vec<String>> = HashMap::new();
632    for line in bytes.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
633        let fields = nul_fields(line);
634        let [oid, peeled, name] = fields.as_slice() else {
635            continue;
636        };
637        if name.is_empty() {
638            continue;
639        }
640        // An annotated tag's own object id is the tag; the commit it points at
641        // is the peeled one.
642        let target = if peeled.is_empty() { oid } else { peeled };
643        out.entry(target.clone()).or_default().push(name.clone());
644    }
645    out
646}
647
648/// A `cat-file --batch -z` stream: one response per spec, in the order asked.
649///
650/// Each is `<oid> SP <type> SP <size> LF <contents> LF`, or
651/// `<spec> SP "missing" LF` when the path is not there. `-z` changes only the
652/// INPUT framing, so headers are still LF-terminated.
653///
654/// Telling the two apart needs the specs, because both near-misses are real:
655///
656/// - a missing response echoes the spec, and a git path may contain a newline,
657///   so scanning for the next LF does not reliably find the end of the line;
658/// - a blob whose contents happen to END with " missing" looks, from the tail,
659///   exactly like an absent one.
660///
661/// Knowing the spec settles both: absence is an EXACT match for
662/// `<spec> " missing\n"` at the current position, and everything else must be a
663/// header — a hex oid, a type, a number — or the repository is broken, which is
664/// not the same as a file being absent and must never render as one.
665fn parse_batch_blobs(out: &[u8], specs: &[Vec<u8>]) -> Result<Vec<Option<Vec<u8>>>, String> {
666    const MISSING: &[u8] = b" missing\n";
667    let mut at = 0usize;
668    let mut got = Vec::with_capacity(specs.len());
669
670    for spec in specs {
671        let rest = out
672            .get(at..)
673            .ok_or_else(|| "output ended early".to_string())?;
674        if rest.starts_with(spec) && rest[spec.len()..].starts_with(MISSING) {
675            got.push(None);
676            at += spec.len() + MISSING.len();
677            continue;
678        }
679        let end = rest
680            .iter()
681            .position(|&b| b == b'\n')
682            .ok_or_else(|| "no header line in cat-file output".to_string())?;
683        let header = String::from_utf8_lossy(&rest[..end]).into_owned();
684        let fields: Vec<&str> = header.split(' ').collect();
685        if fields.len() != 3
686            || fields[0].is_empty()
687            || !fields[0].bytes().all(|b| b.is_ascii_hexdigit())
688        {
689            return Err(format!("unrecognised cat-file response: {header}"));
690        }
691        if fields[1] != "blob" {
692            return Err(format!("{header}: not a blob"));
693        }
694        let size: usize = fields[2]
695            .parse()
696            .map_err(|_| format!("{header}: unparsable size"))?;
697        let body = &rest[end + 1..];
698        if body.len() < size {
699            return Err(format!(
700                "{header}: body is {} bytes, header said {size}",
701                body.len()
702            ));
703        }
704        // git writes an LF after the body. Check it rather than assume it: on
705        // a malformed stream the alternative is to walk on from the wrong
706        // offset and read the next response as this one's neighbour, which
707        // desyncs everything after it without ever reporting a fault.
708        if body.get(size) != Some(&b'\n') {
709            return Err(format!("{header}: body is not LF-terminated"));
710        }
711        got.push(Some(body[..size].to_vec()));
712        // header LF + body + the LF git writes after it
713        at += end + 1 + size + 1;
714    }
715    Ok(got)
716}
717
718fn trim_newline(b: &[u8]) -> &[u8] {
719    let mut end = b.len();
720    while end > 0 && (b[end - 1] == b'\n' || b[end - 1] == b'\r') {
721        end -= 1;
722    }
723    &b[..end]
724}
725
726fn describe(cmd: &Command) -> String {
727    let mut s = String::from("git");
728    for a in cmd.get_args() {
729        s.push(' ');
730        s.push_str(&a.to_string_lossy());
731        if s.len() > 200 {
732            s.push_str(" …");
733            break;
734        }
735    }
736    s
737}
738
739#[cfg(test)]
740mod tests {
741    use super::{parse_batch_blobs, parse_refs, parse_rev_list};
742
743    /// The absent/broken distinction the old two-spawn probe existed for, now
744    /// carried by `--batch`'s own vocabulary — and read against the specs that
745    /// were sent, which is what makes both near-misses decidable.
746    #[test]
747    fn batch_blob_separates_absent_from_broken() {
748        let oid = "e".repeat(40);
749        let one = |spec: &str| vec![spec.as_bytes().to_vec()];
750
751        let found = format!("{oid} blob 6\nhello\n\n");
752        assert_eq!(
753            parse_batch_blobs(found.as_bytes(), &one("HEAD:a"))
754                .unwrap()
755                .remove(0)
756                .as_deref(),
757            Some(&b"hello\n"[..]),
758            "the body is exactly the declared size, trailing LF excluded"
759        );
760
761        // A path that is not there is absence, not failure.
762        assert_eq!(
763            parse_batch_blobs(b"HEAD:nope missing\n", &one("HEAD:nope")).unwrap(),
764            vec![None]
765        );
766        // A blob whose CONTENT ends in " missing" produces exactly the bytes an
767        // absent path produces at the end of the response.
768        let ends_missing = format!("{oid} blob 9\nx missing\n");
769        assert_eq!(
770            parse_batch_blobs(ends_missing.as_bytes(), &one("HEAD:a"))
771                .unwrap()
772                .remove(0)
773                .as_deref(),
774            Some(&b"x missing"[..]),
775            "a blob ending in \" missing\" must not be read as absent"
776        );
777        // A space in the path is not positional: the spec is matched whole.
778        assert_eq!(
779            parse_batch_blobs(b"HEAD:a b.txt missing\n", &one("HEAD:a b.txt")).unwrap(),
780            vec![None]
781        );
782        // Nor is a NEWLINE in one — which is the whole reason the input is
783        // NUL-delimited, and the reason the spec has to be matched rather than
784        // the next LF looked for.
785        assert_eq!(
786            parse_batch_blobs(b"HEAD:we\nird.txt missing\n", &one("HEAD:we\nird.txt")).unwrap(),
787            vec![None]
788        );
789
790        // A tree or a commit at that path is a broken assumption, not a file.
791        assert!(parse_batch_blobs(format!("{oid} tree 42\n").as_bytes(), &one("HEAD:a")).is_err());
792        // A truncated body must never be mistaken for a short file.
793        assert!(
794            parse_batch_blobs(format!("{oid} blob 99\nshort\n").as_bytes(), &one("HEAD:a"))
795                .is_err()
796        );
797        assert!(parse_batch_blobs(b"no newline at all", &one("HEAD:a")).is_err());
798    }
799
800    /// Many specs, one stream: each response has to be found by walking, since
801    /// only the previous one's declared size says where the next begins.
802    #[test]
803    fn batch_blobs_walks_a_stream_of_responses() {
804        let oid = "a".repeat(40);
805        let specs = vec![
806            b"HEAD:one".to_vec(),
807            b"HEAD:gone".to_vec(),
808            b"HEAD:two".to_vec(),
809        ];
810        let mut out = format!("{oid} blob 4\nabcd\n").into_bytes();
811        out.extend_from_slice(b"HEAD:gone missing\n");
812        out.extend_from_slice(format!("{oid} blob 2\nxy\n").as_bytes());
813
814        assert_eq!(
815            parse_batch_blobs(&out, &specs).unwrap(),
816            vec![Some(b"abcd".to_vec()), None, Some(b"xy".to_vec()),],
817            "answers come back in the order the specs were given"
818        );
819
820        // A stream that stops early is a broken repository, not three absences.
821        assert!(parse_batch_blobs(&out[..10], &specs).is_err());
822
823        // And one where the body is not LF-terminated where the header said it
824        // would end: walking on from there would read the next response as this
825        // one's neighbour and desync the rest without reporting a fault.
826        let mut bad = format!("{oid} blob 4\nabcd").into_bytes();
827        bad.extend_from_slice(b"XHEAD:gone missing\n");
828        assert!(parse_batch_blobs(&bad, &specs[..2]).is_err());
829    }
830
831    /// Bytes in, bytes out: a blob that is not UTF-8 survives intact.
832    #[test]
833    fn batch_blob_is_byte_faithful() {
834        let oid = "f".repeat(40);
835        let mut raw = format!("{oid} blob 4\n").into_bytes();
836        raw.extend_from_slice(&[0x00, 0xff, 0xfe, 0x0a, 0x0a]);
837        assert_eq!(
838            parse_batch_blobs(&raw, &[b"HEAD:a".to_vec()])
839                .unwrap()
840                .remove(0)
841                .unwrap(),
842            vec![0x00, 0xff, 0xfe, 0x0a]
843        );
844    }
845
846    #[test]
847    fn parses_nul_separated_records() {
848        let raw = b"aaaa\0a1\0fix the thing\0Alice\nbbbb\0b2\0subject with \xe2\x9c\x93 unicode\0B\xc3\xb6b\n";
849        let entries = parse_rev_list(raw);
850        assert_eq!(entries.len(), 2);
851        assert_eq!(entries[0].sha, "aaaa");
852        assert_eq!(entries[0].short, "a1");
853        assert_eq!(entries[0].subject, "fix the thing");
854        assert_eq!(entries[0].author, "Alice");
855        assert_eq!(entries[1].subject, "subject with ✓ unicode");
856        assert_eq!(entries[1].author, "Böb");
857    }
858
859    #[test]
860    fn tolerates_empty_and_malformed_lines() {
861        assert!(parse_rev_list(b"").is_empty());
862        assert!(parse_rev_list(b"\n\n").is_empty());
863        assert!(parse_rev_list(b"only-two\0fields\n").is_empty());
864    }
865
866    #[test]
867    fn refs_group_by_commit_and_peel_annotated_tags() {
868        // Lightweight ref: own oid is the commit. Annotated tag: the peeled
869        // field carries the commit.
870        let raw = b"aaaa\0\0main\naaaa\0\0origin/main\ntagobj\0aaaa\0v1.0\nbbbb\0\0feature\n";
871        let refs = parse_refs(raw);
872        assert_eq!(
873            refs.get("aaaa").unwrap(),
874            &vec![
875                "main".to_string(),
876                "origin/main".to_string(),
877                "v1.0".to_string()
878            ]
879        );
880        assert_eq!(refs.get("bbbb").unwrap(), &vec!["feature".to_string()]);
881        // The tag object's own id is never a key.
882        assert!(!refs.contains_key("tagobj"));
883    }
884
885    #[test]
886    fn refs_tolerate_junk() {
887        assert!(parse_refs(b"").is_empty());
888        assert!(parse_refs(b"\n\n").is_empty());
889        assert!(parse_refs(b"two\0fields\n").is_empty());
890        // An empty ref name is skipped rather than stored.
891        assert!(parse_refs(b"aaaa\0\0\n").is_empty());
892    }
893}