1use std::path::Path;
28use std::time::Duration;
29
30use serde::{Deserialize, Serialize};
31
32use super::{COMMAND_TIMEOUT, GitOutput, run_git_within};
33
34pub const MAX_UNTRACKED_FILES: usize = 4;
45
46pub(crate) const MAX_UNTRACKED_FILE_SIZE: u64 = 1024;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct FileHunks {
53 pub path: String,
54 pub status: FileStatus,
55 pub hunks: Vec<Hunk>,
56 pub oversize: bool,
61}
62
63pub use forge_primitives::git::FileStatus;
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Hunk {
83 pub old_start: u32,
84 pub old_count: u32,
85 pub new_start: u32,
86 pub new_count: u32,
87 pub lines: Vec<DiffLine>,
88}
89
90impl Hunk {
91 pub fn added_count(&self) -> u32 {
93 u32::try_from(self.lines.iter().filter(|l| l.kind == DiffLineKind::Added).count())
94 .unwrap_or(u32::MAX)
95 }
96
97 pub fn removed_count(&self) -> u32 {
99 u32::try_from(self.lines.iter().filter(|l| l.kind == DiffLineKind::Removed).count())
100 .unwrap_or(u32::MAX)
101 }
102}
103
104impl FileHunks {
105 pub fn added_count(&self) -> u32 {
107 self.hunks.iter().map(Hunk::added_count).fold(0u32, u32::saturating_add)
108 }
109
110 pub fn removed_count(&self) -> u32 {
112 self.hunks.iter().map(Hunk::removed_count).fold(0u32, u32::saturating_add)
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum DiffLineKind {
120 Context,
123 Added,
125 Removed,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct DiffLine {
134 pub kind: DiffLineKind,
135 pub text: String,
136 pub old_line: Option<u32>,
138 pub new_line: Option<u32>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ScanOutcome {
157 pub files: Vec<FileHunks>,
158 pub scanner_ok: bool,
159 pub untracked_suppressed: usize,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct CommitMeta {
170 pub sha: String,
171 pub short_sha: String,
172 pub subject: String,
173 pub body: String,
174}
175
176const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
181
182const LOG_FIELD_SEP: char = '\u{1f}';
185
186pub async fn scan_commits(cwd: &Path, target: &str) -> Vec<CommitMeta> {
190 scan_commits_within(cwd, target, COMMAND_TIMEOUT).await
191}
192
193pub async fn scan_commits_within(cwd: &Path, target: &str, budget: Duration) -> Vec<CommitMeta> {
196 let range = format!("{target}..HEAD");
197 let raw =
198 match run_git_within(cwd, &["log", "--reverse", "--format=%H%x1f%h%x1f%s", &range], budget)
199 .await
200 {
201 GitOutput::Ok(s) => s,
202 GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => return Vec::new(),
203 };
204 raw.lines()
205 .filter_map(|line| {
206 let mut parts = line.split(LOG_FIELD_SEP);
207 let sha = parts.next()?.to_owned();
208 let short_sha = parts.next()?.to_owned();
209 let subject = parts.next().unwrap_or_default().to_owned();
213 if sha.is_empty() {
214 return None;
215 }
216 Some(CommitMeta { sha, short_sha, subject, body: String::new() })
219 })
220 .collect()
221}
222
223pub async fn scan_commit(cwd: &Path, sha: &str) -> ScanOutcome {
228 scan_commit_within(cwd, sha, COMMAND_TIMEOUT).await
229}
230
231pub async fn scan_commit_within(cwd: &Path, sha: &str, budget: Duration) -> ScanOutcome {
234 let Some(ref_spec) = commit_ref_spec(cwd, sha, budget).await else {
235 return ScanOutcome { files: Vec::new(), scanner_ok: false, untracked_suppressed: 0 };
238 };
239 scan_with_ref_spec(cwd, &ref_spec, budget).await
240}
241
242pub(crate) async fn commit_ref_spec(cwd: &Path, sha: &str, budget: Duration) -> Option<String> {
256 let commit = super::file::resolve_commit(cwd, sha, budget).await?;
257 match run_git_within(cwd, &["rev-list", "--parents", "-n", "1", &commit], budget).await {
258 GitOutput::Ok(s) if s.split_whitespace().count() > 1 => {
259 Some(format!("{commit}^..{commit}"))
260 }
261 GitOutput::Ok(_) => Some(format!("{EMPTY_TREE_SHA}..{commit}")),
262 GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => {
265 Some(format!("{commit}^..{commit}"))
266 }
267 }
268}
269
270pub async fn scan_commit_body(cwd: &Path, sha: &str) -> String {
276 scan_commit_body_within(cwd, sha, COMMAND_TIMEOUT).await
277}
278
279pub async fn scan_commit_body_within(cwd: &Path, sha: &str, budget: Duration) -> String {
287 let Some(commit) = super::file::resolve_commit(cwd, sha, budget).await else {
288 return String::new();
289 };
290 match run_git_within(cwd, &["show", "-s", "--format=%b", &commit], budget).await {
291 GitOutput::Ok(s) => s.trim_end().to_owned(),
292 GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => String::new(),
293 }
294}
295
296pub(super) const MAX_INFLIGHT_FETCHES: usize = 16;
303
304const PINNED_CONTEXT_LINES: u32 = 2_000;
310
311const FALLBACK_CONTEXT_LINES: u32 = 3;
315
316pub async fn scan(cwd: &Path, target: &str) -> ScanOutcome {
335 scan_within(cwd, target, COMMAND_TIMEOUT).await
336}
337
338pub async fn scan_within(cwd: &Path, target: &str, budget: Duration) -> ScanOutcome {
344 let ref_spec = diff_ref_spec(target);
345 let mut outcome = scan_with_ref_spec(cwd, &ref_spec, budget).await;
346
347 if target == "HEAD" {
352 let (untracked, untracked_ok, suppressed) = scan_untracked(cwd, budget).await;
353 outcome.files.extend(untracked);
354 outcome.scanner_ok = outcome.scanner_ok && untracked_ok;
355 outcome.untracked_suppressed = suppressed;
356 }
357
358 outcome
359}
360
361async fn scan_with_ref_spec(cwd: &Path, ref_spec: &str, budget: Duration) -> ScanOutcome {
366 let mut scanner_ok = true;
367 let name_status = match run_git_within(cwd, &["diff", ref_spec, "--name-status"], budget).await
368 {
369 GitOutput::Ok(s) => s,
370 GitOutput::Empty => String::new(),
371 GitOutput::Failed | GitOutput::Oversize => {
372 scanner_ok = false;
373 String::new()
374 }
375 };
376 let mut files = parse_name_status(&name_status);
377
378 if !files.is_empty() {
379 use futures::StreamExt;
391 let paths: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
395 let ref_spec_for_fetch = ref_spec.to_owned();
396 let mut stream = futures::stream::iter(paths.into_iter().enumerate().map(|(idx, path)| {
397 let ref_spec = ref_spec_for_fetch.clone();
398 async move { (idx, fetch_file_hunks(cwd, &ref_spec, &path, budget).await) }
399 }))
400 .buffer_unordered(MAX_INFLIGHT_FETCHES);
401 while let Some((idx, result)) = stream.next().await {
402 match result {
403 FileScanOutcome::Ok { hunks, oversize } => {
404 files[idx].hunks = hunks;
405 files[idx].oversize = oversize;
406 }
407 FileScanOutcome::SubprocessFailed => {
411 scanner_ok = false;
412 }
413 }
414 }
415 }
416
417 ScanOutcome { files, scanner_ok, untracked_suppressed: 0 }
418}
419
420enum FileScanOutcome {
426 Ok { hunks: Vec<Hunk>, oversize: bool },
427 SubprocessFailed,
428}
429
430fn diff_ref_spec(target: &str) -> String {
448 if target == "HEAD" { "HEAD".to_owned() } else { format!("{target}...HEAD") }
449}
450
451enum DiffFetch {
454 Ok(Vec<Hunk>),
455 Oversize,
456 Failed,
457}
458
459async fn run_file_diff(
464 cwd: &Path,
465 ref_spec: &str,
466 path: &str,
467 context: u32,
468 budget: Duration,
469) -> DiffFetch {
470 let flag = format!("-U{context}");
471 let section =
472 match run_git_within(cwd, &["diff", ref_spec, "--no-ext-diff", &flag, "--", path], budget)
473 .await
474 {
475 GitOutput::Ok(s) => s,
476 GitOutput::Empty => return DiffFetch::Ok(Vec::new()),
477 GitOutput::Oversize => return DiffFetch::Oversize,
478 GitOutput::Failed => return DiffFetch::Failed,
479 };
480 let hunks = parse_hunks(§ion);
481 if hunks.is_empty() && contains_hunk_marker(§ion) {
482 tracing::warn!(
488 target: crate::logging::targets::ENV_GIT,
489 event_name = "git_hunks_parse_empty",
490 message = "diff section had @@ markers but produced zero hunks",
491 outcome = "failure",
492 path = %path,
493 section_bytes = section.len(),
494 );
495 }
496 DiffFetch::Ok(hunks)
497}
498
499async fn fetch_file_hunks(
514 cwd: &Path,
515 ref_spec: &str,
516 path: &str,
517 budget: Duration,
518) -> FileScanOutcome {
519 match run_file_diff(cwd, ref_spec, path, PINNED_CONTEXT_LINES, budget).await {
520 DiffFetch::Ok(hunks) => FileScanOutcome::Ok { hunks, oversize: false },
521 DiffFetch::Failed => FileScanOutcome::SubprocessFailed,
522 DiffFetch::Oversize => {
523 match run_file_diff(cwd, ref_spec, path, FALLBACK_CONTEXT_LINES, budget).await {
524 DiffFetch::Ok(hunks) => FileScanOutcome::Ok { hunks, oversize: true },
525 DiffFetch::Oversize => FileScanOutcome::Ok { hunks: Vec::new(), oversize: true },
526 DiffFetch::Failed => FileScanOutcome::SubprocessFailed,
527 }
528 }
529 }
530}
531
532#[derive(Debug, Clone, PartialEq, Eq)]
538pub(super) struct NameStatusEntry {
539 pub status: FileStatus,
540 pub path: String,
541 pub old_path: Option<String>,
542}
543
544pub(super) fn status_of(leading: char) -> Option<FileStatus> {
547 Some(match leading {
548 'M' => FileStatus::Modified,
549 'A' => FileStatus::Added,
550 'D' => FileStatus::Deleted,
551 'R' => FileStatus::Renamed,
552 'C' => FileStatus::Copied,
553 'T' => FileStatus::Typechange,
554 'U' => FileStatus::Unmerged,
555 _ => return None,
556 })
557}
558
559pub(super) fn parse_name_status_entries(raw: &str) -> Vec<NameStatusEntry> {
567 raw.lines()
568 .filter_map(|line| {
569 let mut parts = line.split('\t');
570 let status_code = parts.next()?;
571 let path = parts.next_back()?;
577 let leading = status_code.chars().next()?;
578 let Some(status) = status_of(leading) else {
579 tracing::warn!(
580 target: crate::logging::targets::ENV_GIT,
581 event_name = "git_name_status_unknown_code",
582 message = "git diff --name-status emitted an unhandled status code; entry dropped",
583 outcome = "skipped",
584 status_code = ?leading,
585 path = %path,
586 );
587 return None;
588 };
589 let old_path = if matches!(leading, 'R' | 'C') {
592 let rest: Vec<&str> = parts.collect();
593 (!rest.is_empty()).then(|| rest.join("\t"))
594 } else {
595 None
596 };
597 Some(NameStatusEntry { status, path: path.to_owned(), old_path })
598 })
599 .collect()
600}
601
602fn parse_name_status(raw: &str) -> Vec<FileHunks> {
605 parse_name_status_entries(raw)
606 .into_iter()
607 .map(|entry| FileHunks {
608 path: entry.path,
609 status: entry.status,
610 hunks: Vec::new(),
611 oversize: false,
612 })
613 .collect()
614}
615
616fn contains_hunk_marker(section: &str) -> bool {
621 section.lines().any(|line| line.starts_with("@@"))
622}
623
624pub(super) fn parse_hunks(section: &str) -> Vec<Hunk> {
629 let lines: Vec<&str> = section.lines().collect();
630 let mut hunk_starts: Vec<usize> = Vec::new();
631 for (i, line) in lines.iter().enumerate() {
632 if line.starts_with("@@") {
633 hunk_starts.push(i);
634 }
635 }
636 let mut hunks = Vec::with_capacity(hunk_starts.len());
637 for (idx, &start) in hunk_starts.iter().enumerate() {
638 let end = hunk_starts.get(idx + 1).copied().unwrap_or(lines.len());
639 let header_line = lines[start];
640 let Some((old_start, old_count, new_start, new_count)) = parse_hunk_header(header_line)
641 else {
642 let truncated = header_line.chars().take(120).collect::<String>();
647 tracing::warn!(
648 target: crate::logging::targets::ENV_GIT,
649 event_name = "git_hunk_header_malformed",
650 message = "hunk header didn't parse; hunk dropped",
651 outcome = "skipped",
652 header = %truncated,
653 );
654 continue;
655 };
656 let mut diff_lines = Vec::new();
657 let mut old_cursor = old_start;
658 let mut new_cursor = new_start;
659 for line in &lines[start + 1..end] {
660 if let Some(rest) = line.strip_prefix('-') {
661 diff_lines.push(DiffLine {
662 kind: DiffLineKind::Removed,
663 text: rest.to_owned(),
664 old_line: Some(old_cursor),
665 new_line: None,
666 });
667 old_cursor = old_cursor.saturating_add(1);
668 } else if let Some(rest) = line.strip_prefix('+') {
669 diff_lines.push(DiffLine {
670 kind: DiffLineKind::Added,
671 text: rest.to_owned(),
672 old_line: None,
673 new_line: Some(new_cursor),
674 });
675 new_cursor = new_cursor.saturating_add(1);
676 } else if let Some(rest) = line.strip_prefix(' ') {
677 diff_lines.push(DiffLine {
678 kind: DiffLineKind::Context,
679 text: rest.to_owned(),
680 old_line: Some(old_cursor),
681 new_line: Some(new_cursor),
682 });
683 old_cursor = old_cursor.saturating_add(1);
684 new_cursor = new_cursor.saturating_add(1);
685 } else if line.starts_with('\\') {
686 } else if !line.is_empty() {
689 let truncated: String = line.chars().take(120).collect();
694 tracing::warn!(
695 target: crate::logging::targets::ENV_GIT,
696 event_name = "git_hunk_body_stray_line",
697 message = "hunk body line doesn't start with -/+/ /\\; line dropped",
698 outcome = "skipped",
699 line = %truncated,
700 );
701 }
702 }
705 hunks.push(Hunk { old_start, old_count, new_start, new_count, lines: diff_lines });
706 }
707 hunks
708}
709
710fn parse_hunk_header(line: &str) -> Option<(u32, u32, u32, u32)> {
714 let rest = line.strip_prefix("@@")?;
715 let payload = rest.split("@@").next()?.trim();
716 let mut parts = payload.split_whitespace();
717 let old_part = parts.next()?.strip_prefix('-')?;
718 let new_part = parts.next()?.strip_prefix('+')?;
719 let (old_start, old_count) = parse_pair(old_part)?;
720 let (new_start, new_count) = parse_pair(new_part)?;
721 Some((old_start, old_count, new_start, new_count))
722}
723
724fn parse_pair(s: &str) -> Option<(u32, u32)> {
725 if let Some((a, b)) = s.split_once(',') {
726 Some((a.parse().ok()?, b.parse().ok()?))
727 } else {
728 Some((s.parse().ok()?, 1))
729 }
730}
731
732async fn scan_untracked(cwd: &Path, budget: Duration) -> (Vec<FileHunks>, bool, usize) {
749 let raw =
750 match run_git_within(cwd, &["ls-files", "--others", "--exclude-standard"], budget).await {
751 GitOutput::Ok(s) => s,
752 GitOutput::Empty => String::new(),
753 GitOutput::Failed | GitOutput::Oversize => return (Vec::new(), false, 0),
754 };
755 let untracked: Vec<&str> = raw.lines().filter(|l| !l.is_empty()).collect();
756 if untracked.len() > MAX_UNTRACKED_FILES {
757 let suppressed = untracked.len();
758 return (Vec::new(), true, suppressed);
759 }
760 let mut out = Vec::with_capacity(untracked.len());
761 for path in untracked {
762 let file_path = cwd.join(path);
763 let hunks = match tokio::fs::metadata(&file_path).await {
764 Ok(meta) if meta.is_file() && meta.len() <= MAX_UNTRACKED_FILE_SIZE => {
765 match tokio::fs::read_to_string(&file_path).await {
766 Ok(content) => {
767 let raw_lines: Vec<String> = content.lines().map(str::to_owned).collect();
768 let new_count = u32::try_from(raw_lines.len()).unwrap_or(u32::MAX);
769 let diff_lines: Vec<DiffLine> = raw_lines
770 .into_iter()
771 .enumerate()
772 .map(|(i, text)| DiffLine {
773 kind: DiffLineKind::Added,
774 text,
775 old_line: None,
776 new_line: u32::try_from(i + 1).ok(),
777 })
778 .collect();
779 vec![Hunk {
780 old_start: 0,
781 old_count: 0,
782 new_start: 1,
783 new_count,
784 lines: diff_lines,
785 }]
786 }
787 Err(err) => {
788 tracing::warn!(
796 target: crate::logging::targets::ENV_GIT,
797 event_name = "untracked_read_failed",
798 message = "untracked file content read failed; rail entry kept with empty hunks",
799 outcome = "failure",
800 path = %file_path.display(),
801 error = %err,
802 );
803 Vec::new()
804 }
805 }
806 }
807 Ok(meta) if !meta.is_file() => {
808 tracing::warn!(
815 target: crate::logging::targets::ENV_GIT,
816 event_name = "untracked_non_regular",
817 message = "untracked entry is not a regular file; rail entry kept with empty hunks",
818 outcome = "skipped",
819 path = %file_path.display(),
820 );
821 Vec::new()
822 }
823 Ok(meta) => {
824 tracing::warn!(
829 target: crate::logging::targets::ENV_GIT,
830 event_name = "untracked_oversize",
831 message = "untracked file exceeded size cap; rail entry kept with empty hunks",
832 outcome = "skipped",
833 path = %file_path.display(),
834 bytes = meta.len(),
835 cap = MAX_UNTRACKED_FILE_SIZE,
836 );
837 Vec::new()
838 }
839 Err(err) => {
840 tracing::warn!(
841 target: crate::logging::targets::ENV_GIT,
842 event_name = "untracked_metadata_failed",
843 message = "untracked file metadata read failed; rail entry kept with empty hunks",
844 outcome = "failure",
845 path = %file_path.display(),
846 error = %err,
847 );
848 Vec::new()
849 }
850 };
851 out.push(FileHunks {
852 path: path.to_owned(),
853 status: FileStatus::Untracked,
854 hunks,
855 oversize: false,
856 });
857 }
858 (out, true, 0)
859}
860
861#[cfg(test)]
862mod tests {
863 use super::super::TEST_GIT_BUDGET;
864 use super::*;
865
866 #[test]
867 fn parse_name_status_modified() {
868 let raw = "M\tpath/to/file.rs";
869 let files = parse_name_status(raw);
870 assert_eq!(files.len(), 1);
871 assert_eq!(files[0].path, "path/to/file.rs");
872 assert_eq!(files[0].status, FileStatus::Modified);
873 }
874
875 #[test]
876 fn parse_name_status_all_codes() {
877 let raw = "\
878M\ta.rs
879A\tb.rs
880D\tc.rs
881R100\told.rs\tnew.rs
882C75\tsrc.rs\tdst.rs
883T\tsymlink.rs
884U\tconflicted.rs";
885 let files = parse_name_status(raw);
886 let statuses: Vec<_> = files.iter().map(|f| (f.path.clone(), f.status)).collect();
887 assert_eq!(
888 statuses,
889 vec![
890 ("a.rs".into(), FileStatus::Modified),
891 ("b.rs".into(), FileStatus::Added),
892 ("c.rs".into(), FileStatus::Deleted),
893 ("new.rs".into(), FileStatus::Renamed),
894 ("dst.rs".into(), FileStatus::Copied),
895 ("symlink.rs".into(), FileStatus::Typechange),
896 ("conflicted.rs".into(), FileStatus::Unmerged),
897 ]
898 );
899 }
900
901 #[test]
902 fn parse_name_status_drops_unknown_codes() {
903 let raw = "M\tkeep.rs\nX\tweird.rs\nB\tbroken.rs\nA\talso_keep.rs";
908 let files = parse_name_status(raw);
909 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
910 assert_eq!(paths, vec!["keep.rs", "also_keep.rs"]);
911 }
912
913 #[test]
914 fn parse_name_status_empty() {
915 assert!(parse_name_status("").is_empty());
916 }
917
918 fn added(line: &DiffLine, expected: &str) -> bool {
919 line.kind == DiffLineKind::Added && line.text == expected
920 }
921
922 fn removed(line: &DiffLine, expected: &str) -> bool {
923 line.kind == DiffLineKind::Removed && line.text == expected
924 }
925
926 fn context(line: &DiffLine, expected: &str) -> bool {
927 line.kind == DiffLineKind::Context && line.text == expected
928 }
929
930 #[test]
936 fn parse_hunks_single() {
937 let section = "\
938diff --git a/x.rs b/x.rs
939index abc..def 100644
940--- a/x.rs
941+++ b/x.rs
942@@ -1,3 +1,4 @@
943 line1
944 line2
945+new line
946 line3";
947 let hunks = parse_hunks(section);
948 assert_eq!(hunks.len(), 1);
949 assert_eq!(hunks[0].old_start, 1);
950 assert_eq!(hunks[0].old_count, 3);
951 assert_eq!(hunks[0].new_start, 1);
952 assert_eq!(hunks[0].new_count, 4);
953 assert_eq!(hunks[0].added_count(), 1);
954 assert_eq!(hunks[0].removed_count(), 0);
955 assert!(hunks[0].lines.iter().any(|l| added(l, "new line")));
956 let inserted =
959 hunks[0].lines.iter().find(|l| added(l, "new line")).expect("added line present");
960 assert_eq!(inserted.new_line, Some(3));
961 assert_eq!(inserted.old_line, None);
962 }
963
964 #[test]
965 fn parse_hunks_multiple() {
966 let section = "\
967diff --git a/x.rs b/x.rs
968--- a/x.rs
969+++ b/x.rs
970@@ -1,3 +1,4 @@
971 line1
972+added1
973 line2
974 line3
975@@ -10,3 +11,4 @@
976 line10
977+added2
978 line11
979 line12";
980 let hunks = parse_hunks(section);
981 assert_eq!(hunks.len(), 2);
982 assert_eq!(hunks[0].old_start, 1);
983 assert_eq!(hunks[1].old_start, 10);
984 assert!(hunks[0].lines.iter().any(|l| added(l, "added1")));
985 assert!(hunks[1].lines.iter().any(|l| added(l, "added2")));
986 }
987
988 #[test]
989 fn parse_hunks_deletion() {
990 let section = "\
991diff --git a/x.rs b/x.rs
992--- a/x.rs
993+++ b/x.rs
994@@ -1,4 +1,3 @@
995 line1
996-deleted line
997 line2
998 line3";
999 let hunks = parse_hunks(section);
1000 assert_eq!(hunks.len(), 1);
1001 assert_eq!(hunks[0].added_count(), 0);
1002 assert_eq!(hunks[0].removed_count(), 1);
1003 assert!(hunks[0].lines.iter().any(|l| removed(l, "deleted line")));
1004 let context_lines: Vec<&DiffLine> =
1005 hunks[0].lines.iter().filter(|l| l.kind == DiffLineKind::Context).collect();
1006 assert_eq!(context_lines.len(), 3);
1007 assert!(hunks[0].lines.iter().any(|l| context(l, "line1")));
1008 }
1009
1010 #[test]
1011 fn parse_hunks_line_numbers_track_through_changes() {
1012 let section = "\
1016diff --git a/x.rs b/x.rs
1017--- a/x.rs
1018+++ b/x.rs
1019@@ -1,3 +1,3 @@
1020 line_a
1021-line_b
1022+line_c
1023 line_d";
1024 let hunks = parse_hunks(section);
1025 assert_eq!(hunks.len(), 1);
1026 let lines = &hunks[0].lines;
1027 assert_eq!(lines[0].old_line, Some(1));
1028 assert_eq!(lines[0].new_line, Some(1));
1029 assert_eq!(lines[1].kind, DiffLineKind::Removed);
1030 assert_eq!(lines[1].old_line, Some(2));
1031 assert_eq!(lines[1].new_line, None);
1032 assert_eq!(lines[2].kind, DiffLineKind::Added);
1033 assert_eq!(lines[2].old_line, None);
1034 assert_eq!(lines[2].new_line, Some(2));
1035 assert_eq!(lines[3].kind, DiffLineKind::Context);
1036 assert_eq!(lines[3].old_line, Some(3));
1037 assert_eq!(lines[3].new_line, Some(3));
1038 }
1039
1040 #[test]
1041 fn parse_hunk_header_with_counts() {
1042 let (o_s, o_c, n_s, n_c) = parse_hunk_header("@@ -65,4 +65,12 @@").unwrap();
1043 assert_eq!((o_s, o_c, n_s, n_c), (65, 4, 65, 12));
1044 }
1045
1046 #[test]
1047 fn parse_hunk_header_omitted_counts() {
1048 let (o_s, o_c, n_s, n_c) = parse_hunk_header("@@ -42 +42 @@").unwrap();
1050 assert_eq!((o_s, o_c, n_s, n_c), (42, 1, 42, 1));
1051 }
1052
1053 #[test]
1054 fn parse_hunk_header_with_trailing_context() {
1055 let (o_s, _, n_s, _) = parse_hunk_header("@@ -10,2 +10,3 @@ fn foo() {").unwrap();
1058 assert_eq!((o_s, n_s), (10, 10));
1059 }
1060
1061 #[test]
1062 fn parse_hunk_header_malformed() {
1063 assert!(parse_hunk_header("not a hunk header").is_none());
1064 assert!(parse_hunk_header("@@ -x,y +1,2 @@").is_none());
1065 }
1066
1067 #[test]
1068 fn contains_hunk_marker_true_when_at_marker_present() {
1069 let section = "diff --git a/x.rs b/x.rs\n--- a/x.rs\n+++ b/x.rs\n@@ -1 +1 @@\n-a\n+b\n";
1070 assert!(contains_hunk_marker(section));
1071 }
1072
1073 #[test]
1074 fn contains_hunk_marker_false_when_no_at_marker() {
1075 let section =
1076 "diff --git a/logo.png b/logo.png\nBinary files a/logo.png and b/logo.png differ\n";
1077 assert!(!contains_hunk_marker(section));
1078 }
1079
1080 #[test]
1081 fn parse_hunks_on_full_file_section_skips_diff_headers() {
1082 let raw = "diff --git a/x.rs b/x.rs\n\
1087--- a/x.rs\n\
1088+++ b/x.rs\n\
1089@@ -1,1 +1,2 @@\n\
1090 keep\n\
1091+new\n";
1092 let hunks = parse_hunks(raw);
1093 assert_eq!(hunks.len(), 1);
1094 assert!(hunks[0].lines.iter().any(|l| added(l, "new")));
1095 let file = FileHunks {
1096 path: "x.rs".to_owned(),
1097 status: FileStatus::Modified,
1098 hunks: hunks.clone(),
1099 oversize: false,
1100 };
1101 assert_eq!(file.added_count(), 1);
1102 assert_eq!(file.removed_count(), 0);
1103 }
1104
1105 #[test]
1114 fn diff_ref_spec_passes_head_through_unchanged() {
1115 assert_eq!(diff_ref_spec("HEAD"), "HEAD");
1116 }
1117
1118 #[test]
1119 fn diff_ref_spec_converts_branch_to_three_dot_range() {
1120 assert_eq!(diff_ref_spec("main"), "main...HEAD");
1121 assert_eq!(diff_ref_spec("master"), "master...HEAD");
1122 assert_eq!(diff_ref_spec("origin/main"), "origin/main...HEAD");
1123 }
1124
1125 #[test]
1126 fn diff_ref_spec_treats_sha_like_a_branch() {
1127 assert_eq!(diff_ref_spec("abc1234"), "abc1234...HEAD");
1130 }
1131
1132 fn git(dir: &tempfile::TempDir, args: &[&str]) {
1138 let out = std::process::Command::new("git")
1139 .arg("-C")
1140 .arg(dir.path())
1141 .args(args)
1142 .output()
1143 .expect("git");
1144 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
1145 }
1146
1147 fn git_capture(dir: &tempfile::TempDir, args: &[&str]) -> String {
1148 let out = std::process::Command::new("git")
1149 .arg("-C")
1150 .arg(dir.path())
1151 .args(args)
1152 .output()
1153 .expect("git");
1154 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
1155 String::from_utf8_lossy(&out.stdout).trim().to_owned()
1156 }
1157
1158 fn init_commit_repo(dir: &tempfile::TempDir) {
1159 git(dir, &["init", "-q"]);
1160 git(dir, &["symbolic-ref", "HEAD", "refs/heads/main"]);
1161 git(dir, &["config", "user.email", "t@e.com"]);
1162 git(dir, &["config", "user.name", "T"]);
1163 git(dir, &["config", "commit.gpgsign", "false"]);
1164 }
1165
1166 fn write_file(dir: &tempfile::TempDir, name: &str, contents: &str) {
1167 std::fs::write(dir.path().join(name), contents).expect("write");
1168 }
1169
1170 fn commit(dir: &tempfile::TempDir, msg: &str) {
1171 git(dir, &["add", "-A"]);
1172 git(dir, &["commit", "-q", "-m", msg]);
1173 }
1174
1175 fn three_commit_branch(dir: &tempfile::TempDir) {
1178 init_commit_repo(dir);
1179 write_file(dir, "base.txt", "base\n");
1180 commit(dir, "init");
1181 git(dir, &["checkout", "-q", "-b", "feat"]);
1182 write_file(dir, "a.rs", "a\n");
1183 commit(dir, "first");
1184 write_file(dir, "b.rs", "b\n");
1185 commit(dir, "second");
1186 write_file(dir, "c.rs", "c\n");
1187 commit(dir, "third");
1188 }
1189
1190 #[tokio::test(flavor = "current_thread")]
1191 async fn scan_commits_lists_branch_commits_oldest_first() {
1192 let dir = tempfile::tempdir().expect("tempdir");
1193 three_commit_branch(&dir);
1194 let commits = scan_commits_within(dir.path(), "main", TEST_GIT_BUDGET).await;
1195 let subjects: Vec<&str> = commits.iter().map(|c| c.subject.as_str()).collect();
1196 assert_eq!(subjects, vec!["first", "second", "third"], "oldest-first");
1197 assert!(commits.iter().all(|c| !c.sha.is_empty() && !c.short_sha.is_empty()));
1198 assert!(commits[0].sha.starts_with(&commits[0].short_sha), "short sha prefixes full");
1199 }
1200
1201 #[tokio::test(flavor = "current_thread")]
1202 async fn scan_commits_empty_when_no_commits_ahead() {
1203 let dir = tempfile::tempdir().expect("tempdir");
1204 init_commit_repo(&dir);
1205 write_file(&dir, "base.txt", "base\n");
1206 commit(&dir, "init");
1207 assert!(scan_commits_within(dir.path(), "HEAD", TEST_GIT_BUDGET).await.is_empty());
1209 }
1210
1211 #[tokio::test(flavor = "current_thread")]
1217 async fn the_readers_take_the_budget_they_are_given() {
1218 use forge_primitives::git::GitBranch;
1219 use forge_primitives::git_diff::{GitDiffSnapshot, GitDiffStats, LayerState, RepoGate};
1220
1221 let dir = tempfile::tempdir().expect("tempdir");
1222 init_commit_repo(&dir);
1223 write_file(&dir, "base.txt", "base\n");
1224 commit(&dir, "init");
1225 write_file(&dir, "base.txt", "base\nand a change\n");
1226
1227 let bin = tempfile::tempdir().expect("tempdir");
1230 super::super::stall_git_at(dir.path(), bin.path());
1231
1232 let refused = scan_within(dir.path(), "HEAD", Duration::ZERO).await;
1233 assert!(!refused.scanner_ok, "a zero budget reads as a failed scan: {refused:?}");
1234 assert!(refused.files.is_empty(), "with nothing read: {refused:?}");
1235
1236 let deeper = fetch_file_hunks(dir.path(), "HEAD", "base.txt", Duration::ZERO).await;
1240 assert!(
1241 matches!(deeper, FileScanOutcome::SubprocessFailed),
1242 "a later child refuses a zero budget too",
1243 );
1244
1245 let snapshot = GitDiffSnapshot {
1247 branch: GitBranch::Named("feature".into()),
1248 default_branch: None,
1249 repo_gate: RepoGate::InRepo,
1250 pushed_sha: None,
1251 worktree: LayerState::Populated(GitDiffStats {
1252 files: Vec::new(),
1253 total_files: 0,
1254 total_added: 0,
1255 total_removed: 0,
1256 }),
1257 branch_ahead: LayerState::Clean,
1258 pr: None,
1259 closes: Vec::new(),
1260 pr_fetched_at: None,
1261 };
1262 let refused_content = super::super::content::scan_within(
1263 dir.path(),
1264 &snapshot,
1265 None,
1266 None,
1267 None,
1268 Duration::ZERO,
1269 )
1270 .await;
1271 assert!(
1272 matches!(refused_content.worktree, LayerState::ScanFailed),
1273 "the content reader refuses a zero budget too: {refused_content:?}",
1274 );
1275
1276 crate::env::git_command::clear_git_point(dir.path());
1278 let read = scan_within(dir.path(), "HEAD", TEST_GIT_BUDGET).await;
1279 assert!(read.scanner_ok, "and the tests' budget reads it: {read:?}");
1280 assert_eq!(read.files.len(), 1, "with the changed file: {read:?}");
1281 }
1282
1283 #[tokio::test(flavor = "current_thread")]
1284 async fn scan_commit_scopes_to_that_commit_only() {
1285 let dir = tempfile::tempdir().expect("tempdir");
1286 three_commit_branch(&dir);
1287 let commits = scan_commits_within(dir.path(), "main", TEST_GIT_BUDGET).await;
1288 assert_eq!(commits.len(), 3);
1289 let middle = scan_commit_within(dir.path(), &commits[1].sha, TEST_GIT_BUDGET).await;
1290 let paths: Vec<&str> = middle.files.iter().map(|f| f.path.as_str()).collect();
1291 assert_eq!(paths, vec!["b.rs"], "middle commit touches only b.rs");
1292 assert!(middle.scanner_ok);
1293 }
1294
1295 #[tokio::test(flavor = "current_thread")]
1301 async fn an_option_shaped_sha_scans_nothing_and_writes_nothing() {
1302 let dir = tempfile::tempdir().expect("tempdir");
1303 init_commit_repo(&dir);
1304 write_file(&dir, "a.rs", "a\n");
1305 commit(&dir, "init");
1306 let target = dir.path().join("written-by-git");
1307 let shaped = format!("--output={}", target.display());
1308
1309 let outcome = scan_commit_within(dir.path(), &shaped, TEST_GIT_BUDGET).await;
1310 assert!(!outcome.scanner_ok, "a sha that names no commit is the scan's own failure");
1311 assert!(outcome.files.is_empty(), "and it carries no files: {outcome:?}");
1312 assert!(!target.exists(), "git was never asked to write a file");
1313
1314 let body = scan_commit_body_within(dir.path(), &shaped, TEST_GIT_BUDGET).await;
1315 assert!(body.is_empty(), "nor does the body read ask git for it");
1316 assert!(!target.exists(), "still no file");
1317 }
1318
1319 #[tokio::test(flavor = "current_thread")]
1320 async fn scan_commit_root_commit_does_not_panic() {
1321 let dir = tempfile::tempdir().expect("tempdir");
1322 init_commit_repo(&dir);
1323 write_file(&dir, "README.md", "hello\n");
1324 commit(&dir, "init");
1325 let root = git_capture(&dir, &["rev-list", "--max-parents=0", "HEAD"]);
1326 let outcome = scan_commit_within(dir.path(), &root, TEST_GIT_BUDGET).await;
1327 let paths: Vec<&str> = outcome.files.iter().map(|f| f.path.as_str()).collect();
1330 assert_eq!(paths, vec!["README.md"]);
1331 assert!(outcome.scanner_ok);
1332 }
1333
1334 #[tokio::test(flavor = "current_thread")]
1335 async fn scan_commit_body_returns_full_multiline_body() {
1336 let dir = tempfile::tempdir().expect("tempdir");
1337 init_commit_repo(&dir);
1338 write_file(&dir, "a.rs", "a\n");
1339 git(&dir, &["add", "-A"]);
1340 git(
1341 &dir,
1342 &[
1343 "commit",
1344 "-q",
1345 "-m",
1346 "the subject line",
1347 "-m",
1348 "body line one\nbody line two\n\nbody line three",
1349 ],
1350 );
1351 let sha = git_capture(&dir, &["rev-parse", "HEAD"]);
1352 let body = scan_commit_body_within(dir.path(), &sha, TEST_GIT_BUDGET).await;
1353 assert!(!body.contains("the subject line"), "body excludes the subject: {body:?}");
1355 assert!(body.contains("body line one"));
1356 assert!(body.contains("body line three"));
1357 assert!(body.lines().count() >= 4, "multi-line body preserved: {body:?}");
1359 }
1360
1361 #[tokio::test(flavor = "current_thread")]
1362 async fn scan_commit_body_empty_for_subject_only() {
1363 let dir = tempfile::tempdir().expect("tempdir");
1364 init_commit_repo(&dir);
1365 write_file(&dir, "a.rs", "a\n");
1366 commit(&dir, "just a subject");
1367 let sha = git_capture(&dir, &["rev-parse", "HEAD"]);
1368 let body = scan_commit_body_within(dir.path(), &sha, TEST_GIT_BUDGET).await;
1369 assert!(body.is_empty(), "a subject-only commit has an empty body, got {body:?}");
1370 }
1371
1372 #[tokio::test(flavor = "current_thread")]
1375 async fn scan_fetches_full_context_hunks() {
1376 let dir = tempfile::tempdir().expect("tempdir");
1377 init_commit_repo(&dir);
1378 let base_lines: Vec<String> = (1..=20).map(|i| format!("line {i}")).collect();
1383 write_file(&dir, "f.rs", &format!("{}\n", base_lines.join("\n")));
1384 commit(&dir, "base");
1385 let mut edited = base_lines;
1386 "line 3 CHANGED".clone_into(&mut edited[2]);
1387 "line 17 CHANGED".clone_into(&mut edited[16]);
1388 write_file(&dir, "f.rs", &format!("{}\n", edited.join("\n")));
1389
1390 let outcome = scan_within(dir.path(), "HEAD", TEST_GIT_BUDGET).await;
1391 assert!(outcome.scanner_ok);
1392 let file = outcome.files.iter().find(|f| f.path == "f.rs").expect("f.rs scanned");
1393 assert_eq!(file.hunks.len(), 1, "full context merges the two edits into one hunk");
1394 let texts: Vec<&str> = file.hunks[0].lines.iter().map(|l| l.text.as_str()).collect();
1396 assert!(texts.contains(&"line 10"), "a middle context line is in the snapshot");
1397 assert!(texts.contains(&"line 3 CHANGED"), "the first edit is present");
1398 assert!(texts.contains(&"line 17 CHANGED"), "the second edit is present");
1399 }
1400
1401 #[tokio::test(flavor = "current_thread")]
1402 async fn oversize_file_does_not_blank_the_scope() {
1403 let dir = tempfile::tempdir().expect("tempdir");
1404 init_commit_repo(&dir);
1405 write_file(&dir, "small.rs", "fn a() {}\n");
1406 write_file(&dir, "big.txt", &"a".repeat(5 * 1024 * 1024));
1410 commit(&dir, "base");
1411 write_file(&dir, "small.rs", "fn a() { b(); }\n");
1412 write_file(&dir, "big.txt", &"b".repeat(5 * 1024 * 1024));
1413
1414 let outcome = scan_within(dir.path(), "HEAD", TEST_GIT_BUDGET).await;
1415 assert!(outcome.scanner_ok, "one oversize file must not blank the whole scope");
1416 let small = outcome.files.iter().find(|f| f.path == "small.rs").expect("small scanned");
1417 assert!(!small.oversize, "the normal file is not flagged oversize");
1418 assert!(!small.hunks.is_empty(), "the normal file still renders its diff");
1419 let big = outcome.files.iter().find(|f| f.path == "big.txt").expect("big listed");
1420 assert!(big.oversize, "the oversize file is flagged (expansion disabled downstream)");
1421 }
1422}