1use 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#[derive(Debug)]
21pub enum GitError {
22 NotRepository(PathBuf),
24 EmptyHistory,
26 UnsafeArgument(String),
28 Command {
30 args: Vec<OsString>,
32 status: Option<i32>,
34 stderr: String,
36 },
37 InvalidOutput(String),
39 MissingObject(String),
41 Io(io::Error),
43 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#[derive(Debug, Clone, PartialEq, Eq)]
99struct ChangedPath {
100 pub path: PathBuf,
102 pub previous_path: Option<PathBuf>,
104 pub status: ChangeStatus,
106}
107
108#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
174struct WorktreeStatus {
175 pub entries: Vec<ChangedPath>,
176}
177
178#[derive(Debug, Clone, Hash, PartialEq, Eq)]
181pub struct ContributorIdentity(String);
182
183#[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#[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 pub fn revision(&self) -> &str {
220 &self.revision
221 }
222 pub fn contributor(&self) -> &ContributorIdentity {
223 &self.contributor
224 }
225 pub const fn timestamp(&self) -> i64 {
228 self.timestamp
229 }
230 pub fn changes(&self) -> &[HistoryChange] {
231 &self.changes
232 }
233}
234
235#[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#[derive(Clone, Debug)]
267pub struct GitRepository {
268 root: PathBuf,
269 processes: Arc<AtomicUsize>,
270}
271
272impl GitRepository {
273 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 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 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 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 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 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 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 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 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 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
836pub 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 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 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 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 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}