Skip to main content

forge_agent/env/git_diff/
content.rs

1//! Bounded content read beside the stats scan: what a seat's changed
2//! files say, as raw unified-diff hunks.
3//!
4//! The sibling of [`super::scan`], not a layer inside it: the stats scan
5//! is polled (1 Hz on a moving tree, 10 s when still) and its `prev` is
6//! the PR-lookup cache, while this read is taken for a seat's record on
7//! the reads that encode one - a cold load, a reconnect, a seat swap -
8//! and never polled.
9//!
10//! One parser for both readers: the hunks and statuses are
11//! [`super::hunks`]' own, and only the caps below are this read's. A
12//! record is re-encoded per read and re-sent on reconnect, so no read
13//! may carry an unbounded diff, and every cap has a flag beside it
14//! rather than a silence.
15//!
16//! Both layers the snapshot carries are read with the refs the caller has
17//! just established - `HEAD` for the worktree layer, and the base the drawing
18//! read refreshed for the branch-ahead one - so a file's row and its content
19//! describe one tree, with `base` saying how current that base is.
20
21use 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
34/// Unified-diff context lines each per-file fetch pins. git's own
35/// default: enough to read a change in place, small enough that a
36/// one-line edit to a huge file does not carry the file.
37pub const CONTEXT_LINES: u32 = 3;
38
39/// Carried lines per file. A generated file's rewrite must not carry its
40/// whole body into a record that is re-sent on every subscribe.
41pub const MAX_FILE_LINES: usize = 400;
42
43/// Carried bytes per file - the cap a file of few but long lines meets
44/// first.
45pub const MAX_FILE_BYTES: usize = 32 * 1024;
46
47/// Carried line text across the whole read, both layers together. The
48/// JSON envelope the record adds beside it is not counted here; the
49/// bound is on the diff's own content.
50pub const MAX_TOTAL_BYTES: usize = 512 * 1024;
51
52/// The most files the read fetches content for, both layers together.
53/// Each fetch is a subprocess, so this is the read's work bound where
54/// the byte caps are its payload bound; a file past it keeps its status
55/// and is flagged truncated.
56pub const MAX_FILES: usize = 100;
57
58/// The changed files of a seat's two diff layers, bounded and flagged.
59///
60/// A layer answers in [`LayerState`]'s three states, exactly as the
61/// snapshot's layer does: `Populated` carries the files, `Clean` says
62/// the layer had nothing to show, `ScanFailed` says the read could not
63/// be taken.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct DiffContent {
66    pub worktree: LayerState<Vec<FileContent>>,
67    pub branch_ahead: LayerState<Vec<FileContent>>,
68    /// How current the base the branch layer was read against is. `None` when
69    /// there is no branch layer to describe.
70    pub base: Option<BaseState>,
71    /// Why the base fetch did not run, in git's own words, beside `base` for
72    /// the same reason: a page says what failed rather than only that the base
73    /// could not be checked. `None` when the fetch ran or no branch layer is
74    /// drawn.
75    pub base_error: Option<String>,
76    /// Content this read withheld: a file cut by a cap, a file past the
77    /// file cap, or a fetch that failed. The per-file flags say which.
78    pub truncated: bool,
79}
80
81/// One changed file's bounded content.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct FileContent {
84    pub path: String,
85    /// The path a rename or copy came from.
86    pub old_path: Option<String>,
87    pub status: FileStatus,
88    /// git reported the entry as binary: the status is the whole answer,
89    /// and `hunks` is empty.
90    pub binary: bool,
91    /// The entry is a submodule (a gitlink): its content is two commit
92    /// ids rather than file lines, so `hunks` is empty.
93    pub submodule: bool,
94    /// This file's content was withheld: cut by a per-file cap, past the
95    /// file cap, or the fetch failed.
96    pub truncated: bool,
97    pub hunks: Vec<Hunk>,
98}
99
100/// The read's budget, spent as content is carried across both layers.
101#[derive(Debug, Default)]
102struct Caps {
103    /// Files carried so far.
104    files: usize,
105    /// Content bytes carried so far.
106    bytes: usize,
107    /// Whether anything was withheld. Sticky.
108    withheld: bool,
109}
110
111/// One commit's own diff as bounded content.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct CommitContent {
114    pub files: LayerState<Vec<FileContent>>,
115    /// Something was withheld for the budget, the same flag the record's
116    /// two-layer read carries.
117    pub truncated: bool,
118}
119
120/// Read one commit's own diff as bounded content, under its own budget.
121///
122/// The ref spec is [`super::hunks::scan_commit`]'s, so the two reads
123/// describe the same range; the caps are this read's, spent fresh
124/// because the answer stands alone rather than sharing a record's one
125/// budget. `Clean` for a diff with nothing in it, which for a commit is
126/// an empty change rather than a missing one.
127pub async fn scan_commit(cwd: &Path, sha: &str) -> CommitContent {
128    scan_commit_within(cwd, sha, COMMAND_TIMEOUT).await
129}
130
131/// [`scan_commit`] with its command budget spelled: **the budget is a
132/// bound on the machine, not on the read**, spent starting each child the
133/// read spawns.
134pub 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        // A sha that names no commit is the scan's own failure, the state a
137        // sha git cannot read answers everywhere else.
138        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
145/// Read both layers of `snapshot` for `cwd` into bounded content.
146///
147/// `base` is what the read that draws this diff found when it refreshed the
148/// remote-tracking base first; it rides the answer so the page can label a
149/// base it could not refresh. It is carried only when there is a branch layer
150/// to describe.
151///
152/// Infallible like the stats scan: a layer's failure collapses to
153/// [`LayerState::ScanFailed`], a file's to its own `truncated` flag, and
154/// the read's to [`DiffContent::truncated`].
155pub 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
165/// [`scan`] with its command budget spelled; the budget bounds the
166/// machine's start of each child, not the read's own work.
167///
168/// `base_ref` pins the branch range to one revision (a sha the drawing read
169/// resolved) instead of the snapshot's ref name, so this read and the stats
170/// scan beside it cannot straddle two refs. `base_error` is why the refresh
171/// could not run, carried beside the state it left.
172pub 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            // The snapshot's own invariant is that a populated layer has a
190            // resolved default; a violated one is a failure to report, not
191            // an empty answer.
192            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
204/// Read one layer: its file list, then one bounded fetch per file.
205///
206/// **The fetches run a wave at a time, and each wave is folded as it
207/// lands.** A section is a raw diff of up to the subprocess output cap,
208/// so holding every completed one for the whole read would materialise
209/// hundreds of megabytes to carry half a megabyte; a wave is the same
210/// in-flight count the overlay's reader holds, and folding drops the raw
211/// text before the next wave starts.
212async 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    // Nothing changed is the layer's `Clean`, the state the snapshot
224    // collapses a change-free layer to as well.
225    if entries.is_empty() {
226        return LayerState::Clean;
227    }
228    // Only the files the fold will still carry are fetched.
229    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
246/// One file's diff section, `None` when git would not answer it (a crash
247/// or a read past the subprocess cap). A binary body and an empty one
248/// both answer `Some`.
249async 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
260/// One entry and its fetched section as a file row. `None` is a fetch
261/// that was withheld or never made: the row keeps its status and says
262/// its content is missing.
263fn 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
293/// Carry one section's hunks under the per-file caps and the read's
294/// total, answering what is carried and whether anything was cut.
295///
296/// A cut lands mid-hunk: the carried part keeps the hunk's start and
297/// takes counts describing the lines it actually carries, so a renderer
298/// reads a header that matches its body.
299fn 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
352/// git prints one of these instead of hunks when it will not diff the
353/// bytes - on both sides, or the null side of a new/deleted file.
354fn section_is_binary(section: &str) -> bool {
355    section.lines().any(|line| line.starts_with("Binary files "))
356}
357
358/// A gitlink: git prints the mode 160000 on one of the entry's header
359/// lines (`index a..b 160000`, `new file mode 160000`, `old mode
360/// 160000`), and the body is a pair of commit ids.
361fn 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
368/// The ref range the branch-ahead layer is read with: the merge-base
369/// form the stats scan composes (`<default>...HEAD`), so a file's row
370/// and its content stay one tree after a squash-merge.
371fn 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    /// The snapshot these tests read, taken with no project env: they
388    /// exercise the content walk, never identity.
389    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    /// One modified-path entry, the shape `--name-status` yields for it.
439    fn entry(path: &str) -> NameStatusEntry {
440        NameStatusEntry { status: FileStatus::Modified, path: path.to_owned(), old_path: None }
441    }
442
443    /// A one-hunk section carrying `lines` as additions, under a caller's
444    /// own hunk header.
445    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    /// [`section_with_header`] under the plain all-added header.
458    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    /// One layer's content, folded in entry order: the same per-file
474    /// `fold_one` the scan's wave loop calls, driven from fixtures here
475    /// with every section already in hand.
476    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    // ---- the fold, from fixtures ----
490
491    /// A modification carries the parser's lines as `git diff` printed
492    /// them: the status, the path, and one hunk of context and addition.
493    #[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, &sections, &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    /// A deletion carries its removed lines.
519    #[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, &sections, &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    /// A rename row keeps both sides: the new path the status names, and
544    /// the old path only `--name-status` still carries.
545    #[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, &sections, &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    /// A binary file is its status and its flag: no lines exist to carry,
561    /// and the empty body must not read as an empty change.
562    #[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, &sections, &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    /// A submodule entry is flagged the same way: its "content" is two
579    /// commit ids, not file lines.
580    #[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, &sections, &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    /// The per-file line cap cuts mid-hunk, recomputes the kept hunk's
596    /// counts to what is carried, and flags the file and the read.
597    #[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        // A header with starts of its own, so the cut's survival of them is
602        // visible rather than implied by a zero.
603        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, &sections, &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    /// The per-file byte cap cuts a file of few but long lines well
624    /// before its line count reaches the line cap.
625    #[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, &sections, &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    /// The total cap is the read's own, not a file's: with the budget
646    /// nearly spent, the next file is cut and the one after still lists.
647    #[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        // 31 bytes of the 512 KiB total are left: exactly one 31-byte line.
654        let mut caps = Caps { bytes: 512 * 1024 - 31, ..Caps::default() };
655
656        let files = populated(fold_layer(&entries, &sections, &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    /// The file cap is a count across the read: past it a row keeps its
667    /// status and is flagged, and no content rides for it.
668    #[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, &sections, &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    /// A file whose fetch was withheld is listed and flagged, never
690    /// dropped.
691    #[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, &sections, &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    /// The branch layer compares against the merge base - the same
708    /// three-dot range the stats scan's branch-ahead layer uses - so a
709    /// squash-merge cannot empty it where a two-dot diff would.
710    #[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    // ---- a real repository, end to end ----
721
722    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    /// The worktree layer reads what the dirty tree changed: a
746    /// modification, a deletion, an addition and a binary, each with the
747    /// status and the flag that describe it.
748    #[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    /// A feature branch with uncommitted edits reads both layers, with
795    /// the refs the stats scan used: `HEAD` for the worktree, the merge
796    /// base for the branch.
797    #[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    /// One commit's read is that commit's own diff, both from a root
822    /// (no parent to diff against) and from a child, and it does not
823    /// carry the rest of the branch with it.
824    #[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        // A branch read would carry both; the commit read must not.
854        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    /// The read's future must be `Send`: the socket's record encode awaits
864    /// it in a task the upgrade demands `Send` for. A regression to the
865    /// buffered stream the overlay fetches with fails here at compile time
866    /// with the compiler's misleading #100013 lifetime error, which is
867    /// exactly the trap this pin exists to name.
868    #[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    /// The wire spellings `socket.md`'s `diff` row names: a client narrows
886    /// these tags, so one of them moving is a wire change the book and
887    /// every client would have to follow, and the fixture would follow it
888    /// silently.
889    #[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    /// The per-file fetch pins the three context lines this read
909    /// declares: a change in a long file carries the change and a little
910    /// around it, not the file.
911    #[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    /// A file past the per-file line cap flags the file AND the read,
936    /// through the real fetch path: every other truncation fixture drives
937    /// `fold_layer` directly, so this is the one that pins the read-level
938    /// flag's true side end to end.
939    #[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        // Every line edited, so the diff is ~2x the file and passes the cap.
947        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    /// The tree can go clean between the stats scan and the content
965    /// read: the layer answers `Clean` rather than an empty populated
966    /// list, the same state the snapshot collapses a change-free layer
967    /// to.
968    #[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        // Cleaned between the two reads, so the content read finds nothing.
979        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    /// The wave seam, crossed: more files than one wave holds, with the
988    /// first wave spending the read's whole total, so the file over the
989    /// seam can only be cut by a budget that survived it - a per-wave
990    /// `Caps` reset would carry it whole - and the rows can only come
991    /// back in `--name-status` order.
992    #[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        // A full wave of files whose diffs each pass the per-file byte cap:
1000        // sixteen files carrying 81 of 401-byte lines each spend 519,696 of
1001        // the 524,288-byte total, leaving 4,592.
1002        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        // Sorted last, so it lands in the second wave: 100 of 101-byte
1008        // lines, past every per-file cap and only the total can cut it, at
1009        // the 45 lines 4,592 bytes hold.
1010        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    /// A clean tree on the default branch has nothing to read in either
1046    /// layer.
1047    #[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    /// The content read carries the base state its caller refreshed, so the
1064    /// page can label a stale base beside the files it drew. A read that
1065    /// dropped it would draw those files as the branch's own change.
1066    #[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    /// Outside a repository both layers are empty, and the gate on the
1087    /// work row - not this read - is what says why.
1088    #[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    /// A layer the stats scan could not read is mirrored as failed - the
1102    /// content read does not quietly answer it empty.
1103    #[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}