Skip to main content

forge_agent/env/git_diff/
hunks.rs

1//! Per-file hunk scanner for the `/diff` overlay.
2//!
3//! Extends [`super`]'s numstat-based snapshot with full unified-diff
4//! content needed to render the GitHub-style diff viewer. Runs up to
5//! three subprocess calls per scan:
6//!
7//! 1. `git diff <target> --name-status` - file list with M/A/D/R
8//!    classification.
9//! 2. `git diff <target> --no-ext-diff` - full unified-diff body.
10//!    The flag defeats user-configured difftastic / delta so the
11//!    parser sees standard unified-diff format.
12//! 3. `git ls-files --others --exclude-standard` - untracked files
13//!    (only when `target == "HEAD"`, capped at `MAX_UNTRACKED_FILES`
14//!    entries, each read up to `MAX_UNTRACKED_FILE_SIZE` bytes).
15//!
16//! Single-shot - never polled. The `/diff` overlay calls [`scan`] on
17//! open and renders the resulting `Vec<FileHunks>` for the duration
18//! of the view; refreshing mid-review would invalidate pending
19//! comments anchored to specific lines.
20//!
21//! Always returns a [`ScanOutcome`] - `files` may be empty on a
22//! genuinely clean tree OR when one of the subprocess calls hit
23//! Failed / Oversize. The `scanner_ok` flag distinguishes the two
24//! so a downstream renderer can show "scan failed" rather than
25//! miscategorising the empty result as "no changes."
26
27use std::path::Path;
28use std::time::Duration;
29
30use serde::{Deserialize, Serialize};
31
32use super::{COMMAND_TIMEOUT, GitOutput, run_git_within};
33
34/// Skip the untracked-file scan entirely when the working tree
35/// has more than this many untracked entries. A fresh-repo state
36/// with hundreds of untracked files would otherwise dump everything
37/// into the overlay. The Inspector GIT section is unaffected; only
38/// the `/diff` overlay's untracked surface is suppressed (no `U`
39/// rows render in the rail when the cap is exceeded).
40///
41/// Public so a view can surface the cap in the
42/// "+N untracked suppressed (cap M)" notice - the number lives
43/// here as the source of truth.
44pub const MAX_UNTRACKED_FILES: usize = 4;
45
46/// Per-file size ceiling for untracked content surfaced in the diff
47/// body. Above this we keep the rail entry but skip reading bytes.
48pub(crate) const MAX_UNTRACKED_FILE_SIZE: u64 = 1024;
49
50/// One file's worth of hunks, ordered as `git diff` emitted them.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct FileHunks {
53    pub path: String,
54    pub status: FileStatus,
55    pub hunks: Vec<Hunk>,
56    /// The full-context fetch tripped the oversize cap, so `hunks` is a
57    /// bounded fallback (or empty) rather than the pinned snapshot the
58    /// overlay expands from. The overlay renders it normally but disables
59    /// context expansion for this file.
60    pub oversize: bool,
61}
62
63/// File-level change classification from `git diff --name-status`,
64/// plus a synthetic [`Untracked`](FileStatus::Untracked) variant for
65/// files surfaced from `git ls-files --others`.
66///
67/// Covers every status code git emits: M/A/D/R/C/T/U. Unknown
68/// codes (`X` - internal error indicator, `B` - broken pairing)
69/// fire a WARN log and skip the entry rather than collapsing to
70/// `Modified`; legitimate user-visible types stay distinct.
71///
72/// The definition moved to primitives because it crosses to views
73/// now; this re-export keeps every existing reader.
74pub use forge_primitives::git::FileStatus;
75
76/// A single `@@`-delimited hunk inside one file's diff. `lines`
77/// preserves the original unified-diff ordering with each line
78/// tagged as context, added, or removed - the renderer iterates
79/// them directly to draw the `+ / - / ` markers and per-side line
80/// numbers without re-classifying anything.
81#[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    /// Count of lines added in this hunk (`+` markers).
92    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    /// Count of lines removed in this hunk (`-` markers).
98    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    /// Total additions across all hunks in this file.
106    pub fn added_count(&self) -> u32 {
107        self.hunks.iter().map(Hunk::added_count).fold(0u32, u32::saturating_add)
108    }
109
110    /// Total removals across all hunks in this file.
111    pub fn removed_count(&self) -> u32 {
112        self.hunks.iter().map(Hunk::removed_count).fold(0u32, u32::saturating_add)
113    }
114}
115
116/// Per-line classification inside a hunk.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum DiffLineKind {
120    /// Line appears in both old and new - printed without a marker
121    /// (or with a leading space, depending on the renderer).
122    Context,
123    /// Line is in the new file only - rendered with `+` marker.
124    Added,
125    /// Line is in the old file only - rendered with `-` marker.
126    Removed,
127}
128
129/// One rendered diff line. Carries its kind, raw text (no leading
130/// marker), and per-side line numbers so the renderer can show a
131/// gutter without recomputing positions.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct DiffLine {
134    pub kind: DiffLineKind,
135    pub text: String,
136    /// Line number in the old file. `None` for `Added` lines.
137    pub old_line: Option<u32>,
138    /// Line number in the new file. `None` for `Removed` lines.
139    pub new_line: Option<u32>,
140}
141
142/// Outcome of a single scan invocation. `files` carries whatever
143/// was parsed; `scanner_ok` is `false` when at least one of the
144/// underlying subprocess calls hit `Failed` or `Oversize` - the
145/// file list may be partial or empty, and a downstream renderer
146/// should distinguish that case from a genuine clean tree so the
147/// user knows whether to retry vs. accept "no changes."
148///
149/// `untracked_suppressed` carries the count of untracked files
150/// that DIDN'T make it into `files` because the working tree had
151/// more than `MAX_UNTRACKED_FILES` untracked entries. The renderer
152/// surfaces this so the user knows the rail is incomplete; without
153/// it, a fresh-repo state with 5+ untracked files looks identical
154/// to a tree with zero.
155#[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/// One commit in the review stepper's ordered list. `sha` is the full
163/// hash (used to scope the per-commit diff), `short_sha` the abbreviated
164/// form for display, `subject` the commit's first line. `body` is the
165/// message beyond the subject, filled lazily by [`scan_commit_body`] when
166/// the stepper first lands on the commit (empty until then, and for a
167/// subject-only commit).
168#[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
176/// Git's canonical empty-tree object (SHA-1). Diffing a parentless
177/// root commit against it yields the commit's full contents as
178/// additions - `<sha>^` doesn't resolve for a root, so this stands in
179/// for the non-existent parent.
180const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
181
182/// Field separator for the `git log` format below - the ASCII unit
183/// separator, which never appears in a commit subject.
184const LOG_FIELD_SEP: char = '\u{1f}';
185
186/// List the commits in `<target>..HEAD`, oldest first, for the diff
187/// overlay's commit stepper. Empty when the range has no commits
188/// (dirty-tree-only review, `/diff HEAD`, or `target` unresolvable).
189pub async fn scan_commits(cwd: &Path, target: &str) -> Vec<CommitMeta> {
190    scan_commits_within(cwd, target, COMMAND_TIMEOUT).await
191}
192
193/// [`scan_commits`] with its command budget spelled; see the module's
194/// [`scan_within`].
195pub 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            // Subject may be empty (a commit with a blank message); the
210            // sha/short_sha are the identifying fields, so only bail
211            // when those are missing.
212            let subject = parts.next().unwrap_or_default().to_owned();
213            if sha.is_empty() {
214                return None;
215            }
216            // Body stays empty here; a multi-line body would break this
217            // line-per-commit parse. It is fetched lazily per commit.
218            Some(CommitMeta { sha, short_sha, subject, body: String::new() })
219        })
220        .collect()
221}
222
223/// Scan a single commit's own diff (`<sha>^..<sha>`, or against the
224/// empty tree for a parentless root commit) into per-file hunks. Never
225/// surfaces untracked files - a commit's diff is closed over its own
226/// changes.
227pub async fn scan_commit(cwd: &Path, sha: &str) -> ScanOutcome {
228    scan_commit_within(cwd, sha, COMMAND_TIMEOUT).await
229}
230
231/// [`scan_commit`] with its command budget spelled; see the module's
232/// [`scan_within`].
233pub 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        // Nothing a revision could be built from: the scan reports its own
236        // failure rather than a diff of some other tree.
237        return ScanOutcome { files: Vec::new(), scanner_ok: false, untracked_suppressed: 0 };
238    };
239    scan_with_ref_spec(cwd, &ref_spec, budget).await
240}
241
242/// Resolve the two-endpoint `git diff` range for one commit, or `None` when
243/// `sha` names no commit git can read.
244///
245/// **The sha is resolved first, because it is a revision off a wire**: the
246/// range this builds is handed to `git diff` as one argv, and an
247/// option-shaped sha would be read as an option of the diff's own - the same
248/// gate [`super::file::resolve_commit`] applies to the file read, and the
249/// same reason. Resolved, it is a commit id, which cannot be a flag.
250///
251/// Uses `git rev-list --parents -n 1 <id>` (always exits 0, so no spurious
252/// non-zero-exit WARN) to detect a parent: `<id> <parent…>` means a normal
253/// commit (`<id>^..<id>`, first-parent for a merge), a lone `<id>` means a
254/// root commit (diff against the empty tree).
255pub(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        // Best-effort on any anomaly: the normal-commit spec, whose
263        // scan just reports scanner failure rather than panicking.
264        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => {
265            Some(format!("{commit}^..{commit}"))
266        }
267    }
268}
269
270/// Fetch a commit's message body - everything after the subject line,
271/// with the trailing newline trimmed. Empty for a subject-only commit.
272/// Kept out of [`scan_commits`] because a multi-line body would break
273/// its line-per-commit `git log` parse; the stepper fetches it lazily
274/// per commit alongside the hunks.
275pub async fn scan_commit_body(cwd: &Path, sha: &str) -> String {
276    scan_commit_body_within(cwd, sha, COMMAND_TIMEOUT).await
277}
278
279/// [`scan_commit_body`] with its command budget spelled; see the module's
280/// [`scan_within`].
281///
282/// The sha is resolved first, the same gate the commit scan's own ref spec
283/// takes: it is a revision off a wire, and `git show` would read an
284/// option-shaped one as its own option. A sha that names no commit reads as
285/// no body.
286pub 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
296/// Max in-flight `git diff -- <path>` subprocesses during a scan.
297/// macOS's default soft open-file limit is ~256 and every git child
298/// holds stdin/stdout/stderr pipes plus a handful of git internals
299/// (~6-10 FDs); firing all N at once on a 100+ file refactor
300/// branch trips `EMFILE`. 16 keeps total well under the FD ceiling
301/// while still giving a 16× speedup over a sequential loop.
302pub(super) const MAX_INFLIGHT_FETCHES: usize = 16;
303
304/// Unified-diff context radius the per-file fetch pins at open: large
305/// enough to cover realistic inter-hunk gaps (so expanding reveals hidden
306/// lines from the cached snapshot without a live re-fetch), but bounded
307/// so a big file with a small change doesn't emit its whole content and
308/// trip the oversize cap. A gap wider than this reveals up to the cap.
309const PINNED_CONTEXT_LINES: u32 = 2_000;
310
311/// Fallback context radius for a file whose pinned full-context fetch was
312/// still oversize - a bounded diff that renders normally (expansion is
313/// disabled for it). git's own default is 3.
314const FALLBACK_CONTEXT_LINES: u32 = 3;
315
316/// Top-level scanner entry. `target` is a plain ref / SHA / `"HEAD"`
317/// (not a `..`/`...` range - the helper `diff_ref_spec` picks the
318/// shape for us). `"HEAD"` runs a two-dot working-tree diff
319/// (uncommitted only); any other ref runs `git diff <target>...HEAD`,
320/// the three-dot form that surfaces what THIS branch contributed
321/// vs the merge-base with `target`. Three-dot matches the Inspector
322/// GIT panel's branch-ahead scanner (which composes
323/// `format!("{default}...HEAD")` via the same helper), so the panel
324/// and overlay agree on what's diffable even after a squash-merge
325/// where two-dot `git diff main HEAD` would be empty.
326///
327/// Returns a [`ScanOutcome`] - `files` carries one [`FileHunks`]
328/// per changed file in the order `git diff --name-status` reports
329/// them (untracked files when `target == "HEAD"` come at the end
330/// as [`FileStatus::Untracked`]), `scanner_ok` is `false` when any
331/// subprocess hit `Failed`/`Oversize` so the caller can surface
332/// "scan failed, check logs" rather than miscategorising the empty
333/// result as "no changes."
334pub async fn scan(cwd: &Path, target: &str) -> ScanOutcome {
335    scan_within(cwd, target, COMMAND_TIMEOUT).await
336}
337
338/// [`scan`] with its command budget spelled: the shell gives each child
339/// this reader spawns the 10 s `COMMAND_TIMEOUT`, and a test gives them one
340/// of its own. **The budget is a bound on the machine, not on the read**: it
341/// is spent starting each child, so a loaded machine can hold a child's
342/// first output past the shell's 10 s and blank a file's hunk section.
343pub 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    // Untracked only when the caller is asking for working-tree
348    // semantics. `/diff main` from a feature branch already includes
349    // committed-since-main + uncommitted via the ref-vs-worktree
350    // compare; untracked files don't belong to that comparison.
351    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
361/// Scan a pre-resolved diff range into per-file hunks. `ref_spec` is
362/// already the exact shape git receives - `HEAD`, `main...HEAD`,
363/// `<sha>^..<sha>`, an empty-tree range, etc. Never surfaces untracked
364/// files, so `untracked_suppressed` is always 0.
365async 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        // Per-file concurrent fetch with bounded concurrency. Each
380        // `git diff <ref_spec> --no-ext-diff -- <path>` subprocess
381        // holds stdio pipes for its lifetime; firing all N
382        // simultaneously via `futures::future::join_all` blows the
383        // process's open-file limit on real refactor branches
384        // (~130 files × ~3-10 FDs each easily exceeds the macOS
385        // ~256 soft cap). `buffer_unordered(MAX_INFLIGHT_FETCHES)`
386        // caps in-flight subprocesses to a safe number while still
387        // giving ~MAX_INFLIGHT_FETCHES× speedup over a sequential
388        // loop. Order doesn't matter because we zip back into
389        // `files` by index, captured as the future's first item.
390        use futures::StreamExt;
391        // Clone the path list off `files` so the immutable borrow
392        // drops before the stream runs - we mutate `files[idx]`
393        // inside the consume loop.
394        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                // An oversize file is reported as Ok (with the flag set),
408                // so only a genuine subprocess crash flips scanner_ok - one
409                // huge file no longer blanks the whole scope.
410                FileScanOutcome::SubprocessFailed => {
411                    scanner_ok = false;
412                }
413            }
414        }
415    }
416
417    ScanOutcome { files, scanner_ok, untracked_suppressed: 0 }
418}
419
420/// Per-file fetch result. `SubprocessFailed` propagates back to `scan`
421/// so a single-file crash flips `scanner_ok` without poisoning the rest.
422/// `oversize` marks a file whose full-context fetch tripped the size cap
423/// and fell back to a bounded diff (or empty) - it renders but doesn't
424/// expand, and crucially does NOT flip the scope-wide `scanner_ok`.
425enum FileScanOutcome {
426    Ok { hunks: Vec<Hunk>, oversize: bool },
427    SubprocessFailed,
428}
429
430/// Translate a caller-supplied diff `target` into the git ref-spec
431/// the underlying subprocess receives.
432///
433/// - `HEAD` -> `HEAD` (two-dot semantics: working tree vs HEAD,
434///   which is what the user means by "uncommitted edits").
435/// - Anything else (branch name, SHA) -> `<target>...HEAD`
436///   (three-dot semantics: diff from the merge-base of `target` and
437///   `HEAD` to `HEAD`, i.e. "what did THIS branch contribute").
438///
439/// Why the asymmetry: after a squash-merge of the branch into the
440/// target, two-dot `git diff main HEAD` is empty (content already in
441/// main with a different SHA) while three-dot `git diff main...HEAD`
442/// still surfaces the branch's contributions vs the merge-base. The
443/// Inspector GIT panel uses three-dot via
444/// `forge_agent::env::git_diff::scan`'s `format!("{default}...HEAD")`
445/// at the branch-ahead layer; this helper keeps the overlay scanner
446/// in sync so the panel + overlay never disagree on what's diffable.
447fn diff_ref_spec(target: &str) -> String {
448    if target == "HEAD" { "HEAD".to_owned() } else { format!("{target}...HEAD") }
449}
450
451/// One file-diff subprocess result before it's folded into a
452/// [`FileScanOutcome`]: parsed hunks, the size cap, or a crash.
453enum DiffFetch {
454    Ok(Vec<Hunk>),
455    Oversize,
456    Failed,
457}
458
459/// Fetch + parse one file's diff at a given context radius. Binary /
460/// submodule entries and empty diffs return `Ok(vec![])`. Splits the
461/// oversize cap from a genuine subprocess crash so the caller can retry
462/// oversize at a smaller context instead of failing the whole scan.
463async 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(&section);
481    if hunks.is_empty() && contains_hunk_marker(&section) {
482        // Parser produced zero hunks despite seeing at least one
483        // `@@` marker. WARN so an operator hunting "I edited this
484        // file but it shows as no-diff" has a breadcrumb. The
485        // per-hunk malformed-header WARN inside parse_hunks already
486        // fires too; this rollup catches the section-level case.
487        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
499/// Fetch one file's hunks for the overlay, pinning at
500/// [`PINNED_CONTEXT_LINES`] so the returned hunks carry enough context
501/// for in-memory expansion. The `--` separator keeps a `--foo` path from
502/// being read as a flag.
503///
504/// A file whose pinned fetch trips the oversize cap falls back to a
505/// bounded [`FALLBACK_CONTEXT_LINES`] diff and is flagged `oversize` (it
506/// renders but can't expand); if even that is oversize (a huge fully-
507/// rewritten file) it keeps empty hunks and the flag. Either way it stays
508/// `Ok`, so one big file never flips the scope-wide `scanner_ok`.
509///
510/// `ref_spec` is the already-resolved diff range from [`diff_ref_spec`]
511/// (e.g. `HEAD`, or `main...HEAD`); callers pre-resolve it so the per-file
512/// and top-level scans agree.
513async 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/// One `git diff --name-status` line: the classification, the new path,
533/// and the old path a rename or copy names (absent for every other
534/// status). The old path survives here rather than in [`FileHunks`] so
535/// the content read can carry both sides of a rename while the overlay
536/// keeps the one it draws.
537#[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
544/// The status a `--name-status` code's leading letter means, or `None` for
545/// a code git grew that forge does not know.
546pub(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
559/// Parse `git diff --name-status` output into entries.
560///
561/// Status codes covered: M (modified), A (added), D (deleted),
562/// R (renamed), C (copied), T (typechange - symlink/submodule),
563/// U (unmerged - mid-merge-conflict). Unknown codes (X, B, or
564/// anything else git might emit in future) fire a WARN log and
565/// drop the entry so an operator can spot the gap.
566pub(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            // For R/C the line is "R100\told\tnew" - the new path is
572            // the last tab-separated field. For plain M/A/D it's
573            // "M\tpath" - also last. `next_back` is the
574            // DoubleEndedIterator-aware way to grab the tail without
575            // walking the whole split.
576            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            // Whatever sits between the code and the new path is the
590            // old path, which only a rename or copy names.
591            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
602/// Parse `git diff --name-status` output into a list of `FileHunks`
603/// stubs. Hunks are filled in by the per-file fetch pass.
604fn 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
616/// True when `section` contains at least one `@@` line - used by
617/// the per-file fetch path to distinguish "no body to parse"
618/// (binary / submodule, no log) from "parser couldn't extract
619/// hunks despite headers being present" (WARN-worthy).
620fn contains_hunk_marker(section: &str) -> bool {
621    section.lines().any(|line| line.starts_with("@@"))
622}
623
624/// Parse one file's diff section into individual hunks. Hunks are
625/// `@@`-delimited; each line in a hunk body starts with one of
626/// `-` (removed), `+` (added), ` ` (context), or `\` (newline
627/// annotation, dropped).
628pub(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            // Malformed @@ header - never expected on healthy git
643            // output, but if it does fire (corrupt diff, custom
644            // pretty-format leaking through) the operator needs to
645            // see which line broke the parse.
646            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                // `\ No newline at end of file` - legitimate diff
687                // marker, intentionally dropped.
688            } else if !line.is_empty() {
689                // Hunk-body line with an unexpected leading byte.
690                // Healthy unified-diff output never produces this;
691                // if it fires we want a breadcrumb (custom pretty-
692                // format leakage, corruption, encoding mishap).
693                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            // Truly empty lines fall through silently - they
703            // bracket the final hunk in some `git diff` outputs.
704        }
705        hunks.push(Hunk { old_start, old_count, new_start, new_count, lines: diff_lines });
706    }
707    hunks
708}
709
710/// Parse `@@ -OLD_START[,OLD_COUNT] +NEW_START[,NEW_COUNT] @@`.
711/// Missing counts default to 1 per the unified-diff spec. Returns
712/// `None` on malformed headers (caller skips the hunk).
713fn 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
732/// Read untracked files from `git ls-files --others
733/// --exclude-standard` and synthesise a [`FileHunks`] for each one
734/// (status [`Untracked`](FileStatus::Untracked), single all-added
735/// hunk). Caps total count at `MAX_UNTRACKED_FILES` - when the
736/// working tree has more untracked entries than that, returns an
737/// empty `Vec` so a fresh-repo state doesn't dump hundreds of files
738/// into the overlay.
739///
740/// Returns `(files, ok, suppressed)`:
741/// - `ok = false` only when the underlying `ls-files` subprocess
742///   hit `Failed` / `Oversize`. The cap-exceeded path keeps
743///   `ok = true` because git ran fine.
744/// - `suppressed` is the count of untracked files that didn't make
745///   it into `files` due to `MAX_UNTRACKED_FILES`. Zero when the
746///   tree was under the cap (or empty); positive when the cap
747///   kicked in so the renderer can surface the truncation count.
748async 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                        // File showed up in `ls-files --others` and
789                        // its metadata read OK, but the content
790                        // read failed (permissions flipped between
791                        // calls, broken symlink target, decoding
792                        // error). Keep the rail entry but log so
793                        // an operator can correlate "U row with no
794                        // content" to the IO error.
795                        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                // ls-files --others normally returns regular files
809                // only, but submodules / sockets / fifos can sneak
810                // in on exotic setups. Log so an operator hunting
811                // "why is this file showing as untracked-empty"
812                // has a breadcrumb - the rail row stays but its
813                // body is unviewable.
814                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                // File exists but exceeds MAX_UNTRACKED_FILE_SIZE.
825                // Documented cap behaviour; the WARN gives an
826                // operator something to grep when a specific file
827                // doesn't appear with its content.
828                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        // X (internal error) and B (broken pairing) shouldn't
904        // appear in normal git output but are reserved codes; the
905        // parser drops them rather than miscategorising. A WARN
906        // log fires per drop but tests don't capture tracing.
907        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    // Raw strings throughout - unified-diff context lines are
931    // single-space-prefixed, and Rust's `\<newline>` continuation
932    // strips that space when it eats the next line's leading
933    // whitespace. Raw strings preserve all whitespace verbatim.
934
935    #[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        // The added line carries its new-file line number; old_line
957        // is None so a gutter renderer leaves that side blank.
958        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        // Mixed hunk: 1 context, 1 remove, 1 add, 1 context.
1013        // Old:  L1 line_a, L2 line_b, L3 line_d
1014        // New:  L1 line_a, L2 line_c, L3 line_d
1015        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        // Per unified-diff spec, missing count means 1.
1049        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        // Real git output appends the enclosing function name after
1056        // the second @@. Parser must ignore it.
1057        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        // Regression: per-file fetch yields the full output starting
1083        // with `diff --git` / `index` / `--- a/` / `+++ b/` headers.
1084        // `parse_hunks` must skip those and only consume from the
1085        // first `@@` marker onward.
1086        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    /// `diff_ref_spec` keeps `HEAD` as a two-dot working-tree diff
1106    /// (otherwise `HEAD...HEAD` would be empty since HEAD is its
1107    /// own merge-base with itself), and converts any other target
1108    /// to a three-dot range. Three-dot is the same form the
1109    /// Inspector GIT panel uses for branch-ahead at
1110    /// `forge_agent::env::git_diff::scan` (`format!("{default}...HEAD")`);
1111    /// keeping them in sync prevents the overlay-empty / panel-says-
1112    /// 142-lines asymmetry that surfaces after squash-merges.
1113    #[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        // Commit SHAs benefit from three-dot the same way branches
1128        // do - diff vs merge-base, not vs the tip directly.
1129        assert_eq!(diff_ref_spec("abc1234"), "abc1234...HEAD");
1130    }
1131
1132    // ---- commit-stepper scan (scan_commits / scan_commit) ----
1133
1134    // `git init -b` needs git >= 2.28; init without -b then point HEAD
1135    // via symbolic-ref for portability (mirrors the parent module's
1136    // helper).
1137    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    /// Build main+init then a `feat` branch with three commits, each
1176    /// adding a distinct file. Shared by the stepper scan tests.
1177    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        // HEAD..HEAD is empty; a dirty-tree-only review has no stepper.
1208        assert!(scan_commits_within(dir.path(), "HEAD", TEST_GIT_BUDGET).await.is_empty());
1209    }
1210
1211    /// **The readers take the budget they are given.** A ZERO budget refuses
1212    /// a read at its first `git` child, a helper deeper in refuses the same
1213    /// way, and the same repo reads under the budget these cases use - the
1214    /// seam `run_git_within` now offers every hunk and content read,
1215    /// production keeping `COMMAND_TIMEOUT`.
1216    #[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        // Every refusal below runs against a `git` that cannot answer inside
1228        // the zero budget: the machine's own can, and did, on CI (#2249).
1229        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        // A child the reader spawns LATER takes the same budget, so the
1237        // claim has a witness beyond the entry's first call: the per-file
1238        // fetch, which only runs once the name-status has listed a file.
1239        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        // And the content reader carries the same seam.
1246        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        // The budget these cases use reads the same repo.
1277        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    /// **A sha off a wire never reaches `git diff` as an option.** The range
1296    /// this scan builds is one argv, and `git diff --output=<file>^..…`
1297    /// writes files wherever the caller points it; the resolution is the
1298    /// gate, and this is the pin (the file read's own is beside it, in
1299    /// `file.rs`).
1300    #[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        // Parentless root: the empty-tree fallback surfaces its full
1328        // contents as an added file rather than erroring on `<sha>^`.
1329        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        // `%b` starts after the subject, so the subject never appears.
1354        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        // The internal blank line between paragraphs survives.
1358        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    // ---- full-context scan (pin-at-open snapshot source) ----
1373
1374    #[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        // A 20-line file; edits near the top (line 3) and near the bottom
1379        // (line 17) leave a ~13-line unchanged gap between them. At full
1380        // context git folds them into one hunk carrying every line, so the
1381        // overlay can narrow + expand from that single cached snapshot.
1382        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        // The previously-hidden middle lines are all present in the snapshot.
1395        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        // A file whose diff blows the 8 MiB stdout cap: one ~5 MiB line
1407        // changed to another, so old+new exceeds the cap even at the
1408        // bounded fallback context.
1409        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}