Skip to main content

drep/diff/
mod.rs

1//! Which files changed, in git terms.
2//!
3//! Every command in `drep` that looks at "what changed" comes through here.
4//! `drep check --staged` for a pre-commit hook, `--diff <ref>` for a pre-push
5//! gate, and the cache key that deduplicates repeated LLM calls all need a
6//! stable answer to the same question. They shell out to git directly —
7//! `tokio::process::Command` rather than libgit2, because the only operations
8//! drep needs are the ones git's own CLI was built for, and a git CLI that
9//! misbehaves would surface as a real OS-level error rather than a translated
10//! library one.
11//!
12//! Two invariants matter more than the implementations:
13//!
14//! - "No files changed" must be **distinct** from "I could not ask git".
15//!   Conflating them is how a commit gate rubber-stamps the day the user's
16//!   git install breaks.
17//! - `current_commit_sha` is the one place this is reversed: it only feeds
18//!   a cache key, and a cache-key component must never take the analysis down.
19
20use std::ffi::{OsStr, OsString};
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use futures::StreamExt;
25
26use crate::files;
27
28mod git;
29pub mod hunks;
30mod prefix;
31mod quoting;
32mod uncommitted;
33
34use git::{GitEnv, committing_index, spawn_git, spawn_git_bytes};
35pub(crate) use git::{git_query, run_git, same_directory};
36use hunks::{Hunk, parse_unified_diff};
37use prefix::{from_prefix, hunks_from, paths_from, working_prefix};
38pub use uncommitted::{Uncommitted, diverging_from, uncommitted_paths};
39
40/// The well-known SHA for the empty git tree.
41///
42/// On a fresh `git init` (no commits yet) there is no `HEAD` to diff against,
43/// so drep diffs against this instead. Otherwise every first commit on a new
44/// repository would fail with "fatal: ambiguous argument 'HEAD'".
45const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
46
47/// How long `current_commit_sha` is willing to wait for `git` to answer.
48///
49/// Used to be wall-clock unbounded, which was fine until a hung `git` stalled
50/// the gate and blocked every commit. Five seconds is more than enough for a
51/// local `git rev-parse`; if it does not answer by then the answer is
52/// "unknown" and the cache key falls through.
53const SHA_TIMEOUT: Duration = Duration::from_secs(5);
54
55/// What went wrong shelling out to git.
56///
57/// Distinct from `std::io::Error` because the most common cause — git exits
58/// non-zero for "not a repository" — is not the same as "could not spawn
59/// git", and the two should not be displayed the same way.
60#[derive(Debug)]
61pub enum GitError {
62    /// `git` could not be spawned at all: missing binary, permission denied,
63    /// or the OS rejected the argv. This is fundamentally different from
64    /// git exiting non-zero.
65    Spawn(String),
66    /// `git` ran and refused. `code` is the exit status when git set one;
67    /// `stderr` is the last argument worth showing to a user.
68    NonZero { code: Option<i32>, stderr: String },
69}
70
71impl std::fmt::Display for GitError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            GitError::Spawn(msg) => write!(f, "git could not be spawned: {msg}"),
75            GitError::NonZero { code, stderr } => {
76                let code = code.map_or("<signal>".to_owned(), |c| c.to_string());
77                write!(f, "git exited {code}: {stderr}")
78            }
79        }
80    }
81}
82
83impl std::error::Error for GitError {}
84
85/// Whether `root` has any commit yet.
86///
87/// Single-purpose helper: kept here because it is git semantics, not file
88/// discovery, and the diff commands need to know this both for the empty-tree
89/// fallback and for the `changed_since` no-HEAD case.
90async fn has_head(root: &Path) -> bool {
91    run_git(root, &["rev-parse", "--verify", "HEAD"])
92        .await
93        .is_ok()
94}
95
96/// Resolve a path printed by `git rev-parse` against the queried repository.
97///
98/// Git prints an absolute path for some worktree layouts and a repository-
99/// relative path for others. Callers must not independently guess which form
100/// they received.
101pub(crate) async fn git_path(root: &Path, args: &[&str]) -> Result<PathBuf, GitError> {
102    let raw = run_git(root, args).await?;
103    let path = PathBuf::from(raw);
104    Ok(if path.is_absolute() {
105        path
106    } else {
107        root.join(path)
108    })
109}
110
111/// The working tree's top-level directory.
112///
113/// Here rather than beside its caller for the reason this module's header
114/// states: `run_git` is the only place drep spawns git, and its
115/// `GIT_DIR`/`GIT_WORK_TREE` scrubbing is what stops a hook's inherited
116/// environment answering about a different repository. Site policy is evaluated
117/// against this answer, and a marker checked against the wrong tree is a policy
118/// bypass rather than a cosmetic mistake.
119///
120/// Through `git_path` because `--show-toplevel` prints an absolute path for some
121/// worktree layouts and a relative one for others - the guess that helper exists
122/// to remove.
123pub(crate) async fn repository_root(root: &Path) -> Result<PathBuf, GitError> {
124    git_path(root, &["rev-parse", "--show-toplevel"]).await
125}
126
127/// Parse the newline-delimited output of `git diff --name-only` into paths,
128/// then keep only those the caller analyzes.
129///
130/// Empty lines are tolerated because git occasionally emits a trailing one
131/// depending on version and locale settings; the filter is the load-bearing
132/// half — it is what makes a diff query return files drep can do something
133/// with, and keeps lock/build output from inflating the work set.
134///
135/// Each line is decoded before the filter sees it: git quotes a name holding
136/// a byte such as `é` or `"`, and the quoted form matched no file class, so a
137/// staged `café.md` was silently absent from `lint-docs --staged`.
138///
139/// `wanted` is a parameter rather than a hardcoded `files::is_scan_target`
140/// because the file classes are disjoint and one command owns each: `check`
141/// asks for registered-language sources, `lint-docs` asks for markdown. With
142/// the predicate baked in, `lint-docs --staged` could not be expressed at all
143/// and the hook ran over the whole repository instead.
144fn filter_paths(output: &str, wanted: fn(&Path) -> bool) -> Vec<PathBuf> {
145    output
146        .lines()
147        .filter(|line| !line.trim().is_empty())
148        .map(quoting::decode)
149        .filter(|path| wanted(path))
150        .collect()
151}
152
153/// Every `git diff` drep runs starts with these, ahead of its selection.
154///
155/// drep parses the output, and git formats it from the user's configuration
156/// and the repository's attributes. Each option pins one thing a setting or
157/// attribute would otherwise change, and each such change made the parser find
158/// no hunk, or a hunk other than the committed text, which the gate then
159/// reports as reviewed:
160///
161/// - `core.quotePath=true` quotes every byte above 0x7f, so a name that is not
162///   UTF-8 arrives as escapes `quoting::decode` reverses exactly, rather than
163///   as raw bytes the lossy conversion of stdout replaces.
164/// - `diff.suppressBlankEmpty=false` keeps the leading space on a blank context
165///   line. Printed empty, the line was skipped and every line after it in the
166///   hunk carried the wrong number.
167/// - `--text` because `binary` or `-diff` in a committed `.gitattributes`, or
168///   a single NUL byte in the content, turns a source file's hunks into one
169///   `Binary files ... differ` line. Binary files outside the caller's file
170///   class are rendered as text too, then dropped unread.
171/// - `--no-textconv` and `--no-ext-diff` ignore a textconv driver,
172///   `diff.external` and `GIT_EXTERNAL_DIFF`, whose program (difftastic, say)
173///   prints something other than the committed text as a unified diff.
174/// - `--no-color` overrides `color.ui=always`, which wraps each header in
175///   escape codes even when stdout is a pipe.
176/// - `--src-prefix`/`--dst-prefix` restore the `a/` and `b/` that
177///   `diff.noprefix` removes and `diff.mnemonicPrefix` (`c/` and `i/` for
178///   `--cached`) and `diff.srcPrefix`/`diff.dstPrefix` replace.
179/// - `--no-relative` overrides `diff.relative`, which names each file from the
180///   working directory and leaves out every file outside it, so a commit run
181///   from a subdirectory passed its other files unreviewed. Every path is named
182///   from the top level, as `:<path>` resolves it when a staged file's content
183///   is read, and `prefix` converts it to the working directory's view before a
184///   query returns it.
185///
186/// `--diff-filter=ACMRT` is selection rather than format, and is here so the
187/// staged and branch queries state it once; `staged_files` says why deletions
188/// are left out. `T` is kept because a type change is how a symlink becomes a
189/// regular file, and leaving it out passed that file's whole content
190/// unreviewed.
191const DIFF: &[&str] = &[
192    "-c",
193    "core.quotePath=true",
194    "-c",
195    "diff.suppressBlankEmpty=false",
196    "diff",
197    "--text",
198    "--no-textconv",
199    "--no-ext-diff",
200    "--no-color",
201    "--src-prefix=a/",
202    "--dst-prefix=b/",
203    "--no-relative",
204    "--diff-filter=ACMRT",
205];
206
207/// Run `git diff` with its output format pinned by [`DIFF`], selecting with
208/// `selection`.
209async fn git_diff(root: &Path, selection: &[&str], env: GitEnv<'_>) -> Result<String, GitError> {
210    spawn_git(root, &[DIFF, selection].concat(), env).await
211}
212
213/// Files staged for commit, relative to `root`, that drep analyzes.
214///
215/// `--diff-filter=ACMRT` excludes deletions on purpose: a deleted file
216/// cannot be analyzed, and passing it on would look like an unreadable file
217/// rather than an absent one. The empty-tree fallback covers the
218/// initial-commit case (no `HEAD` yet).
219pub async fn staged_files(
220    root: &Path,
221    wanted: fn(&Path) -> bool,
222) -> Result<Vec<PathBuf>, GitError> {
223    let (names, prefix) = staged_diff(root, NAMES).await?;
224    Ok(paths_from(filter_paths(&names, wanted), &prefix))
225}
226
227/// How many staged files have their content read at once: one `git cat-file`
228/// each, bounded like drep's other child-process fan-outs.
229const CONTENT_READ_CONCURRENCY: usize = 4;
230
231/// Each staged file `wanted` accepts, with the content the commit records for it.
232///
233/// The content comes from the same index as the list, never from the working
234/// tree, which differs from it after a partial `git add` and under an index the
235/// committer chose. A file whose content cannot be read carries its error, as
236/// reading it from disk would.
237pub async fn staged_contents(
238    root: &Path,
239    wanted: fn(&Path) -> bool,
240) -> Result<Vec<(PathBuf, std::io::Result<String>)>, GitError> {
241    let view = StagedView::of(root).await?;
242    let paths = filter_paths(&view.diff(root, NAMES).await?, wanted);
243    let view = &view;
244    Ok(futures::stream::iter(paths)
245        .map(|path| async move {
246            let content = view.content(root, &path).await;
247            (from_prefix(&path, &view.prefix), content)
248        })
249        .buffered(CONTENT_READ_CONCURRENCY)
250        .collect()
251        .await)
252}
253
254/// `git diff --cached` in whichever output mode the caller wants, and the
255/// working directory's prefix its paths are to be read from.
256///
257/// The selection rules — the empty-tree fallback here, `--diff-filter=ACMRT`
258/// in [`DIFF`] — live once rather than in each of `staged_files` and
259/// `staged_hunks`. They were stated twice, and a change applied to one and not
260/// the other would make the file list and the hunk set disagree about what is
261/// in scope: drep would analyze a file the gate never listed, which is exactly
262/// the class of failure this module exists to prevent.
263async fn staged_diff(root: &Path, mode: &str) -> Result<(String, PathBuf), GitError> {
264    let view = StagedView::of(root).await?;
265    Ok((view.diff(root, mode).await?, view.prefix))
266}
267
268/// What the staged queries read: whether HEAD exists, the index the commit is being made from, and where `root` sits in the working tree.
269///
270/// Built once per query so the file list and the content read for it come from the same index.
271struct StagedView {
272    head: bool,
273    index: Option<PathBuf>,
274    prefix: PathBuf,
275}
276
277impl StagedView {
278    async fn of(root: &Path) -> Result<Self, GitError> {
279        let (head, index, prefix) =
280            tokio::join!(has_head(root), committing_index(root), working_prefix(root));
281        Ok(Self {
282            head,
283            index: index?,
284            prefix: prefix?,
285        })
286    }
287
288    fn env(&self) -> GitEnv<'_> {
289        self.index
290            .as_deref()
291            .map_or(GitEnv::Scrubbed, GitEnv::Index)
292    }
293
294    async fn diff(&self, root: &Path, mode: &str) -> Result<String, GitError> {
295        let selection: &[&str] = if self.head {
296            &["--cached", mode]
297        } else {
298            &["--cached", mode, EMPTY_TREE]
299        };
300        git_diff(root, selection, self.env()).await
301    }
302
303    /// The content the commit records for `path`: its blob in the index, as UTF-8 text.
304    ///
305    /// The `:<path>` spec is built from the path's bytes, so a name that is not
306    /// UTF-8, which `quoting::decode` keeps exactly, reaches git unchanged.
307    async fn content(&self, root: &Path, path: &Path) -> std::io::Result<String> {
308        let mut spec = OsString::from(":");
309        spec.push(path);
310        let args = [OsStr::new("cat-file"), OsStr::new("blob"), &spec];
311        let blob = spawn_git_bytes(root, &args, self.env())
312            .await
313            .map_err(std::io::Error::other)?;
314        String::from_utf8(blob).map_err(|_| {
315            std::io::Error::new(
316                std::io::ErrorKind::InvalidData,
317                "stream did not contain valid UTF-8",
318            )
319        })
320    }
321}
322
323/// `git diff <ref>...<HEAD|empty-tree>` in whichever output mode is wanted.
324///
325/// The three-dot spec is built once for the same reason as `staged_diff`: the
326/// merge-base semantics are a decision, and `changed_since`/`hunks_since` must
327/// not be able to drift apart on it.
328///
329/// A `git_ref` that begins with `-` is rejected before any git invocation.
330/// Without this guard, `drep check --diff --output=/tmp/x` would reach git
331/// as a flag — `--output=/tmp/x` is parsed by `git diff` as an option, not
332/// a ref. Passing `--` does not help: after `--`, git treats arguments as
333/// *paths*, and `--diff -- this/file` is "diff versus the path `this/file`"
334/// rather than "diff versus the ref `--`".
335async fn since_diff(
336    root: &Path,
337    git_ref: &str,
338    tip: Option<&str>,
339    mode: &str,
340) -> Result<String, GitError> {
341    for candidate in [Some(git_ref), tip].into_iter().flatten() {
342        refuse_flag_like(candidate)?;
343    }
344    // An explicit tip names the commit the caller means, which is not always
345    // the checked-out one. The pre-push hook is the case that forced this:
346    // git can push a ref that is not HEAD (`git push origin feature:feature`
347    // from another branch, or `git push --all`), and diffing against HEAD
348    // there reviews the wrong branch entirely and green-lights the pushed one
349    // unseen - the exact "unanalyzed reported as clean" failure the gate
350    // exists to prevent.
351    // No `EMPTY_TREE` fallback here, unlike `staged_diff`. A three-dot spec is
352    // a *symmetric difference between two commits*, and the empty tree is a
353    // tree - git rejects `<ref>...4b825dc` with "Invalid symmetric difference
354    // expression" whatever `<ref>` is. So the fallback could never produce a
355    // diff; it only turned "this repo has no commits" into a confusing message
356    // about symmetric differences. A repo with an unborn HEAD also has no ref
357    // to diff *from*, so there is nothing to salvage - say so plainly.
358    let ref_b = match tip {
359        Some(tip) => tip,
360        None if has_head(root).await => "HEAD",
361        None => {
362            return Err(GitError::NonZero {
363                code: None,
364                stderr: "this repository has no commits yet, so there is nothing to \
365                         diff against; use --staged before the first commit"
366                    .to_owned(),
367            });
368        }
369    };
370    let spec = format!("{git_ref}...{ref_b}");
371    git_diff(root, &[mode, &spec], GitEnv::Scrubbed).await
372}
373
374/// Refuse a ref that git would parse as an option.
375///
376/// Every query that hands a caller's ref to git applies it before spawning,
377/// for the reason [`since_diff`] gives: `--output=<file>` in a ref position is
378/// an option to `git diff`, and `--` cannot help because what follows it is a
379/// path.
380pub(crate) fn refuse_flag_like(candidate: &str) -> Result<(), GitError> {
381    if candidate.starts_with('-') {
382        return Err(GitError::NonZero {
383            code: None,
384            stderr: format!("ref `{candidate}` looks like a flag; refusing to pass it to git"),
385        });
386    }
387    Ok(())
388}
389
390/// Output mode: just the paths.
391const NAMES: &str = "--name-only";
392
393/// Files changed on this branch relative to `git_ref`, relative to `root`.
394///
395/// Three-dot diff (`<ref>...HEAD`) is the merge-base diff — *what my branch
396/// changed*. Two-dot would also report everything that landed on the other
397/// branch since the fork, which would gate a push on files the author never
398/// touched.
399///
400/// `git_ref` is the same string the user typed: a branch name, a SHA, or a
401/// remote-tracking ref like `origin/main`. A ref that does not exist makes
402/// git exit non-zero, and that surfaces here as `Err(GitError::NonZero)`
403/// rather than an empty Vec — see the module docs.
404pub async fn changed_since(root: &Path, git_ref: &str) -> Result<Vec<PathBuf>, GitError> {
405    let (names, prefix) =
406        tokio::join!(since_diff(root, git_ref, None, NAMES), working_prefix(root));
407    Ok(paths_from(
408        filter_paths(&names?, files::is_scan_target),
409        &prefix?,
410    ))
411}
412
413/// How many lines of unchanged context to request around each change.
414///
415/// Generous on purpose. The model has no parser and no whole-file view, so
416/// this is the only thing giving it the surrounding function body to judge a
417/// change against. git merges hunks whose context windows overlap, so a large
418/// value cannot produce duplicate coverage of the same lines.
419pub const CONTEXT_LINES: u32 = 20;
420
421/// Hunks for the files staged for commit.
422///
423/// Same selection as `staged_files` — `--diff-filter=ACMRT`, empty-tree
424/// fallback when there is no HEAD — but the diff itself rather than the
425/// names. `CONTEXT_LINES` of context is requested so the model reading each
426/// hunk has the surrounding function body to compare against.
427pub async fn staged_hunks(root: &Path, wanted: fn(&Path) -> bool) -> Result<Vec<Hunk>, GitError> {
428    let (diff, prefix) = staged_diff(root, &unified()).await?;
429    Ok(hunks_from(hunks_for(&diff, wanted), &prefix))
430}
431
432/// The `--unified=N` flag, built from [`CONTEXT_LINES`].
433fn unified() -> String {
434    format!("--unified={CONTEXT_LINES}")
435}
436
437/// Parse a diff and keep only the hunks for the files the caller analyzes.
438///
439/// The file-class policy is applied here rather than inside the parser: which
440/// files a command reviews is a product decision, and `hunks.rs` answers only
441/// "what does this diff say". Same layer, and now same signature, as
442/// `filter_paths` over `--name-only` output: both queries take the class from
443/// their caller, so a command cannot get one of them right and the other
444/// wrong.
445fn hunks_for(diff_text: &str, wanted: fn(&Path) -> bool) -> Vec<Hunk> {
446    let mut hunks = parse_unified_diff(diff_text);
447    hunks.retain(|hunk| wanted(&hunk.file_path));
448    hunks
449}
450
451/// Hunks for what this branch changed relative to `git_ref`.
452///
453/// Three-dot (`<ref>...HEAD`), matching `changed_since`: the merge-base diff,
454/// so work that landed on the base branch after the fork is not attributed to
455/// this branch. `CONTEXT_LINES` of context is requested so the model has
456/// enough surrounding code to judge each change.
457pub async fn hunks_since(
458    root: &Path,
459    git_ref: &str,
460    wanted: fn(&Path) -> bool,
461) -> Result<Vec<Hunk>, GitError> {
462    hunks_between(root, git_ref, None, wanted).await
463}
464
465/// Hunks between `git_ref` and an explicit `tip`, or `HEAD` when `tip` is
466/// `None`.
467///
468/// The tip exists for the pre-push hook. git hands a hook the ref being
469/// pushed, which need not be the checked-out branch, and reviewing `HEAD`
470/// instead means the pushed code is never seen - so the hook passes the ref's
471/// own oid here.
472pub async fn hunks_between(
473    root: &Path,
474    git_ref: &str,
475    tip: Option<&str>,
476    wanted: fn(&Path) -> bool,
477) -> Result<Vec<Hunk>, GitError> {
478    let unified = unified();
479    let (diff, prefix) = tokio::join!(
480        since_diff(root, git_ref, tip, &unified),
481        working_prefix(root)
482    );
483    Ok(hunks_from(hunks_for(&diff?, wanted), &prefix?))
484}
485
486/// The current commit's SHA, with `"unknown"` on any failure.
487///
488/// Deliberately lossy: this is the cache-key component for repeated LLM
489/// calls, and a cache-key component must never take analysis down. A hung
490/// git is a real failure mode in CI containers; the 5s timeout plus the
491/// "unknown" fallback means the worst case is a cache miss, not a gate
492/// stall.
493pub async fn current_commit_sha(root: &Path) -> String {
494    let result = tokio::time::timeout(SHA_TIMEOUT, run_git(root, &["rev-parse", "HEAD"])).await;
495    match result {
496        Ok(Ok(sha)) => normalize_sha(sha),
497        // Timed out, git failed, or git is absent. All mean the same thing to a
498        // cache key.
499        _ => UNKNOWN_SHA.to_owned(),
500    }
501}
502
503/// The placeholder used whenever the real SHA cannot be determined.
504pub(crate) const UNKNOWN_SHA: &str = "unknown";
505
506/// Empty output is as useless as an error.
507///
508/// Split out from `current_commit_sha` so it is reachable from a test: the
509/// empty-success case cannot be provoked through real git, which fails rather
510/// than succeeding with no output. Previously the guard sat inline and no test
511/// could distinguish it from an unconditional pass-through.
512pub(crate) fn normalize_sha(sha: String) -> String {
513    if sha.is_empty() {
514        UNKNOWN_SHA.to_owned()
515    } else {
516        sha
517    }
518}
519
520#[cfg(test)]
521mod tests;