1use std::path::Path;
22
23use serde::{Deserialize, Serialize};
24
25use forge_primitives::git_diff::{BaseState, GitDiffSnapshot, LayerState};
26
27use super::hunks::{
28 FileStatus, Hunk, MAX_INFLIGHT_FETCHES, NameStatusEntry, parse_hunks, parse_name_status_entries,
29};
30use std::time::Duration;
31
32use super::{COMMAND_TIMEOUT, GitOutput, run_git_within};
33
34pub const CONTEXT_LINES: u32 = 3;
38
39pub const MAX_FILE_LINES: usize = 400;
42
43pub const MAX_FILE_BYTES: usize = 32 * 1024;
46
47pub const MAX_TOTAL_BYTES: usize = 512 * 1024;
51
52pub const MAX_FILES: usize = 100;
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct DiffContent {
66 pub worktree: LayerState<Vec<FileContent>>,
67 pub branch_ahead: LayerState<Vec<FileContent>>,
68 pub base: Option<BaseState>,
71 pub base_error: Option<String>,
76 pub truncated: bool,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct FileContent {
84 pub path: String,
85 pub old_path: Option<String>,
87 pub status: FileStatus,
88 pub binary: bool,
91 pub submodule: bool,
94 pub truncated: bool,
97 pub hunks: Vec<Hunk>,
98}
99
100#[derive(Debug, Default)]
102struct Caps {
103 files: usize,
105 bytes: usize,
107 withheld: bool,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct CommitContent {
114 pub files: LayerState<Vec<FileContent>>,
115 pub truncated: bool,
118}
119
120pub async fn scan_commit(cwd: &Path, sha: &str) -> CommitContent {
128 scan_commit_within(cwd, sha, COMMAND_TIMEOUT).await
129}
130
131pub async fn scan_commit_within(cwd: &Path, sha: &str, budget: Duration) -> CommitContent {
135 let Some(ref_spec) = super::hunks::commit_ref_spec(cwd, sha, budget).await else {
136 return CommitContent { files: LayerState::ScanFailed, truncated: false };
139 };
140 let mut caps = Caps::default();
141 let files = scan_layer(cwd, &ref_spec, &mut caps, budget).await;
142 CommitContent { files, truncated: caps.withheld }
143}
144
145pub async fn scan<'a>(
156 cwd: &'a Path,
157 snapshot: &'a GitDiffSnapshot,
158 base: Option<BaseState>,
159 base_ref: Option<&str>,
160 base_error: Option<&str>,
161) -> DiffContent {
162 scan_within(cwd, snapshot, base, base_ref, base_error, COMMAND_TIMEOUT).await
163}
164
165pub async fn scan_within<'a>(
173 cwd: &'a Path,
174 snapshot: &'a GitDiffSnapshot,
175 base: Option<BaseState>,
176 base_ref: Option<&str>,
177 base_error: Option<&str>,
178 budget: Duration,
179) -> DiffContent {
180 let mut caps = Caps::default();
181 let worktree = match &snapshot.worktree {
182 LayerState::Populated(_) => scan_layer(cwd, "HEAD", &mut caps, budget).await,
183 LayerState::Clean => LayerState::Clean,
184 LayerState::ScanFailed => LayerState::ScanFailed,
185 };
186 let branch_ahead = match &snapshot.branch_ahead {
187 LayerState::Populated(_) => match base_ref.or(snapshot.default_branch.as_deref()) {
188 Some(default) => scan_layer(cwd, &branch_range(default), &mut caps, budget).await,
189 None => LayerState::ScanFailed,
193 },
194 LayerState::Clean => LayerState::Clean,
195 LayerState::ScanFailed => LayerState::ScanFailed,
196 };
197 let (base, base_error) = match &snapshot.branch_ahead {
198 LayerState::Populated(_) => (base, base_error.map(str::to_owned)),
199 LayerState::Clean | LayerState::ScanFailed => (None, None),
200 };
201 DiffContent { worktree, branch_ahead, base, base_error, truncated: caps.withheld }
202}
203
204async fn scan_layer(
213 cwd: &Path,
214 ref_spec: &str,
215 caps: &mut Caps,
216 budget: Duration,
217) -> LayerState<Vec<FileContent>> {
218 let entries = match run_git_within(cwd, &["diff", ref_spec, "--name-status"], budget).await {
219 GitOutput::Ok(raw) => parse_name_status_entries(&raw),
220 GitOutput::Empty => Vec::new(),
221 GitOutput::Failed | GitOutput::Oversize => return LayerState::ScanFailed,
222 };
223 if entries.is_empty() {
226 return LayerState::Clean;
227 }
228 let to_fetch = entries.len().min(MAX_FILES.saturating_sub(caps.files));
230 let mut files = Vec::with_capacity(entries.len());
231 for wave in entries[..to_fetch].chunks(MAX_INFLIGHT_FETCHES) {
232 let sections = futures::future::join_all(
233 wave.iter().map(|entry| fetch_section(cwd, ref_spec, &entry.path, budget)),
234 )
235 .await;
236 for (entry, section) in wave.iter().zip(sections) {
237 files.push(fold_one(entry, section.as_deref(), caps));
238 }
239 }
240 for entry in &entries[to_fetch..] {
241 files.push(fold_one(entry, None, caps));
242 }
243 LayerState::Populated(files)
244}
245
246async fn fetch_section(cwd: &Path, ref_spec: &str, path: &str, budget: Duration) -> Option<String> {
250 let context = format!("-U{CONTEXT_LINES}");
251 match run_git_within(cwd, &["diff", ref_spec, "--no-ext-diff", &context, "--", path], budget)
252 .await
253 {
254 GitOutput::Ok(section) => Some(section),
255 GitOutput::Empty => Some(String::new()),
256 GitOutput::Failed | GitOutput::Oversize => None,
257 }
258}
259
260fn fold_one(entry: &NameStatusEntry, section: Option<&str>, caps: &mut Caps) -> FileContent {
264 let carried = caps.files < MAX_FILES;
265 let Some(section) = section.filter(|_| carried) else {
266 caps.withheld = true;
267 return FileContent {
268 path: entry.path.clone(),
269 old_path: entry.old_path.clone(),
270 status: entry.status,
271 binary: false,
272 submodule: false,
273 truncated: true,
274 hunks: Vec::new(),
275 };
276 };
277 caps.files += 1;
278 let binary = section_is_binary(section);
279 let submodule = !binary && section_is_submodule(section);
280 let (hunks, cut) =
281 if binary || submodule { (Vec::new(), false) } else { fold_hunks(section, caps) };
282 FileContent {
283 path: entry.path.clone(),
284 old_path: entry.old_path.clone(),
285 status: entry.status,
286 binary,
287 submodule,
288 truncated: cut,
289 hunks,
290 }
291}
292
293fn fold_hunks(section: &str, caps: &mut Caps) -> (Vec<Hunk>, bool) {
300 let mut carried = Vec::new();
301 let mut file_lines = 0usize;
302 let mut file_bytes = 0usize;
303 let mut cut = false;
304 for hunk in parse_hunks(section) {
305 let total = hunk.lines.len();
306 let mut kept = Vec::new();
307 let mut old_count = 0u32;
308 let mut new_count = 0u32;
309 let mut stopped = false;
310 for line in &hunk.lines {
311 let cost = line.text.len() + 1;
312 if file_lines + 1 > MAX_FILE_LINES
313 || file_bytes + cost > MAX_FILE_BYTES
314 || caps.bytes + cost > MAX_TOTAL_BYTES
315 {
316 stopped = true;
317 break;
318 }
319 file_lines += 1;
320 file_bytes += cost;
321 caps.bytes += cost;
322 if line.old_line.is_some() {
323 old_count += 1;
324 }
325 if line.new_line.is_some() {
326 new_count += 1;
327 }
328 kept.push(line.clone());
329 }
330 if kept.len() == total {
331 carried.push(hunk);
332 } else if !kept.is_empty() {
333 carried.push(Hunk {
334 old_start: hunk.old_start,
335 old_count,
336 new_start: hunk.new_start,
337 new_count,
338 lines: kept,
339 });
340 }
341 if stopped {
342 cut = true;
343 break;
344 }
345 }
346 if cut {
347 caps.withheld = true;
348 }
349 (carried, cut)
350}
351
352fn section_is_binary(section: &str) -> bool {
355 section.lines().any(|line| line.starts_with("Binary files "))
356}
357
358fn section_is_submodule(section: &str) -> bool {
362 section
363 .lines()
364 .take_while(|line| !line.starts_with("@@"))
365 .any(|line| line.split_whitespace().last() == Some("160000"))
366}
367
368fn branch_range(default_branch: &str) -> String {
372 format!("{default_branch}...HEAD")
373}
374
375#[cfg(test)]
376mod tests {
377 use std::process::Command as StdCommand;
378
379 use tempfile::TempDir;
380
381 use super::super::TEST_GIT_BUDGET;
382 use super::super::hunks::DiffLineKind;
383 use super::*;
384 use forge_primitives::git::GitBranch;
385 use forge_primitives::git_diff::RepoGate;
386
387 async fn scan_stats(cwd: &std::path::Path, prev: Option<&GitDiffSnapshot>) -> GitDiffSnapshot {
390 super::super::scan_within(
391 cwd,
392 &std::collections::HashMap::new(),
393 prev,
394 None,
395 forge_primitives::git_diff::PrRefresh::Fetch,
396 true,
397 super::super::TEST_GIT_BUDGET,
398 )
399 .await
400 }
401
402 const MODIFIED: &str = r"diff --git a/x.rs b/x.rs
403index 1111111..2222222 100644
404--- a/x.rs
405+++ b/x.rs
406@@ -1,3 +1,4 @@
407 line1
408 line2
409+new line
410 line3
411";
412
413 const DELETED: &str = r"diff --git a/gone.rs b/gone.rs
414deleted file mode 100644
415index 1111111..0000000
416--- a/gone.rs
417+++ /dev/null
418@@ -1,2 +0,0 @@
419-old one
420-old two
421";
422
423 const BINARY: &str = r"diff --git a/blob.bin b/blob.bin
424new file mode 100644
425index 0000000..2222222
426Binary files /dev/null and b/blob.bin differ
427";
428
429 const SUBMODULE: &str = r"diff --git a/sub b/sub
430index 1111111..2222222 160000
431--- a/sub
432+++ b/sub
433@@ -1 +1 @@
434-Subproject commit 1111111111111111111111111111111111111111
435+Subproject commit 2222222222222222222222222222222222222222
436";
437
438 fn entry(path: &str) -> NameStatusEntry {
440 NameStatusEntry { status: FileStatus::Modified, path: path.to_owned(), old_path: None }
441 }
442
443 fn section_with_header(header: &str, lines: &[String]) -> String {
446 let mut body = String::new();
447 for line in lines {
448 body.push('+');
449 body.push_str(line);
450 body.push('\n');
451 }
452 format!(
453 "diff --git a/x.rs b/x.rs\nindex 1111111..2222222 100644\n--- a/x.rs\n+++ b/x.rs\n{header}\n{body}"
454 )
455 }
456
457 fn added_section(lines: &[String]) -> String {
459 section_with_header(&format!("@@ -0,0 +1,{} @@", lines.len()), lines)
460 }
461
462 fn populated(layer: LayerState<Vec<FileContent>>) -> Vec<FileContent> {
463 let LayerState::Populated(files) = layer else {
464 panic!("the layer is populated, got {layer:?}");
465 };
466 files
467 }
468
469 fn carried_lines(files: &[FileContent]) -> usize {
470 files.iter().flat_map(|file| &file.hunks).map(|hunk| hunk.lines.len()).sum()
471 }
472
473 fn fold_layer(
477 entries: &[NameStatusEntry],
478 sections: &[Option<String>],
479 caps: &mut Caps,
480 ) -> LayerState<Vec<FileContent>> {
481 let files = entries
482 .iter()
483 .enumerate()
484 .map(|(idx, entry)| fold_one(entry, sections.get(idx).and_then(Option::as_deref), caps))
485 .collect();
486 LayerState::Populated(files)
487 }
488
489 #[test]
494 fn fold_carries_a_modified_files_lines() {
495 let entries = [entry("x.rs")];
496 let sections = [Some(MODIFIED.to_owned())];
497 let mut caps = Caps::default();
498
499 let files = populated(fold_layer(&entries, §ions, &mut caps));
500
501 assert_eq!(files.len(), 1, "one entry, one row");
502 let file = &files[0];
503 assert_eq!(file.path, "x.rs", "the row carries the path `--name-status` named");
504 assert_eq!(file.status, FileStatus::Modified, "and the status it classified");
505 assert!(!file.truncated, "a small file is not flagged");
506 assert!(!caps.withheld, "and the read withholds nothing");
507 assert_eq!(file.hunks.len(), 1, "one section, one hunk");
508 assert_eq!(file.hunks[0].lines.len(), 4, "context, context, added, context");
509 assert!(
510 file.hunks[0]
511 .lines
512 .iter()
513 .any(|line| line.kind == DiffLineKind::Added && line.text == "new line"),
514 "the added line arrives with its kind and its text",
515 );
516 }
517
518 #[test]
520 fn fold_carries_a_deleted_files_removed_lines() {
521 let entries = [NameStatusEntry {
522 status: FileStatus::Deleted,
523 path: "gone.rs".to_owned(),
524 old_path: None,
525 }];
526 let sections = [Some(DELETED.to_owned())];
527 let mut caps = Caps::default();
528
529 let files = populated(fold_layer(&entries, §ions, &mut caps));
530
531 let file = &files[0];
532 assert_eq!(file.status, FileStatus::Deleted, "the row keeps the deletion's status");
533 assert!(!file.truncated, "and a small deletion is not flagged");
534 let removed: Vec<&str> = file.hunks[0]
535 .lines
536 .iter()
537 .filter(|line| line.kind == DiffLineKind::Removed)
538 .map(|line| line.text.as_str())
539 .collect();
540 assert_eq!(removed, vec!["old one", "old two"], "both removed lines arrive, in order");
541 }
542
543 #[test]
546 fn a_rename_row_carries_the_path_it_came_from() {
547 let entries = parse_name_status_entries("R100\told.rs\tnew.rs");
548 let sections = [Some(added_section(&["still here".to_owned()]))];
549 let mut caps = Caps::default();
550
551 let files = populated(fold_layer(&entries, §ions, &mut caps));
552
553 let file = &files[0];
554 assert_eq!(file.status, FileStatus::Renamed, "the rename's own status crosses");
555 assert_eq!(file.path, "new.rs", "with the new path as the row's");
556 assert_eq!(file.old_path.as_deref(), Some("old.rs"), "and the old path kept beside it");
557 assert_eq!(file.hunks.len(), 1, "the moved file still carries its change");
558 }
559
560 #[test]
563 fn a_binary_section_is_flagged_and_carries_no_hunks() {
564 let entries = [entry("blob.bin")];
565 let sections = [Some(BINARY.to_owned())];
566 let mut caps = Caps::default();
567
568 let files = populated(fold_layer(&entries, §ions, &mut caps));
569
570 let file = &files[0];
571 assert!(file.binary, "the binary flag is what says the body is not an empty change");
572 assert!(!file.submodule, "and a blob is not the gitlink case");
573 assert!(!file.truncated, "nothing was cut: the file simply has no lines");
574 assert!(file.hunks.is_empty(), "no lines exist to carry for a binary");
575 assert!(!caps.withheld, "and no content was withheld");
576 }
577
578 #[test]
581 fn a_submodule_section_is_flagged_and_carries_no_hunks() {
582 let entries = [entry("sub")];
583 let sections = [Some(SUBMODULE.to_owned())];
584 let mut caps = Caps::default();
585
586 let files = populated(fold_layer(&entries, §ions, &mut caps));
587
588 let file = &files[0];
589 assert!(file.submodule, "the gitlink's mode is what flags it");
590 assert!(!file.binary, "and a gitlink is not the binary case");
591 assert!(!file.truncated, "nothing was cut: the shas are simply not carried");
592 assert!(file.hunks.is_empty(), "a sha bump is not file content");
593 }
594
595 #[test]
598 fn the_per_file_line_cap_cuts_and_flags_the_file() {
599 let added: Vec<String> = (0..MAX_FILE_LINES + 5).map(|i| format!("line {i}")).collect();
600 let entries = [entry("x.rs")];
601 let sections = [Some(section_with_header("@@ -61,405 +77,410 @@", &added))];
604 let mut caps = Caps::default();
605
606 let files = populated(fold_layer(&entries, §ions, &mut caps));
607
608 let file = &files[0];
609 assert!(
610 file.truncated,
611 "a file past the 400-line per-file cap is flagged (MAX_FILE_LINES is 400)",
612 );
613 assert!(caps.withheld, "and the read says content was withheld");
614 assert_eq!(carried_lines(&files), 400, "exactly the 400-line cap is carried");
615 let kept = &file.hunks[0];
616 assert_eq!(kept.lines.len(), 400, "the surviving hunk is the carried lines alone");
617 assert_eq!(kept.new_count, 400, "the kept hunk's counts describe what is carried");
618 assert_eq!(kept.old_count, 0, "and the old side counts only the removed lines carried");
619 assert_eq!(kept.old_start, 61, "with the hunk's own old start kept");
620 assert_eq!(kept.new_start, 77, "and its own new start");
621 }
622
623 #[test]
626 fn the_per_file_byte_cap_cuts_and_flags_the_file() {
627 let line = "x".repeat(200);
628 let added: Vec<String> = (0..MAX_FILE_LINES).map(|_| line.clone()).collect();
629 let entries = [entry("x.rs")];
630 let sections = [Some(added_section(&added))];
631 let mut caps = Caps::default();
632
633 let files = populated(fold_layer(&entries, §ions, &mut caps));
634
635 let file = &files[0];
636 assert!(
637 file.truncated,
638 "200-byte lines pass the 32 KiB per-file byte cap (MAX_FILE_BYTES is 32768)",
639 );
640 assert_eq!(carried_lines(&files), 163, "163 lines of 201 bytes fit the 32 KiB cap");
641 assert!(carried_lines(&files) < MAX_FILE_LINES, "the byte cap, not the line cap, cut this");
642 assert!(caps.withheld, "and the read says content was withheld");
643 }
644
645 #[test]
648 fn the_total_cap_stops_the_read_across_files() {
649 let line = "y".repeat(30);
650 let added: Vec<String> = (0..3).map(|_| line.clone()).collect();
651 let entries = [entry("a.rs"), entry("b.rs")];
652 let sections = [Some(added_section(&added)), Some(added_section(&added))];
653 let mut caps = Caps { bytes: 512 * 1024 - 31, ..Caps::default() };
655
656 let files = populated(fold_layer(&entries, §ions, &mut caps));
657
658 assert!(files[0].truncated, "the first file is cut by the total cap");
659 assert_eq!(carried_lines(&files[..1]), 1, "one 31-byte line fits the remaining budget");
660 assert!(files[1].truncated, "and the next file carries nothing");
661 assert!(files[1].hunks.is_empty(), "the exhausted budget carries no lines for it");
662 assert!(caps.withheld, "the read says its content was withheld");
663 assert!(caps.bytes <= 512 * 1024, "the read never carries past its 512 KiB total");
664 }
665
666 #[test]
669 fn the_file_cap_leaves_the_rest_listed_with_no_content() {
670 let entries: Vec<NameStatusEntry> =
671 (0..=MAX_FILES).map(|i| entry(&format!("f{i}.rs"))).collect();
672 let sections: Vec<Option<String>> =
673 (0..=MAX_FILES).map(|_| Some(added_section(&["one".to_owned()]))).collect();
674 let mut caps = Caps::default();
675
676 let files = populated(fold_layer(&entries, §ions, &mut caps));
677
678 assert_eq!(files.len(), MAX_FILES + 1, "every file is still listed");
679 assert_eq!(MAX_FILES, 100, "the read's file cap is the 100 socket.md states");
680 let last_carried = files.get(MAX_FILES - 1).expect("the 100th file rides within the cap");
681 let first_withheld = files.get(MAX_FILES).expect("the 101st file is still listed");
682 assert!(!last_carried.truncated, "the 100th file is within the 100-file cap");
683 assert!(first_withheld.truncated, "the 101st is past the 100-file cap");
684 assert!(first_withheld.hunks.is_empty(), "and carries no content");
685 assert_eq!(first_withheld.path, format!("f{MAX_FILES}.rs"), "while keeping its status row");
686 assert!(caps.withheld, "and the read says content was withheld");
687 }
688
689 #[test]
692 fn a_file_whose_fetch_was_withheld_is_listed_and_flagged() {
693 let entries = [entry("kept.rs"), entry("lost.rs")];
694 let sections = [Some(added_section(&["one".to_owned()])), None];
695 let mut caps = Caps::default();
696
697 let files = populated(fold_layer(&entries, §ions, &mut caps));
698
699 assert_eq!(files.len(), 2, "both rows are listed");
700 assert!(!files[0].truncated, "the fetched file carries its content unflagged");
701 assert!(files[1].truncated, "a withheld fetch flags the file");
702 assert!(files[1].hunks.is_empty(), "and it carries no lines");
703 assert_eq!(files[1].path, "lost.rs", "while its own path stays on the row");
704 assert!(caps.withheld, "and the read withholds");
705 }
706
707 #[test]
711 fn the_branch_layer_compares_against_the_merge_base() {
712 assert_eq!(branch_range("main"), "main...HEAD", "a local default reads three-dot");
713 assert_eq!(
714 branch_range("origin/main"),
715 "origin/main...HEAD",
716 "and so does a remote-tracking one",
717 );
718 }
719
720 fn git(dir: &TempDir, args: &[&str]) {
723 let out =
724 StdCommand::new("git").arg("-C").arg(dir.path()).args(args).output().expect("run git");
725 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
726 }
727
728 fn init_repo(dir: &TempDir, branch: &str) {
729 git(dir, &["init", "-q"]);
730 git(dir, &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")]);
731 git(dir, &["config", "user.email", "test@example.com"]);
732 git(dir, &["config", "user.name", "Test"]);
733 git(dir, &["config", "commit.gpgsign", "false"]);
734 }
735
736 fn write_file(dir: &TempDir, name: &str, contents: &str) {
737 std::fs::write(dir.path().join(name), contents).expect("write file");
738 }
739
740 fn commit_all(dir: &TempDir, message: &str) {
741 git(dir, &["add", "-A"]);
742 git(dir, &["commit", "-q", "-m", message]);
743 }
744
745 #[tokio::test(flavor = "current_thread")]
749 async fn a_dirty_tree_reads_its_worktree_layer() {
750 let dir = tempfile::tempdir().expect("tempdir");
751 init_repo(&dir, "main");
752 write_file(&dir, "kept.rs", "one\ntwo\n");
753 write_file(&dir, "gone.rs", "old one\nold two\n");
754 commit_all(&dir, "base");
755 write_file(&dir, "kept.rs", "one\ntwo\nthree\n");
756 git(&dir, &["rm", "-q", "gone.rs"]);
757 write_file(&dir, "added.rs", "new\n");
758 std::fs::write(dir.path().join("blob.bin"), b"\0\0binary\0").expect("binary write");
759 git(&dir, &["add", "added.rs", "blob.bin"]);
760
761 let snapshot = scan_stats(dir.path(), None).await;
762 let content = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
763
764 assert!(
765 matches!(content.branch_ahead, LayerState::Clean),
766 "on the default branch there is no branch layer",
767 );
768 let files = populated(content.worktree);
769 let row = |path: &str| files.iter().find(|file| file.path == path).expect(path);
770 let lines = |path: &str| -> Vec<(DiffLineKind, String)> {
771 row(path)
772 .hunks
773 .iter()
774 .flat_map(|hunk| &hunk.lines)
775 .map(|line| (line.kind, line.text.clone()))
776 .collect()
777 };
778 assert_eq!(row("kept.rs").status, FileStatus::Modified);
779 assert!(
780 lines("kept.rs").contains(&(DiffLineKind::Added, "three".to_owned())),
781 "the modification carries its added line",
782 );
783 assert_eq!(row("gone.rs").status, FileStatus::Deleted);
784 assert!(
785 lines("gone.rs").contains(&(DiffLineKind::Removed, "old one".to_owned())),
786 "the deletion carries its removed lines",
787 );
788 assert_eq!(row("added.rs").status, FileStatus::Added);
789 assert!(row("blob.bin").binary, "a NUL-carrying file is the binary case");
790 assert!(row("blob.bin").hunks.is_empty());
791 assert!(!content.truncated, "a handful of small files withholds nothing");
792 }
793
794 #[tokio::test(flavor = "current_thread")]
798 async fn a_feature_branch_reads_both_layers() {
799 let dir = tempfile::tempdir().expect("tempdir");
800 init_repo(&dir, "main");
801 write_file(&dir, "base.rs", "base\n");
802 commit_all(&dir, "base");
803 git(&dir, &["checkout", "-q", "-b", "feat/x"]);
804 write_file(&dir, "feat.rs", "fn x() {}\n");
805 commit_all(&dir, "feat");
806 write_file(&dir, "base.rs", "base\nedited\n");
807
808 let snapshot = scan_stats(dir.path(), None).await;
809 let content = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
810
811 let worktree = populated(content.worktree);
812 assert_eq!(worktree.len(), 1);
813 assert_eq!(worktree[0].path, "base.rs", "the worktree layer is the uncommitted edit");
814 let ahead = populated(content.branch_ahead);
815 assert_eq!(ahead.len(), 1);
816 assert_eq!(ahead[0].path, "feat.rs", "the branch layer is the commit the branch added");
817 assert_eq!(ahead[0].status, FileStatus::Added);
818 assert!(!content.truncated);
819 }
820
821 #[tokio::test(flavor = "current_thread")]
825 async fn a_commit_reads_its_own_diff_alone() {
826 let dir = tempfile::tempdir().expect("tempdir");
827 init_repo(&dir, "main");
828 write_file(&dir, "base.rs", "base\n");
829 commit_all(&dir, "root");
830 let root = String::from_utf8(
831 StdCommand::new("git")
832 .arg("-C")
833 .arg(dir.path())
834 .args(["rev-parse", "HEAD"])
835 .output()
836 .expect("rev-parse")
837 .stdout,
838 )
839 .expect("utf8")
840 .trim()
841 .to_owned();
842 write_file(&dir, "second.rs", "second\n");
843 git(&dir, &["checkout", "-q", "-b", "feat/x"]);
844 commit_all(&dir, "second");
845
846 let root_content = scan_commit_within(dir.path(), &root, TEST_GIT_BUDGET).await;
847 assert!(!root_content.truncated, "a one-file commit withholds nothing");
848 let files = populated(root_content.files);
849 assert_eq!(files.len(), 1, "the root commit's read is its own file");
850 assert_eq!(files[0].path, "base.rs", "the file the root added, not the child's");
851 assert_eq!(files[0].status, FileStatus::Added);
852
853 let snapshot = scan_stats(dir.path(), None).await;
855 let whole = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
856 let LayerState::Populated(entries) = whole.branch_ahead else {
857 panic!("the branch layer is populated");
858 };
859 let all: Vec<&str> = entries.iter().map(|file| file.path.as_str()).collect();
860 assert!(all.contains(&"second.rs"), "the branch read carries the child's file");
861 }
862
863 #[test]
869 fn the_read_future_is_send() {
870 fn assert_send<T: Send>(_: T) {}
871 let snapshot = GitDiffSnapshot {
872 branch: GitBranch::Named("main".to_owned()),
873 default_branch: Some("main".to_owned()),
874 repo_gate: RepoGate::InRepo,
875 pushed_sha: None,
876 worktree: LayerState::Clean,
877 branch_ahead: LayerState::Clean,
878 pr: None,
879 closes: Vec::new(),
880 pr_fetched_at: None,
881 };
882 assert_send(scan(std::path::Path::new("/"), &snapshot, None, None, None));
883 }
884
885 #[test]
890 fn the_wire_spellings_are_the_ones_the_book_names() {
891 let clean: LayerState<Vec<FileContent>> = LayerState::Clean;
892 assert_eq!(serde_json::to_value(&clean).expect("encodes"), serde_json::json!("clean"));
893 let failed: LayerState<Vec<FileContent>> = LayerState::ScanFailed;
894 assert_eq!(
895 serde_json::to_value(&failed).expect("encodes"),
896 serde_json::json!("scan_failed"),
897 );
898 assert_eq!(
899 serde_json::to_value(FileStatus::Typechange).expect("encodes"),
900 serde_json::json!("typechange"),
901 );
902 assert_eq!(
903 serde_json::to_value(DiffLineKind::Removed).expect("encodes"),
904 serde_json::json!("removed"),
905 );
906 }
907
908 #[tokio::test(flavor = "current_thread")]
912 async fn a_fetch_pins_the_context_the_read_declares() {
913 let dir = tempfile::tempdir().expect("tempdir");
914 init_repo(&dir, "main");
915 let base: Vec<String> = (1..=9).map(|i| format!("line {i}")).collect();
916 write_file(&dir, "long.rs", &format!("{}\n", base.join("\n")));
917 commit_all(&dir, "base");
918 let edited: Vec<String> = (1..=9)
919 .map(|i| if i == 5 { "line 5 CHANGED".to_owned() } else { format!("line {i}") })
920 .collect();
921 write_file(&dir, "long.rs", &format!("{}\n", edited.join("\n")));
922
923 let snapshot = scan_stats(dir.path(), None).await;
924 let content = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
925
926 let files = populated(content.worktree);
927 let file = files.iter().find(|file| file.path == "long.rs").expect("long.rs");
928 assert_eq!(
929 carried_lines(std::slice::from_ref(file)),
930 8,
931 "3 context above + the change + 3 below: CONTEXT_LINES is 3",
932 );
933 }
934
935 #[tokio::test(flavor = "current_thread")]
940 async fn a_file_past_the_line_cap_flags_the_file_and_the_read() {
941 let dir = tempfile::tempdir().expect("tempdir");
942 init_repo(&dir, "main");
943 let base: Vec<String> = (1..=MAX_FILE_LINES + 20).map(|i| format!("line {i}")).collect();
944 write_file(&dir, "long.rs", &format!("{}\n", base.join("\n")));
945 commit_all(&dir, "base");
946 let edited: Vec<String> = base.iter().map(|line| format!("{line} edited")).collect();
948 write_file(&dir, "long.rs", &format!("{}\n", edited.join("\n")));
949
950 let snapshot = scan_stats(dir.path(), None).await;
951 let content = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
952
953 assert!(content.truncated, "the read says its content was withheld");
954 let files = populated(content.worktree);
955 let file = files.iter().find(|file| file.path == "long.rs").expect("long.rs");
956 assert!(file.truncated, "and the file says its own content was cut");
957 assert_eq!(
958 carried_lines(std::slice::from_ref(file)),
959 MAX_FILE_LINES,
960 "the carried lines stop at the per-file cap",
961 );
962 }
963
964 #[tokio::test(flavor = "current_thread")]
969 async fn a_layer_that_went_clean_between_reads_answers_clean() {
970 let dir = tempfile::tempdir().expect("tempdir");
971 init_repo(&dir, "main");
972 write_file(&dir, "kept.rs", "one\n");
973 commit_all(&dir, "base");
974 write_file(&dir, "kept.rs", "one\ntwo\n");
975
976 let snapshot = scan_stats(dir.path(), None).await;
977 assert!(snapshot.worktree.is_populated(), "the dirty tree is the premise");
978 write_file(&dir, "kept.rs", "one\n");
980
981 let content = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
982
983 assert!(matches!(content.worktree, LayerState::Clean));
984 assert!(!content.truncated);
985 }
986
987 #[tokio::test(flavor = "current_thread")]
993 async fn the_read_carries_its_budget_and_order_across_the_wave_seam() {
994 let dir = tempfile::tempdir().expect("tempdir");
995 init_repo(&dir, "main");
996 write_file(&dir, "base.txt", "base\n");
997 commit_all(&dir, "base");
998
999 let wide = format!("{}\n", "x".repeat(400));
1003 for i in 0..MAX_INFLIGHT_FETCHES {
1004 let name = format!("w1-{i:02}.rs");
1005 write_file(&dir, &name, &wide.repeat(100));
1006 }
1007 let seam = "z-seam.rs";
1011 write_file(&dir, seam, &format!("{}\n", "y".repeat(100)).repeat(100));
1012 git(&dir, &["add", "-A"]);
1013
1014 let snapshot = scan_stats(dir.path(), None).await;
1015 let content = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
1016
1017 assert!(content.truncated, "the read says its content was withheld");
1018 let files = populated(content.worktree);
1019 assert_eq!(files.len(), MAX_INFLIGHT_FETCHES + 1, "every changed file is listed");
1020 let paths: Vec<&str> = files.iter().map(|file| file.path.as_str()).collect();
1021 assert_eq!(paths[0], "w1-00.rs", "the rows come back in `--name-status` order");
1022 assert_eq!(paths[MAX_INFLIGHT_FETCHES - 1], "w1-15.rs", "through the first wave");
1023 assert_eq!(paths[MAX_INFLIGHT_FETCHES], seam, "and across the seam, still in order");
1024
1025 let carried: usize = files
1026 .iter()
1027 .flat_map(|file| &file.hunks)
1028 .flat_map(|hunk| &hunk.lines)
1029 .map(|line| line.text.len() + 1)
1030 .sum();
1031 assert!(carried <= 512 * 1024, "the total never carries past its 512 KiB");
1032 let over = files.last().expect("the seam file is the last row");
1033 assert!(over.truncated, "the seam file is cut by the budget the first wave left");
1034 assert_eq!(
1035 carried_lines(std::slice::from_ref(over)),
1036 45,
1037 "with exactly the 45 lines the 4,592 bytes past the seam hold",
1038 );
1039 assert!(
1040 files[..MAX_INFLIGHT_FETCHES].iter().all(|file| file.truncated),
1041 "and every first-wave file is cut by the 32 KiB per-file byte cap",
1042 );
1043 }
1044
1045 #[tokio::test(flavor = "current_thread")]
1048 async fn a_clean_tree_reads_no_layer() {
1049 let dir = tempfile::tempdir().expect("tempdir");
1050 init_repo(&dir, "main");
1051 write_file(&dir, "base.rs", "base\n");
1052 commit_all(&dir, "base");
1053
1054 let snapshot = scan_stats(dir.path(), None).await;
1055 let content = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
1056
1057 assert!(matches!(content.worktree, LayerState::Clean));
1058 assert!(matches!(content.branch_ahead, LayerState::Clean));
1059 assert!(content.base.is_none(), "a layer with nothing to describe has no base state");
1060 assert!(!content.truncated);
1061 }
1062
1063 #[tokio::test(flavor = "current_thread")]
1067 async fn the_content_read_carries_the_bases_state() {
1068 let dir = tempfile::tempdir().expect("tempdir");
1069 init_repo(&dir, "main");
1070 write_file(&dir, "base.rs", "base\n");
1071 commit_all(&dir, "base");
1072 git(&dir, &["update-ref", "refs/remotes/origin/main", "HEAD"]);
1073 git(&dir, &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
1074 git(&dir, &["checkout", "-q", "-b", "feat/x"]);
1075 write_file(&dir, "feat.rs", "fn x() {}\n");
1076 commit_all(&dir, "feat");
1077
1078 let snapshot = scan_stats(dir.path(), None).await;
1079 let base = Some(BaseState::Behind { behind: 2 });
1080 let content = scan_within(dir.path(), &snapshot, base, None, None, TEST_GIT_BUDGET).await;
1081
1082 assert_eq!(populated(content.branch_ahead).len(), 1, "the branch's own file is read");
1083 assert_eq!(content.base, base, "the caller's base state crosses beside its files");
1084 }
1085
1086 #[tokio::test(flavor = "current_thread")]
1089 async fn outside_a_repository_reads_no_layer() {
1090 let dir = tempfile::tempdir().expect("tempdir");
1091
1092 let snapshot = scan_stats(dir.path(), None).await;
1093 assert_eq!(snapshot.repo_gate, RepoGate::NotARepo);
1094 let content = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
1095
1096 assert!(matches!(content.worktree, LayerState::Clean));
1097 assert!(matches!(content.branch_ahead, LayerState::Clean));
1098 assert!(!content.truncated);
1099 }
1100
1101 #[tokio::test(flavor = "current_thread")]
1104 async fn a_scan_failed_layer_is_mirrored() {
1105 let dir = tempfile::tempdir().expect("tempdir");
1106 init_repo(&dir, "main");
1107 let snapshot = GitDiffSnapshot {
1108 branch: GitBranch::Named("main".to_owned()),
1109 default_branch: Some("main".to_owned()),
1110 repo_gate: RepoGate::InRepo,
1111 pushed_sha: None,
1112 worktree: LayerState::ScanFailed,
1113 branch_ahead: LayerState::Clean,
1114 pr: None,
1115 closes: Vec::new(),
1116 pr_fetched_at: None,
1117 };
1118
1119 let content = scan_within(dir.path(), &snapshot, None, None, None, TEST_GIT_BUDGET).await;
1120
1121 assert!(matches!(content.worktree, LayerState::ScanFailed), "a failed layer stays failed");
1122 assert!(matches!(content.branch_ahead, LayerState::Clean));
1123 }
1124}