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