Skip to main content

smackdebt_git/
repository.rs

1//! Small, synchronous Git adapter used by the project orchestration crate.
2//!
3//! The adapter deliberately owns no source-analysis policy.  It only turns
4//! Git's structured output into values that callers can use to build a report.
5//! All commands are passed as argument vectors to `git`; refs and paths are
6//! never interpolated into a shell command.
7
8use std::collections::BTreeSet;
9use std::ffi::OsString;
10use std::fmt;
11use std::io::{self, BufRead, BufReader, Read, Write};
12use std::path::{Path, PathBuf};
13use std::process::{Child, ChildStdin, ChildStdout, Command, Output, Stdio};
14use std::sync::{
15    Arc,
16    atomic::{AtomicUsize, Ordering},
17};
18
19/// An error returned by a Git operation.
20#[derive(Debug)]
21pub enum GitError {
22    /// The path is not inside a Git worktree.
23    NotRepository(PathBuf),
24    /// The repository has no commit history.
25    EmptyHistory,
26    /// A ref or object name contained an unsafe argument.
27    UnsafeArgument(String),
28    /// Git returned a non-zero status.
29    Command {
30        /// The arguments passed to Git (excluding the executable).
31        args: Vec<OsString>,
32        /// Exit status, when Git was able to start and exit.
33        status: Option<i32>,
34        /// Git's standard error, decoded lossily for diagnostics.
35        stderr: String,
36    },
37    /// Git emitted data that did not match the requested format.
38    InvalidOutput(String),
39    /// A requested object does not exist.
40    MissingObject(String),
41    /// An I/O operation failed.
42    Io(io::Error),
43    /// Git output was not valid UTF-8 where text was required.
44    Utf8(std::string::FromUtf8Error),
45}
46
47impl fmt::Display for GitError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::NotRepository(path) => write!(f, "not a Git repository: {}", path.display()),
51            Self::EmptyHistory => write!(f, "repository has no commits"),
52            Self::UnsafeArgument(value) => write!(f, "unsafe Git argument: {value:?}"),
53            Self::Command {
54                args,
55                status,
56                stderr,
57            } => {
58                write!(f, "git command {:?} failed", args)?;
59                if let Some(status) = status {
60                    write!(f, " with status {status}")?;
61                }
62                if !stderr.trim().is_empty() {
63                    write!(f, ": {}", stderr.trim())?;
64                }
65                Ok(())
66            }
67            Self::InvalidOutput(message) => write!(f, "invalid Git output: {message}"),
68            Self::MissingObject(object) => write!(f, "Git object is missing: {object}"),
69            Self::Io(error) => error.fmt(f),
70            Self::Utf8(error) => error.fmt(f),
71        }
72    }
73}
74
75impl std::error::Error for GitError {
76    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
77        match self {
78            Self::Io(error) => Some(error),
79            Self::Utf8(error) => Some(error),
80            _ => None,
81        }
82    }
83}
84
85impl From<io::Error> for GitError {
86    fn from(error: io::Error) -> Self {
87        Self::Io(error)
88    }
89}
90
91impl From<std::string::FromUtf8Error> for GitError {
92    fn from(error: std::string::FromUtf8Error) -> Self {
93        Self::Utf8(error)
94    }
95}
96
97/// A path and its relation to the requested Git ref.
98#[derive(Debug, Clone, PartialEq, Eq)]
99struct ChangedPath {
100    /// Path at the worktree side of the change.
101    pub path: PathBuf,
102    /// Path at the base side for a rename or copy.
103    pub previous_path: Option<PathBuf>,
104    /// The two-column porcelain status.
105    pub status: ChangeStatus,
106}
107
108/// One source change between a base ref and the current worktree.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Change {
111    current_path: PathBuf,
112    base_path: PathBuf,
113    current_exists: bool,
114    base_exists: bool,
115}
116
117impl Change {
118    pub fn current_path(&self) -> &Path {
119        &self.current_path
120    }
121
122    pub fn base_path(&self) -> &Path {
123        &self.base_path
124    }
125
126    pub const fn current_exists(&self) -> bool {
127        self.current_exists
128    }
129
130    pub const fn base_exists(&self) -> bool {
131        self.base_exists
132    }
133}
134
135/// The meaningful status of one changed path.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137enum ChangeStatus {
138    Added,
139    Modified,
140    Deleted,
141    Renamed,
142    Copied,
143    TypeChanged,
144    Unmerged,
145    Untracked,
146    Ignored,
147}
148
149impl ChangeStatus {
150    fn from_code(code: u8, untracked: bool) -> Result<Self, GitError> {
151        if untracked || code == b'?' {
152            return Ok(Self::Untracked);
153        }
154        Ok(match code {
155            b'A' => Self::Added,
156            b'M' => Self::Modified,
157            b'D' => Self::Deleted,
158            b'R' => Self::Renamed,
159            b'C' => Self::Copied,
160            b'T' => Self::TypeChanged,
161            b'U' => Self::Unmerged,
162            b'!' => Self::Ignored,
163            other => {
164                return Err(GitError::InvalidOutput(format!(
165                    "unknown status code {other:?}"
166                )));
167            }
168        })
169    }
170}
171
172/// A status snapshot for the selected worktree.
173#[derive(Debug, Clone, PartialEq, Eq, Default)]
174struct WorktreeStatus {
175    pub entries: Vec<ChangedPath>,
176}
177
178/// A normalized contributor key that is intentionally opaque outside Git
179/// history aggregation.
180#[derive(Debug, Clone, Hash, PartialEq, Eq)]
181pub struct ContributorIdentity(String);
182
183/// One file change in a non-merge commit.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct HistoryChange {
186    path: PathBuf,
187    previous_path: Option<PathBuf>,
188    added_lines: Option<u32>,
189    deleted_lines: Option<u32>,
190}
191
192impl HistoryChange {
193    pub fn path(&self) -> &Path {
194        &self.path
195    }
196    pub fn previous_path(&self) -> Option<&Path> {
197        self.previous_path.as_deref()
198    }
199    pub const fn added_lines(&self) -> Option<u32> {
200        self.added_lines
201    }
202    pub const fn deleted_lines(&self) -> Option<u32> {
203        self.deleted_lines
204    }
205}
206
207/// Compact facts for one commit. The callback receiving this value finishes
208/// before the next commit is parsed.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct HistoryCommit {
211    revision: String,
212    contributor: ContributorIdentity,
213    timestamp: i64,
214    changes: Vec<HistoryChange>,
215}
216
217impl HistoryCommit {
218    /// The commit this history entry came from, as Git names it.
219    pub fn revision(&self) -> &str {
220        &self.revision
221    }
222    pub fn contributor(&self) -> &ContributorIdentity {
223        &self.contributor
224    }
225    /// The moment the commit landed on the analyzed history — the committer
226    /// date in whole seconds since the epoch — not when it was authored.
227    pub const fn timestamp(&self) -> i64 {
228        self.timestamp
229    }
230    pub fn changes(&self) -> &[HistoryChange] {
231        &self.changes
232    }
233}
234
235/// Coverage facts collected while streaming history. When a window cutoff is
236/// selected, the streamed set is the windowed set, so the commit count and the
237/// newest and oldest timestamps describe the window.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct HistoryStreamSummary {
240    revision: Option<String>,
241    commits: u32,
242    newest_timestamp: Option<i64>,
243    oldest_timestamp: Option<i64>,
244    shallow: bool,
245}
246
247impl HistoryStreamSummary {
248    pub fn revision(&self) -> Option<&str> {
249        self.revision.as_deref()
250    }
251    pub const fn commits(&self) -> u32 {
252        self.commits
253    }
254    pub const fn newest_timestamp(&self) -> Option<i64> {
255        self.newest_timestamp
256    }
257    pub const fn oldest_timestamp(&self) -> Option<i64> {
258        self.oldest_timestamp
259    }
260    pub const fn is_shallow(&self) -> bool {
261        self.shallow
262    }
263}
264
265/// A repository rooted at a discovered worktree.
266#[derive(Clone, Debug)]
267pub struct GitRepository {
268    root: PathBuf,
269    processes: Arc<AtomicUsize>,
270}
271
272impl GitRepository {
273    /// Discover the worktree containing `path`.
274    pub fn discover(path: impl AsRef<Path>) -> Result<Self, GitError> {
275        let path = path.as_ref();
276        let probe = if path.is_file() {
277            path.parent().unwrap_or(path)
278        } else {
279            path
280        };
281        let output = run_git_counted(
282            probe,
283            [
284                OsString::from("rev-parse"),
285                OsString::from("--show-toplevel"),
286            ],
287            None,
288        )?;
289        if !output.status.success() {
290            return Err(GitError::NotRepository(path.to_path_buf()));
291        }
292        let root = PathBuf::from(String::from_utf8(output.stdout)?.trim());
293        if root.as_os_str().is_empty() {
294            return Err(GitError::NotRepository(path.to_path_buf()));
295        }
296        Ok(Self {
297            root,
298            processes: Arc::new(AtomicUsize::new(1)),
299        })
300    }
301
302    pub fn root(&self) -> &Path {
303        &self.root
304    }
305
306    pub fn git_processes(&self) -> usize {
307        self.processes.load(Ordering::Relaxed)
308    }
309
310    /// Return the current branch's upstream/default ref when available.
311    ///
312    /// Detached repositories can still use a local `main` or `master` ref;
313    /// absent refs return `None` instead of inventing a comparison base.
314    pub fn default_ref(&self) -> Result<Option<String>, GitError> {
315        if let Ok(value) = self.run_text([
316            OsString::from("symbolic-ref"),
317            OsString::from("--quiet"),
318            OsString::from("--short"),
319            OsString::from("refs/remotes/origin/HEAD"),
320        ]) {
321            let value = value.trim();
322            if !value.is_empty() {
323                return Ok(Some(value.to_owned()));
324            }
325        }
326        for candidate in ["origin/main", "origin/master", "main", "master"] {
327            if self.ref_exists(candidate)? {
328                return Ok(Some(candidate.to_owned()));
329            }
330        }
331        Ok(None)
332    }
333
334    fn ref_exists(&self, reference: &str) -> Result<bool, GitError> {
335        validate_ref(reference)?;
336        let args = vec![
337            OsString::from("rev-parse"),
338            OsString::from("--verify"),
339            OsString::from("--quiet"),
340            OsString::from(format!("{}^{{commit}}", reference)),
341        ];
342        let output = self.run_raw(args)?;
343        Ok(output.status.success())
344    }
345
346    /// The commits this worktree holds that the base does not.
347    ///
348    /// A diff asks what a change did, and the change is exactly this set: the
349    /// history before it is the history without them. The answer is a set of
350    /// revisions rather than a count, so a streamed commit can say which side
351    /// of the change it belongs to.
352    pub fn commits_ahead(&self, base: &str) -> Result<BTreeSet<String>, GitError> {
353        validate_ref(base)?;
354        let value = self.run_text([
355            OsString::from("rev-list"),
356            OsString::from(format!("{base}..HEAD")),
357        ])?;
358        Ok(value.split_whitespace().map(str::to_owned).collect())
359    }
360
361    pub fn merge_base(&self, left: &str, right: &str) -> Result<String, GitError> {
362        validate_ref(left)?;
363        validate_ref(right)?;
364        let value = self.run_text([
365            OsString::from("merge-base"),
366            OsString::from(left),
367            OsString::from(right),
368        ])?;
369        let value = value.trim();
370        if value.is_empty() {
371            return Err(GitError::InvalidOutput(
372                "merge-base returned no object".into(),
373            ));
374        }
375        Ok(value.to_owned())
376    }
377
378    /// Read worktree status, including all non-ignored untracked files.
379    fn status(&self) -> Result<WorktreeStatus, GitError> {
380        let output = self.run_raw(vec![
381            OsString::from("status"),
382            OsString::from("--porcelain=v1"),
383            OsString::from("-z"),
384            OsString::from("--untracked-files=all"),
385            OsString::from("--renames"),
386        ])?;
387        if !output.status.success() {
388            return Err(command_error(&output, vec![]));
389        }
390        Ok(WorktreeStatus {
391            entries: parse_status_z(&output.stdout)?,
392        })
393    }
394
395    /// Return base-to-worktree changes. The base is passed as a ref, never a
396    /// shell fragment, and `--` makes the path boundary explicit.
397    fn changed_paths(&self, base: &str) -> Result<Vec<ChangedPath>, GitError> {
398        validate_ref(base)?;
399        let output = self.run_raw(vec![
400            OsString::from("diff"),
401            OsString::from("--name-status"),
402            OsString::from("-z"),
403            OsString::from("-M"),
404            OsString::from(base),
405            OsString::from("--"),
406        ])?;
407        if !output.status.success() {
408            return Err(command_error(&output, vec![]));
409        }
410        parse_name_status_z(&output.stdout)
411    }
412
413    /// Returns the sorted, unique source changes visible from a base ref.
414    pub fn changes_from(&self, base: &str) -> Result<Vec<Change>, GitError> {
415        let mut changed = self.changed_paths(base)?;
416        for entry in self.status()?.entries {
417            if entry.status == ChangeStatus::Untracked
418                && !changed.iter().any(|known| known.path == entry.path)
419            {
420                changed.push(entry);
421            }
422        }
423        changed.sort_by(|left, right| left.path.cmp(&right.path));
424        changed.dedup_by(|left, right| left.path == right.path);
425        Ok(changed
426            .into_iter()
427            .map(|entry| {
428                let current_exists = entry.status != ChangeStatus::Deleted;
429                let base_exists =
430                    !matches!(entry.status, ChangeStatus::Added | ChangeStatus::Untracked);
431                let base_path = entry.previous_path.unwrap_or_else(|| entry.path.clone());
432                Change {
433                    current_path: entry.path,
434                    base_path,
435                    current_exists,
436                    base_exists,
437                }
438            })
439            .collect())
440    }
441
442    /// Stream non-merge history through one Git process. When `since` selects
443    /// a window cutoff, the filter runs inside the Git process itself, so
444    /// commits that landed before the cutoff are never streamed; both the
445    /// process filter and each commit's `timestamp` describe the landed
446    /// (committer) date, so the two can never disagree. Only one commit is
447    /// retained by this adapter at a time.
448    pub fn stream_history(
449        &self,
450        since: Option<i64>,
451        mut accept: impl FnMut(HistoryCommit) -> Result<(), GitError>,
452    ) -> Result<HistoryStreamSummary, GitError> {
453        let mut args = vec![
454            OsString::from("log"),
455            OsString::from("--no-merges"),
456            OsString::from("--numstat"),
457            OsString::from("--format=%x1e%H%x00%aN%x00%aE%x00%ct%x00"),
458            OsString::from("-z"),
459            OsString::from("--find-renames"),
460            OsString::from("--use-mailmap"),
461        ];
462        if let Some(cutoff) = since {
463            // Git's bare `@<epoch>` date form silently falls back to "now" for
464            // small or negative numbers, which would empty the stream. The
465            // internal `@<epoch> <offset>` form parses every non-negative
466            // cutoff, and clamping is exact: no commit landed before the epoch.
467            args.push(OsString::from(format!("--since=@{} +0000", cutoff.max(0))));
468        }
469        args.push(OsString::from("--"));
470        let mut command = self.command(args.clone());
471        let mut child = command
472            .stdout(Stdio::piped())
473            .stderr(Stdio::piped())
474            .spawn()
475            .map_err(GitError::Io)?;
476        #[cfg(feature = "evidence-stats")]
477        crate::evidence::record_process();
478        let stdout = child
479            .stdout
480            .take()
481            .ok_or_else(|| GitError::InvalidOutput("history stdout was not piped".into()))?;
482        let stderr = child
483            .stderr
484            .take()
485            .ok_or_else(|| GitError::InvalidOutput("history stderr was not piped".into()))?;
486        let mut reader = ZeroReader::new(stdout);
487        let mut stderr_reader = BufReader::new(stderr);
488        let shallow = self.root.join(".git/shallow").is_file();
489        let mut history = HistoryParser::default();
490        let read_result = reader.read_tokens(|token| history.accept(token, &mut accept));
491        let status = if read_result.is_err() {
492            let _ = child.kill();
493            child.wait()?
494        } else {
495            child.wait()?
496        };
497        self.processes.fetch_add(1, Ordering::Relaxed);
498        read_result?;
499        if !status.success() {
500            let mut message = String::new();
501            stderr_reader.read_to_string(&mut message)?;
502            if message.contains("does not have any commits")
503                || message.contains("does not have any commits yet")
504            {
505                return Err(GitError::EmptyHistory);
506            }
507            return Err(GitError::Command {
508                args,
509                status: status.code(),
510                stderr: message,
511            });
512        }
513        history.finish(&mut accept, shallow)
514    }
515
516    /// Start one `git cat-file --batch` process. Keep this reader alive across
517    /// requests to avoid one process per changed file.
518    pub fn object_reader(&self, max_pending: usize) -> Result<ObjectReader, GitError> {
519        ObjectReader::start(self.root.clone(), self.processes.clone(), max_pending)
520    }
521
522    fn run_text<I>(&self, args: I) -> Result<String, GitError>
523    where
524        I: IntoIterator<Item = OsString>,
525    {
526        let output = self.run_raw(args.into_iter().collect())?;
527        if !output.status.success() {
528            return Err(command_error(&output, Vec::new()));
529        }
530        Ok(String::from_utf8(output.stdout)?)
531    }
532
533    fn run_raw(&self, args: Vec<OsString>) -> Result<Output, GitError> {
534        let output = self.command(args.clone()).output()?;
535        #[cfg(feature = "evidence-stats")]
536        crate::evidence::record_process();
537        self.processes.fetch_add(1, Ordering::Relaxed);
538        Ok(output)
539    }
540
541    fn command(&self, args: Vec<OsString>) -> Command {
542        let mut command = Command::new("git");
543        command
544            .args(args)
545            .current_dir(&self.root)
546            .env("LC_ALL", "C");
547        command
548    }
549}
550
551fn run_git_counted(
552    root: &Path,
553    args: impl IntoIterator<Item = OsString>,
554    _counter: Option<&AtomicUsize>,
555) -> Result<Output, GitError> {
556    let args: Vec<OsString> = args.into_iter().collect();
557    let output = Command::new("git").args(&args).current_dir(root).output()?;
558    #[cfg(feature = "evidence-stats")]
559    crate::evidence::record_process();
560    Ok(output)
561}
562
563fn command_error(output: &Output, args: Vec<OsString>) -> GitError {
564    GitError::Command {
565        args,
566        status: output.status.code(),
567        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
568    }
569}
570
571fn validate_ref(reference: &str) -> Result<(), GitError> {
572    if reference.is_empty()
573        || reference.starts_with('-')
574        || reference
575            .bytes()
576            .any(|byte| byte == 0 || byte.is_ascii_control() || byte == b' ')
577    {
578        return Err(GitError::UnsafeArgument(reference.to_owned()));
579    }
580    // Let Git's own ref checker reject malformed names, while keeping command
581    // arguments safe before invoking it. The adapter never treats a ref as a
582    // path or as shell input.
583    Ok(())
584}
585
586fn parse_status_z(bytes: &[u8]) -> Result<Vec<ChangedPath>, GitError> {
587    let mut chunks = bytes.split(|byte| *byte == 0);
588    let mut result = Vec::new();
589    while let Some(raw) = chunks.next() {
590        if raw.is_empty() {
591            continue;
592        }
593        if raw.len() < 3 || raw[2] != b' ' {
594            return Err(GitError::InvalidOutput(
595                "status record lacks XY separator".into(),
596            ));
597        }
598        let x = raw[0];
599        let y = raw[1];
600        let status_code = if x == b'?' || y == b'?' {
601            b'?'
602        } else if x == b' ' {
603            y
604        } else {
605            x
606        };
607        let path = PathBuf::from(String::from_utf8(raw[3..].to_vec())?);
608        let status = ChangeStatus::from_code(status_code, status_code == b'?')?;
609        let previous_path = if matches!(status, ChangeStatus::Renamed | ChangeStatus::Copied) {
610            let next = chunks
611                .next()
612                .ok_or_else(|| GitError::InvalidOutput("rename record lacks old path".into()))?;
613            Some(PathBuf::from(String::from_utf8(next.to_vec())?))
614        } else {
615            None
616        };
617        result.push(ChangedPath {
618            path,
619            previous_path,
620            status,
621        });
622    }
623    Ok(result)
624}
625
626fn parse_name_status_z(bytes: &[u8]) -> Result<Vec<ChangedPath>, GitError> {
627    let mut chunks = bytes.split(|byte| *byte == 0);
628    let mut result = Vec::new();
629    while let Some(raw) = chunks.next() {
630        if raw.is_empty() {
631            continue;
632        }
633        let (status_bytes, inline_path) = match raw.iter().position(|byte| *byte == b'\t') {
634            Some(tab) => (&raw[..tab], Some(&raw[tab + 1..])),
635            None => (raw, None),
636        };
637        let code = status_bytes
638            .first()
639            .copied()
640            .ok_or_else(|| GitError::InvalidOutput("empty name-status code".into()))?;
641        let status = ChangeStatus::from_code(code, false)?;
642        let first = if let Some(path) = inline_path {
643            PathBuf::from(String::from_utf8(path.to_vec())?)
644        } else {
645            let path = chunks
646                .next()
647                .ok_or_else(|| GitError::InvalidOutput("name-status lacks path".into()))?;
648            PathBuf::from(String::from_utf8(path.to_vec())?)
649        };
650        let (path, previous_path) =
651            if matches!(status, ChangeStatus::Renamed | ChangeStatus::Copied) {
652                let second = chunks
653                    .next()
654                    .ok_or_else(|| GitError::InvalidOutput("rename lacks destination".into()))?;
655                (
656                    PathBuf::from(String::from_utf8(second.to_vec())?),
657                    Some(first),
658                )
659            } else {
660                (first, None)
661            };
662        result.push(ChangedPath {
663            path,
664            previous_path,
665            status,
666        });
667    }
668    Ok(result)
669}
670
671#[derive(Default)]
672struct HistoryParser {
673    header: Vec<Vec<u8>>,
674    current: Option<PendingCommit>,
675    pending_rename: Option<PendingRename>,
676    revision: Option<String>,
677    commits: u32,
678    newest_timestamp: Option<i64>,
679    oldest_timestamp: Option<i64>,
680}
681
682struct PendingCommit {
683    revision: String,
684    contributor: ContributorIdentity,
685    timestamp: i64,
686    changes: Vec<HistoryChange>,
687}
688
689struct PendingRename {
690    added_lines: Option<u32>,
691    deleted_lines: Option<u32>,
692    old_path: Option<PathBuf>,
693}
694
695impl HistoryParser {
696    fn accept(
697        &mut self,
698        raw: &[u8],
699        accept: &mut impl FnMut(HistoryCommit) -> Result<(), GitError>,
700    ) -> Result<(), GitError> {
701        if raw.starts_with(&[0x1e]) {
702            self.flush(accept)?;
703            if self.pending_rename.is_some() {
704                return Err(GitError::InvalidOutput("rename record lacks a path".into()));
705            }
706            self.header.clear();
707            self.header.push(raw[1..].to_vec());
708            return Ok(());
709        }
710        if !self.header.is_empty() && self.header.len() < 4 {
711            self.header.push(raw.to_vec());
712            if self.header.len() == 4 {
713                let revision = String::from_utf8(self.header[0].clone())?;
714                if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) {
715                    return Err(GitError::InvalidOutput(
716                        "history commit id is malformed".into(),
717                    ));
718                }
719                let timestamp = String::from_utf8(self.header[3].clone())?
720                    .parse::<i64>()
721                    .map_err(|_| {
722                        GitError::InvalidOutput("history timestamp is malformed".into())
723                    })?;
724                let mut identity = self.header[1].clone();
725                identity.push(0);
726                identity.extend_from_slice(&self.header[2]);
727                if self.revision.is_none() {
728                    self.revision = Some(revision.clone());
729                    self.newest_timestamp = Some(timestamp);
730                }
731                self.oldest_timestamp = Some(timestamp);
732                self.current = Some(PendingCommit {
733                    revision,
734                    contributor: ContributorIdentity(String::from_utf8(identity)?),
735                    timestamp,
736                    changes: Vec::new(),
737                });
738            }
739            return Ok(());
740        }
741        let token = raw.strip_prefix(b"\n").unwrap_or(raw);
742        if token.is_empty() {
743            return Ok(());
744        }
745        let current = self.current.as_mut().ok_or_else(|| {
746            GitError::InvalidOutput("history change appeared before commit header".into())
747        })?;
748        if let Some(mut rename) = self.pending_rename.take() {
749            let path = PathBuf::from(String::from_utf8(token.to_vec())?);
750            if rename.old_path.is_none() {
751                rename.old_path = Some(path);
752                self.pending_rename = Some(rename);
753            } else {
754                current.changes.push(HistoryChange {
755                    path,
756                    previous_path: rename.old_path,
757                    added_lines: rename.added_lines,
758                    deleted_lines: rename.deleted_lines,
759                });
760            }
761            return Ok(());
762        }
763        let mut fields = token.splitn(3, |byte| *byte == b'\t');
764        let added = parse_numstat_count(fields.next())?;
765        let deleted = parse_numstat_count(fields.next())?;
766        let path = fields
767            .next()
768            .ok_or_else(|| GitError::InvalidOutput("history numstat lacks a path".into()))?;
769        if path.is_empty() {
770            self.pending_rename = Some(PendingRename {
771                added_lines: added,
772                deleted_lines: deleted,
773                old_path: None,
774            });
775        } else {
776            current.changes.push(HistoryChange {
777                path: PathBuf::from(String::from_utf8(path.to_vec())?),
778                previous_path: None,
779                added_lines: added,
780                deleted_lines: deleted,
781            });
782        }
783        Ok(())
784    }
785
786    fn flush(
787        &mut self,
788        accept: &mut impl FnMut(HistoryCommit) -> Result<(), GitError>,
789    ) -> Result<(), GitError> {
790        if let Some(rename) = self.pending_rename.take() {
791            return Err(GitError::InvalidOutput(if rename.old_path.is_some() {
792                "rename record lacks new path".into()
793            } else {
794                "rename record lacks old path".into()
795            }));
796        }
797        if let Some(commit) = self.current.take() {
798            self.commits += 1;
799            accept(HistoryCommit {
800                revision: commit.revision,
801                contributor: commit.contributor,
802                timestamp: commit.timestamp,
803                changes: commit.changes,
804            })?;
805        }
806        Ok(())
807    }
808
809    fn finish(
810        mut self,
811        accept: &mut impl FnMut(HistoryCommit) -> Result<(), GitError>,
812        shallow: bool,
813    ) -> Result<HistoryStreamSummary, GitError> {
814        self.flush(accept)?;
815        Ok(HistoryStreamSummary {
816            revision: self.revision,
817            commits: self.commits,
818            newest_timestamp: self.newest_timestamp,
819            oldest_timestamp: self.oldest_timestamp,
820            shallow,
821        })
822    }
823}
824
825fn parse_numstat_count(raw: Option<&[u8]>) -> Result<Option<u32>, GitError> {
826    let raw = raw.ok_or_else(|| GitError::InvalidOutput("history numstat is incomplete".into()))?;
827    if raw == b"-" {
828        return Ok(None);
829    }
830    String::from_utf8(raw.to_vec())?
831        .parse()
832        .map(Some)
833        .map_err(|_| GitError::InvalidOutput("history line count is malformed".into()))
834}
835
836/// A single long-lived `git cat-file --batch` reader.
837pub struct ObjectReader {
838    child: Child,
839    stdin: ChildStdin,
840    stdout: BufReader<ChildStdout>,
841    max_pending: usize,
842    pending: usize,
843    processes: Arc<AtomicUsize>,
844}
845
846impl fmt::Debug for ObjectReader {
847    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
848        f.debug_struct("BatchObjectReader")
849            .field("max_pending", &self.max_pending)
850            .field("pending", &self.pending)
851            .finish_non_exhaustive()
852    }
853}
854
855impl ObjectReader {
856    fn start(
857        root: PathBuf,
858        processes: Arc<AtomicUsize>,
859        max_pending: usize,
860    ) -> Result<Self, GitError> {
861        let max_pending = max_pending.max(1);
862        let mut command = Command::new("git");
863        command
864            .args(["cat-file", "--batch"])
865            .current_dir(root)
866            .stdin(Stdio::piped())
867            .stdout(Stdio::piped())
868            .stderr(Stdio::piped());
869        let mut child = command.spawn()?;
870        #[cfg(feature = "evidence-stats")]
871        crate::evidence::record_process();
872        let stdin = child
873            .stdin
874            .take()
875            .ok_or_else(|| GitError::InvalidOutput("cat-file stdin was not piped".into()))?;
876        let stdout = child
877            .stdout
878            .take()
879            .ok_or_else(|| GitError::InvalidOutput("cat-file stdout was not piped".into()))?;
880        processes.fetch_add(1, Ordering::Relaxed);
881        Ok(Self {
882            child,
883            stdin,
884            stdout: BufReader::new(stdout),
885            max_pending,
886            pending: 0,
887            processes,
888        })
889    }
890
891    /// Request one object and synchronously receive its bytes. `max_pending`
892    /// remains part of the API so a future project producer can queue up to a
893    /// measured limit without changing the process contract.
894    fn read_object(&mut self, object: &str) -> Result<Vec<u8>, GitError> {
895        validate_object(object)?;
896        #[cfg(feature = "evidence-stats")]
897        crate::evidence::record_object_read();
898        if self.pending >= self.max_pending {
899            self.pending = 0;
900        }
901        self.stdin.write_all(object.as_bytes())?;
902        self.stdin.write_all(b"\n")?;
903        self.stdin.flush()?;
904        self.pending += 1;
905        let mut header = Vec::new();
906        self.stdout.read_until(b'\n', &mut header)?;
907        if header.is_empty() {
908            return Err(GitError::InvalidOutput(
909                "cat-file ended before header".into(),
910            ));
911        }
912        let header = String::from_utf8(header)?;
913        let fields: Vec<_> = header.trim_end_matches('\n').split(' ').collect();
914        if fields.len() >= 2 && fields[1] == "missing" {
915            return Err(GitError::MissingObject(object.to_owned()));
916        }
917        if fields.len() != 3 {
918            return Err(GitError::InvalidOutput(format!(
919                "invalid cat-file header: {header:?}"
920            )));
921        }
922        let size: usize = fields[2]
923            .parse()
924            .map_err(|_| GitError::InvalidOutput("invalid cat-file object size".into()))?;
925        let mut bytes = vec![0; size];
926        self.stdout.read_exact(&mut bytes)?;
927        let mut newline = [0u8; 1];
928        self.stdout.read_exact(&mut newline)?;
929        if newline[0] != b'\n' {
930            return Err(GitError::InvalidOutput(
931                "cat-file object lacked separator".into(),
932            ));
933        }
934        Ok(bytes)
935    }
936
937    /// Read a path from a commit without spawning another Git process.
938    pub fn read_path(&mut self, reference: &str, path: &Path) -> Result<Vec<u8>, GitError> {
939        validate_ref(reference)?;
940        validate_path(path)?;
941        let path = path
942            .to_str()
943            .ok_or_else(|| GitError::UnsafeArgument(path.to_string_lossy().into_owned()))?;
944        self.read_object(&format!("{reference}:{path}"))
945    }
946
947    /// Lists regular files in one commit tree through this batch process.
948    pub fn tree_files(&mut self, reference: &str) -> Result<Vec<PathBuf>, GitError> {
949        validate_ref(reference)?;
950        let root = self.read_object(&format!("{reference}^{{tree}}"))?;
951        let mut files = Vec::new();
952        self.collect_tree_files(&root, Path::new(""), &mut files)?;
953        files.sort();
954        Ok(files)
955    }
956
957    fn collect_tree_files(
958        &mut self,
959        tree: &[u8],
960        parent: &Path,
961        files: &mut Vec<PathBuf>,
962    ) -> Result<(), GitError> {
963        let mut offset = 0;
964        while offset < tree.len() {
965            let mode_end = tree[offset..]
966                .iter()
967                .position(|byte| *byte == b' ')
968                .map(|position| offset + position)
969                .ok_or_else(|| GitError::InvalidOutput("tree entry lacks mode".into()))?;
970            let name_start = mode_end + 1;
971            let name_end = tree[name_start..]
972                .iter()
973                .position(|byte| *byte == 0)
974                .map(|position| name_start + position)
975                .ok_or_else(|| GitError::InvalidOutput("tree entry lacks name".into()))?;
976            let oid_start = name_end + 1;
977            let oid_end = oid_start + 20;
978            let oid = tree
979                .get(oid_start..oid_end)
980                .ok_or_else(|| GitError::InvalidOutput("tree entry lacks object id".into()))?;
981            let name = std::str::from_utf8(&tree[name_start..name_end])
982                .map_err(|_| GitError::InvalidOutput("tree path is not UTF-8".into()))?;
983            let path = parent.join(name);
984            let mode = &tree[offset..mode_end];
985            if mode == b"40000" {
986                let child = self.read_object(&hex_object_id(oid))?;
987                self.collect_tree_files(&child, &path, files)?;
988            } else if mode == b"100644" || mode == b"100755" {
989                files.push(path);
990            }
991            offset = oid_end;
992        }
993        Ok(())
994    }
995}
996
997fn hex_object_id(bytes: &[u8]) -> String {
998    const HEX: &[u8; 16] = b"0123456789abcdef";
999    let mut result = String::with_capacity(bytes.len() * 2);
1000    for byte in bytes {
1001        result.push(HEX[(byte >> 4) as usize] as char);
1002        result.push(HEX[(byte & 0x0f) as usize] as char);
1003    }
1004    result
1005}
1006
1007impl Drop for ObjectReader {
1008    fn drop(&mut self) {
1009        let _ = self.stdin.flush();
1010        let _ = self.child.kill();
1011        let _ = self.child.wait();
1012        let _ = &self.processes;
1013    }
1014}
1015
1016fn validate_object(object: &str) -> Result<(), GitError> {
1017    if object.is_empty() || object.bytes().any(|byte| matches!(byte, 0 | b'\n' | b'\r')) {
1018        Err(GitError::UnsafeArgument(object.to_owned()))
1019    } else {
1020        Ok(())
1021    }
1022}
1023
1024fn validate_path(path: &Path) -> Result<(), GitError> {
1025    if path.as_os_str().is_empty()
1026        || path.is_absolute()
1027        || path.components().any(|component| {
1028            matches!(
1029                component,
1030                std::path::Component::ParentDir | std::path::Component::RootDir
1031            )
1032        })
1033        || path
1034            .as_os_str()
1035            .to_string_lossy()
1036            .bytes()
1037            .any(|byte| matches!(byte, 0 | b'\n' | b'\r'))
1038    {
1039        return Err(GitError::UnsafeArgument(
1040            path.to_string_lossy().into_owned(),
1041        ));
1042    }
1043    Ok(())
1044}
1045
1046struct ZeroReader<R> {
1047    reader: R,
1048    buffer: Vec<u8>,
1049}
1050
1051impl<R: Read> ZeroReader<R> {
1052    fn new(reader: R) -> Self {
1053        Self {
1054            reader,
1055            buffer: vec![0; 16 * 1024],
1056        }
1057    }
1058    fn read_tokens(
1059        &mut self,
1060        mut callback: impl FnMut(&[u8]) -> Result<(), GitError>,
1061    ) -> Result<(), GitError> {
1062        let mut token = Vec::new();
1063        loop {
1064            let size = self.reader.read(&mut self.buffer)?;
1065            if size == 0 {
1066                if !token.is_empty() {
1067                    callback(&token)?;
1068                }
1069                return Ok(());
1070            }
1071            for byte in &self.buffer[..size] {
1072                if *byte == 0 {
1073                    callback(&token)?;
1074                    token.clear();
1075                } else {
1076                    token.push(*byte);
1077                }
1078            }
1079        }
1080    }
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085    use super::*;
1086    use std::fs::{self, File};
1087    use std::io::Write;
1088    use std::sync::atomic::{AtomicU64, Ordering};
1089
1090    static NEXT: AtomicU64 = AtomicU64::new(0);
1091
1092    struct Repo {
1093        path: PathBuf,
1094    }
1095    impl Repo {
1096        fn new() -> Self {
1097            let path = std::env::temp_dir().join(format!(
1098                "smackdebt-git-{}-{}",
1099                std::process::id(),
1100                NEXT.fetch_add(1, Ordering::Relaxed)
1101            ));
1102            fs::create_dir_all(&path).unwrap();
1103            git(&path, ["init", "-q"]);
1104            git(&path, ["config", "user.email", "test@example.com"]);
1105            git(&path, ["config", "user.name", "Test"]);
1106            Self { path }
1107        }
1108        fn commit(&self, path: &str, body: &str, message: &str) {
1109            let full = self.path.join(path);
1110            if let Some(parent) = full.parent() {
1111                fs::create_dir_all(parent).unwrap();
1112            }
1113            File::create(full)
1114                .unwrap()
1115                .write_all(body.as_bytes())
1116                .unwrap();
1117            git(&self.path, ["add", "."]);
1118            git(&self.path, ["commit", "-qm", message]);
1119        }
1120        fn commit_dated(
1121            &self,
1122            path: &str,
1123            body: &str,
1124            message: &str,
1125            authored: &str,
1126            landed: &str,
1127        ) {
1128            let full = self.path.join(path);
1129            File::create(full)
1130                .unwrap()
1131                .write_all(body.as_bytes())
1132                .unwrap();
1133            git(&self.path, ["add", "."]);
1134            let output = Command::new("git")
1135                .args(["commit", "-qm", message])
1136                .env("GIT_AUTHOR_DATE", authored)
1137                .env("GIT_COMMITTER_DATE", landed)
1138                .current_dir(&self.path)
1139                .output()
1140                .unwrap();
1141            assert!(
1142                output.status.success(),
1143                "git failed: {}",
1144                String::from_utf8_lossy(&output.stderr)
1145            );
1146        }
1147    }
1148    impl Drop for Repo {
1149        fn drop(&mut self) {
1150            let _ = fs::remove_dir_all(&self.path);
1151        }
1152    }
1153
1154    fn git<const N: usize>(cwd: &Path, args: [&str; N]) {
1155        let output = Command::new("git")
1156            .args(args)
1157            .current_dir(cwd)
1158            .output()
1159            .unwrap();
1160        assert!(
1161            output.status.success(),
1162            "git failed: {}",
1163            String::from_utf8_lossy(&output.stderr)
1164        );
1165    }
1166
1167    #[test]
1168    fn discovers_root_and_default_branch() {
1169        let repo = Repo::new();
1170        repo.commit("main.rs", "fn main() {}", "initial");
1171        let adapter = GitRepository::discover(repo.path.join("main.rs")).unwrap();
1172        assert!(adapter.root().join("main.rs").exists());
1173        assert_eq!(adapter.default_ref().unwrap().as_deref(), Some("master"));
1174    }
1175
1176    #[test]
1177    fn repository_without_a_default_ref_reports_none() {
1178        let repo = Repo::new();
1179        let adapter = GitRepository::discover(&repo.path).unwrap();
1180        assert_eq!(adapter.default_ref().unwrap(), None);
1181    }
1182
1183    #[test]
1184    fn empty_repository_has_unavailable_history_from_the_history_process() {
1185        let repo = Repo::new();
1186        let adapter = GitRepository::discover(&repo.path).unwrap();
1187        let before = adapter.git_processes();
1188        assert!(matches!(
1189            adapter.stream_history(None, |_| Ok(())),
1190            Err(GitError::EmptyHistory)
1191        ));
1192        assert_eq!(adapter.git_processes() - before, 1);
1193    }
1194
1195    #[test]
1196    fn status_includes_untracked_and_rename() {
1197        let repo = Repo::new();
1198        repo.commit("old.rs", "one", "initial");
1199        fs::rename(repo.path.join("old.rs"), repo.path.join("new.rs")).unwrap();
1200        git(&repo.path, ["add", "-A"]);
1201        File::create(repo.path.join("scratch.rs")).unwrap();
1202        let adapter = GitRepository::discover(&repo.path).unwrap();
1203        let status = adapter.status().unwrap();
1204        assert!(
1205            status
1206                .entries
1207                .iter()
1208                .any(|entry| entry.path == Path::new("scratch.rs")
1209                    && entry.status == ChangeStatus::Untracked)
1210        );
1211        assert!(
1212            status
1213                .entries
1214                .iter()
1215                .any(|entry| entry.path == Path::new("new.rs")
1216                    && entry.status == ChangeStatus::Renamed),
1217            "entries: {:?}",
1218            status.entries
1219        );
1220    }
1221
1222    #[test]
1223    fn changed_paths_parses_nul_delimited_statuses_and_renames() {
1224        let repo = Repo::new();
1225        repo.commit("old.rs", "one", "initial");
1226        fs::rename(repo.path.join("old.rs"), repo.path.join("new.rs")).unwrap();
1227        git(&repo.path, ["add", "-A"]);
1228        let adapter = GitRepository::discover(&repo.path).unwrap();
1229        let changes = adapter.changed_paths("HEAD").unwrap();
1230        assert_eq!(changes.len(), 1);
1231        assert_eq!(changes[0].status, ChangeStatus::Renamed);
1232        assert_eq!(changes[0].path, Path::new("new.rs"));
1233        assert_eq!(
1234            changes[0].previous_path.as_deref(),
1235            Some(Path::new("old.rs"))
1236        );
1237    }
1238
1239    #[test]
1240    fn history_uses_one_process_and_streams_churn() {
1241        let repo = Repo::new();
1242        repo.commit("a.rs", "one", "one");
1243        repo.commit("a.rs", "two\nthree", "two");
1244        let adapter = GitRepository::discover(&repo.path).unwrap();
1245        let before = adapter.git_processes();
1246        let mut commits = Vec::new();
1247        let summary = adapter
1248            .stream_history(None, |commit| {
1249                commits.push(commit);
1250                Ok(())
1251            })
1252            .unwrap();
1253        assert_eq!(adapter.git_processes() - before, 1);
1254        assert_eq!(summary.commits(), 2);
1255        assert_eq!(commits.len(), 2);
1256        assert_eq!(commits[0].changes()[0].path(), Path::new("a.rs"));
1257        assert_eq!(commits[0].changes()[0].added_lines(), Some(2));
1258        assert_eq!(commits[0].changes()[0].deleted_lines(), Some(1));
1259    }
1260
1261    #[test]
1262    fn a_history_cutoff_filters_inside_the_git_process_on_landed_dates() {
1263        let repo = Repo::new();
1264        repo.commit_dated(
1265            "a.rs",
1266            "one",
1267            "old",
1268            "2000-01-02T03:04:05Z",
1269            "2000-01-02T03:04:05Z",
1270        );
1271        repo.commit_dated(
1272            "a.rs",
1273            "two",
1274            "recent",
1275            "2020-01-02T03:04:05Z",
1276            "2020-01-02T03:04:05Z",
1277        );
1278        let adapter = GitRepository::discover(&repo.path).unwrap();
1279        let mut unbounded = 0u32;
1280        let summary = adapter
1281            .stream_history(None, |_| {
1282                unbounded += 1;
1283                Ok(())
1284            })
1285            .unwrap();
1286        assert_eq!((summary.commits(), unbounded), (2, 2));
1287        let cutoff = 1_500_000_000;
1288        let mut windowed = Vec::new();
1289        let summary = adapter
1290            .stream_history(Some(cutoff), |commit| {
1291                windowed.push(commit);
1292                Ok(())
1293            })
1294            .unwrap();
1295        // The out-of-window commit was never streamed, and the summary
1296        // describes the windowed set.
1297        assert_eq!((summary.commits(), windowed.len()), (1, 1));
1298        assert!(windowed[0].timestamp() >= cutoff);
1299        assert_eq!(summary.newest_timestamp(), Some(windowed[0].timestamp()));
1300        assert_eq!(summary.oldest_timestamp(), Some(windowed[0].timestamp()));
1301    }
1302
1303    #[test]
1304    fn a_rebased_commit_counts_by_when_it_landed_not_when_it_was_authored() {
1305        let repo = Repo::new();
1306        repo.commit_dated(
1307            "a.rs",
1308            "one",
1309            "authored long ago, landed recently",
1310            "2000-01-02T03:04:05Z",
1311            "2020-01-02T03:04:05Z",
1312        );
1313        let adapter = GitRepository::discover(&repo.path).unwrap();
1314        let mut commits = Vec::new();
1315        let summary = adapter
1316            .stream_history(Some(1_500_000_000), |commit| {
1317                commits.push(commit);
1318                Ok(())
1319            })
1320            .unwrap();
1321        assert_eq!(summary.commits(), 1);
1322        assert_eq!(commits[0].timestamp(), 1_577_934_245);
1323    }
1324
1325    #[test]
1326    fn a_cutoff_before_the_epoch_still_streams_every_commit() {
1327        let repo = Repo::new();
1328        repo.commit("a.rs", "one", "one");
1329        let adapter = GitRepository::discover(&repo.path).unwrap();
1330        let mut streamed = 0u32;
1331        let summary = adapter
1332            .stream_history(Some(i64::MIN), |_| {
1333                streamed += 1;
1334                Ok(())
1335            })
1336            .unwrap();
1337        assert_eq!((summary.commits(), streamed), (1, 1));
1338    }
1339
1340    #[test]
1341    fn history_follows_renames_and_keeps_binary_churn_unknown() {
1342        let repo = Repo::new();
1343        repo.commit("old.rs", "one\n", "initial");
1344        fs::rename(repo.path.join("old.rs"), repo.path.join("new.rs")).unwrap();
1345        git(&repo.path, ["add", "-A"]);
1346        git(&repo.path, ["commit", "-qm", "rename"]);
1347        repo.commit("image.bin", "\0\u{1}\u{2}", "binary");
1348        let adapter = GitRepository::discover(&repo.path).unwrap();
1349        let mut commits = Vec::new();
1350        adapter
1351            .stream_history(None, |commit| {
1352                commits.push(commit);
1353                Ok(())
1354            })
1355            .unwrap();
1356        let rename = commits
1357            .iter()
1358            .flat_map(|commit| commit.changes())
1359            .find(|change| change.path() == Path::new("new.rs"))
1360            .unwrap();
1361        assert_eq!(rename.previous_path(), Some(Path::new("old.rs")));
1362        let binary = commits
1363            .iter()
1364            .flat_map(|commit| commit.changes())
1365            .find(|change| change.path() == Path::new("image.bin"))
1366            .unwrap();
1367        assert_eq!(binary.added_lines(), None);
1368        assert_eq!(binary.deleted_lines(), None);
1369    }
1370
1371    #[test]
1372    fn malformed_history_record_is_rejected() {
1373        let mut parser = HistoryParser::default();
1374        let mut accepted = |_| Ok(());
1375        parser
1376            .accept(
1377                b"\x1e0123456789012345678901234567890123456789",
1378                &mut accepted,
1379            )
1380            .unwrap();
1381        parser.accept(b"Name", &mut accepted).unwrap();
1382        parser
1383            .accept(b"address@example.test", &mut accepted)
1384            .unwrap();
1385        parser.accept(b"123", &mut accepted).unwrap();
1386        assert!(matches!(
1387            parser.accept(b"not-a-numstat-record", &mut accepted),
1388            Err(GitError::InvalidOutput(_))
1389        ));
1390    }
1391
1392    #[test]
1393    fn malformed_history_after_valid_commit_preserves_accepted_prefix() {
1394        let mut parser = HistoryParser::default();
1395        let mut commits = Vec::new();
1396        let mut accepted = |commit| {
1397            commits.push(commit);
1398            Ok(())
1399        };
1400        parser
1401            .accept(
1402                b"\x1e0123456789012345678901234567890123456789",
1403                &mut accepted,
1404            )
1405            .unwrap();
1406        parser.accept(b"Name", &mut accepted).unwrap();
1407        parser
1408            .accept(b"address@example.test", &mut accepted)
1409            .unwrap();
1410        parser.accept(b"123", &mut accepted).unwrap();
1411        parser.accept(b"2\t1\tfirst.rs", &mut accepted).unwrap();
1412        parser
1413            .accept(
1414                b"\x1e1123456789012345678901234567890123456789",
1415                &mut accepted,
1416            )
1417            .unwrap();
1418        parser.accept(b"Name", &mut accepted).unwrap();
1419        parser
1420            .accept(b"address@example.test", &mut accepted)
1421            .unwrap();
1422        parser.accept(b"122", &mut accepted).unwrap();
1423
1424        assert!(matches!(
1425            parser.accept(b"not-a-numstat-record", &mut accepted),
1426            Err(GitError::InvalidOutput(_))
1427        ));
1428        assert_eq!(commits.len(), 1);
1429        assert_eq!(commits[0].changes()[0].path(), Path::new("first.rs"));
1430    }
1431
1432    #[test]
1433    fn batch_reader_reuses_one_process() {
1434        let repo = Repo::new();
1435        repo.commit("a.rs", "contents", "one");
1436        let adapter = GitRepository::discover(&repo.path).unwrap();
1437        let before = adapter.git_processes();
1438        let object = String::from_utf8(
1439            Command::new("git")
1440                .args(["rev-parse", "HEAD:a.rs"])
1441                .current_dir(&repo.path)
1442                .output()
1443                .unwrap()
1444                .stdout,
1445        )
1446        .unwrap();
1447        let mut reader = adapter.object_reader(2).unwrap();
1448        assert_eq!(reader.read_object(object.trim()).unwrap(), b"contents");
1449        assert_eq!(reader.read_object(object.trim()).unwrap(), b"contents");
1450        assert_eq!(adapter.git_processes() - before, 1);
1451    }
1452
1453    #[test]
1454    fn batch_reader_reads_paths_with_spaces_and_rejects_line_breaks() {
1455        let repo = Repo::new();
1456        repo.commit("with space.rs", "contents", "one");
1457        let adapter = GitRepository::discover(&repo.path).unwrap();
1458        let mut reader = adapter.object_reader(1).unwrap();
1459        assert_eq!(
1460            reader
1461                .read_path("HEAD", Path::new("with space.rs"))
1462                .unwrap(),
1463            b"contents"
1464        );
1465        assert!(matches!(
1466            reader.read_path("HEAD", Path::new("bad\nname.rs")),
1467            Err(GitError::UnsafeArgument(_))
1468        ));
1469    }
1470
1471    #[test]
1472    fn batch_reader_lists_tree_files_without_another_process() {
1473        let repo = Repo::new();
1474        fs::create_dir_all(repo.path.join("nested")).unwrap();
1475        fs::write(repo.path.join("root.rs"), "fn root() {}\n").unwrap();
1476        fs::write(repo.path.join("nested/leaf.rs"), "fn leaf() {}\n").unwrap();
1477        git(&repo.path, ["add", "."]);
1478        git(&repo.path, ["commit", "-qm", "tree"]);
1479        let adapter = GitRepository::discover(&repo.path).unwrap();
1480        let mut reader = adapter.object_reader(4).unwrap();
1481        let processes = adapter.git_processes();
1482        assert_eq!(
1483            reader.tree_files("HEAD").unwrap(),
1484            [PathBuf::from("nested/leaf.rs"), PathBuf::from("root.rs")]
1485        );
1486        assert_eq!(adapter.git_processes(), processes);
1487    }
1488
1489    #[test]
1490    fn rejects_shell_like_refs() {
1491        let repo = Repo::new();
1492        let adapter = GitRepository::discover(&repo.path).unwrap();
1493        assert!(matches!(
1494            adapter.merge_base("HEAD; touch pwned", "HEAD"),
1495            Err(GitError::UnsafeArgument(_))
1496        ));
1497        assert!(!repo.path.join("pwned").exists());
1498    }
1499}