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::path::{Path, PathBuf};
21use std::process::Stdio;
22use std::time::Duration;
23
24use tokio::process::Command;
25
26use crate::files;
27
28pub mod hunks;
29
30use hunks::{Hunk, parse_unified_diff};
31
32/// The well-known SHA for the empty git tree.
33///
34/// On a fresh `git init` (no commits yet) there is no `HEAD` to diff against,
35/// so drep diffs against this instead. Otherwise every first commit on a new
36/// repository would fail with "fatal: ambiguous argument 'HEAD'".
37const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
38
39/// How long `current_commit_sha` is willing to wait for `git` to answer.
40///
41/// Used to be wall-clock unbounded, which was fine until a hung `git` stalled
42/// the gate and blocked every commit. Five seconds is more than enough for a
43/// local `git rev-parse`; if it does not answer by then the answer is
44/// "unknown" and the cache key falls through.
45const SHA_TIMEOUT: Duration = Duration::from_secs(5);
46
47/// Ceiling on any single git invocation.
48///
49/// Generous compared with `SHA_TIMEOUT` because `git diff` on a large history
50/// is legitimately slower than `rev-parse`, but bounded so a hung git cannot
51/// stall a commit.
52const GIT_TIMEOUT: Duration = Duration::from_secs(60);
53
54/// What went wrong shelling out to git.
55///
56/// Distinct from `std::io::Error` because the most common cause — git exits
57/// non-zero for "not a repository" — is not the same as "could not spawn
58/// git", and the two should not be displayed the same way.
59#[derive(Debug)]
60pub enum GitError {
61    /// `git` could not be spawned at all: missing binary, permission denied,
62    /// or the OS rejected the argv. This is fundamentally different from
63    /// git exiting non-zero.
64    Spawn(String),
65    /// `git` ran and refused. `code` is the exit status when git set one;
66    /// `stderr` is the last argument worth showing to a user.
67    NonZero { code: Option<i32>, stderr: String },
68}
69
70impl std::fmt::Display for GitError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            GitError::Spawn(msg) => write!(f, "git could not be spawned: {msg}"),
74            GitError::NonZero { code, stderr } => {
75                let code = code.map_or("<signal>".to_owned(), |c| c.to_string());
76                write!(f, "git exited {code}: {stderr}")
77            }
78        }
79    }
80}
81
82impl std::error::Error for GitError {}
83
84/// Whether `root` has any commit yet.
85///
86/// Single-purpose helper: kept here because it is git semantics, not file
87/// discovery, and the diff commands need to know this both for the empty-tree
88/// fallback and for the `changed_since` no-HEAD case.
89async fn has_head(root: &Path) -> bool {
90    run_git(root, &["rev-parse", "--verify", "HEAD"])
91        .await
92        .is_ok()
93}
94
95/// Run `git <args>` in `root` and return trimmed stdout on success.
96///
97/// All the diff commands want the same shape: capture stdout, capture
98/// stderr separately, never panic. `kill_on_drop` ensures a hung git cannot
99/// outlive its caller.
100/// Every git invocation is bounded.
101///
102/// The timeout lives here rather than at one call site: `current_commit_sha`
103/// wrapped itself, but `staged_files`, `changed_since` and `has_head` called
104/// this bare, so a hung git blocked the gate indefinitely. `kill_on_drop` only
105/// helps when the future is dropped, which nothing was doing.
106///
107/// `pub(crate)` because it is the *only* place drep spawns git. `cli::init`
108/// asks git where the hooks directory is and what `core.hooksPath` holds, and
109/// a second spawn helper there would be a second place for the timeout, the
110/// stdin-null and the non-zero handling to drift.
111/// Run a git query whose answer is carried by its exit code.
112///
113/// `Ok(Some(stdout))` when git exited 0, `Ok(None)` when it exited **1**, and
114/// an error for anything else. Exit 1 is git's "no" - not ignored, not tracked,
115/// no such config key - while 2 and above mean the question could not be asked
116/// at all, and collapsing the two would report a broken repository as a clean
117/// answer.
118///
119/// Three call sites had transcribed this discrimination separately
120/// (`hooks::run_git_config_path`, and `gitignore`'s ignored and tracked
121/// probes), which is three places for the 1-versus-2 rule to drift.
122pub(crate) async fn git_query(root: &Path, args: &[&str]) -> Result<Option<String>, GitError> {
123    match run_git(root, args).await {
124        Ok(stdout) => Ok(Some(stdout)),
125        Err(GitError::NonZero { code: Some(1), .. }) => Ok(None),
126        Err(err) => Err(err),
127    }
128}
129
130pub(crate) async fn run_git(root: &Path, args: &[&str]) -> Result<String, GitError> {
131    let mut command = Command::new("git");
132    command
133        .args(args)
134        // drep names the repository by path, and `current_dir(root)` is that
135        // statement. An inherited `GIT_DIR`/`GIT_WORK_TREE`/`GIT_COMMON_DIR`
136        // silently overrides it, so git answers about a *different* repository
137        // than the one asked about - and a relative `GIT_INDEX_FILE` resolves
138        // against the wrong directory entirely. Both happen in practice,
139        // because drep's whole job is running inside a git hook, where git
140        // exports all of them.
141        //
142        // Removing them makes `root` authoritative. It changes nothing in the
143        // ordinary case (git rediscovers the same repository from the working
144        // directory), and it is what stops the answers depending on who
145        // launched the process.
146        .env_remove("GIT_DIR")
147        .env_remove("GIT_WORK_TREE")
148        .env_remove("GIT_COMMON_DIR")
149        .env_remove("GIT_INDEX_FILE")
150        // The object-database trio, for the same reason as the four above:
151        // they redirect where a child `git` reads and writes objects, so an
152        // inherited one points at the outer repository's store while every
153        // other setting names the intended one.
154        .env_remove("GIT_OBJECT_DIRECTORY")
155        .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
156        .env_remove("GIT_QUARANTINE_PATH")
157        .current_dir(root)
158        .stdin(Stdio::null())
159        .stdout(Stdio::piped())
160        .stderr(Stdio::piped())
161        .kill_on_drop(true);
162
163    let output = match tokio::time::timeout(GIT_TIMEOUT, command.output()).await {
164        Ok(result) => result,
165        Err(_) => {
166            return Err(GitError::Spawn(format!(
167                "git {} timed out after {}s",
168                args.join(" "),
169                GIT_TIMEOUT.as_secs()
170            )));
171        }
172    }
173    .map_err(|err| GitError::Spawn(err.to_string()))?;
174
175    if !output.status.success() {
176        return Err(GitError::NonZero {
177            code: output.status.code(),
178            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
179        });
180    }
181    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
182}
183
184/// Parse the newline-delimited output of `git diff --name-only` into paths,
185/// then keep only those the caller analyzes.
186///
187/// Empty lines are tolerated because git occasionally emits a trailing one
188/// depending on version and locale settings; the filter is the load-bearing
189/// half — it is what makes a diff query return files drep can do something
190/// with, and keeps lock/build output from inflating the work set.
191///
192/// `wanted` is a parameter rather than a hardcoded `files::is_scan_target`
193/// because the file classes are disjoint and one command owns each: `check`
194/// asks for registered-language sources, `lint-docs` asks for markdown. With
195/// the predicate baked in, `lint-docs --staged` could not be expressed at all
196/// and the hook ran over the whole repository instead.
197fn filter_paths(output: &str, wanted: fn(&Path) -> bool) -> Vec<PathBuf> {
198    output
199        .lines()
200        .filter(|line| !line.trim().is_empty())
201        .map(PathBuf::from)
202        .filter(|path| wanted(path))
203        .collect()
204}
205
206/// Files staged for commit, relative to `root`, that drep analyzes.
207///
208/// `--diff-filter=ACMR` excludes deletions on purpose: a deleted file
209/// cannot be analyzed, and passing it on would look like an unreadable file
210/// rather than an absent one. The empty-tree fallback covers the
211/// initial-commit case (no `HEAD` yet).
212pub async fn staged_files(
213    root: &Path,
214    wanted: fn(&Path) -> bool,
215) -> Result<Vec<PathBuf>, GitError> {
216    Ok(filter_paths(&staged_diff(root, NAMES).await?, wanted))
217}
218
219/// `git diff --cached` in whichever output mode the caller wants.
220///
221/// The selection rules — `--diff-filter=ACMR` and the empty-tree fallback —
222/// live here once rather than in each of `staged_files` and `staged_hunks`.
223/// They were stated twice, and a change applied to one and not the other would
224/// make the file list and the hunk set disagree about what is in scope: drep
225/// would analyze a file the gate never listed, which is exactly the class of
226/// failure this module exists to prevent.
227async fn staged_diff(root: &Path, mode: &str) -> Result<String, GitError> {
228    let args: &[&str] = if has_head(root).await {
229        &["diff", "--cached", "--diff-filter=ACMR", mode]
230    } else {
231        &["diff", "--cached", "--diff-filter=ACMR", mode, EMPTY_TREE]
232    };
233    run_git(root, args).await
234}
235
236/// `git diff <ref>...<HEAD|empty-tree>` in whichever output mode is wanted.
237///
238/// The three-dot spec is built once for the same reason as `staged_diff`: the
239/// merge-base semantics are a decision, and `changed_since`/`hunks_since` must
240/// not be able to drift apart on it.
241///
242/// A `git_ref` that begins with `-` is rejected before any git invocation.
243/// Without this guard, `drep check --diff --output=/tmp/x` would reach git
244/// as a flag — `--output=/tmp/x` is parsed by `git diff` as an option, not
245/// a ref. Passing `--` does not help: after `--`, git treats arguments as
246/// *paths*, and `--diff -- this/file` is "diff versus the path `this/file`"
247/// rather than "diff versus the ref `--`".
248async fn since_diff(
249    root: &Path,
250    git_ref: &str,
251    tip: Option<&str>,
252    mode: &str,
253) -> Result<String, GitError> {
254    for candidate in [Some(git_ref), tip].into_iter().flatten() {
255        if candidate.starts_with('-') {
256            return Err(GitError::NonZero {
257                code: None,
258                stderr: format!("ref `{candidate}` looks like a flag; refusing to pass it to git"),
259            });
260        }
261    }
262    // An explicit tip names the commit the caller means, which is not always
263    // the checked-out one. The pre-push hook is the case that forced this:
264    // git can push a ref that is not HEAD (`git push origin feature:feature`
265    // from another branch, or `git push --all`), and diffing against HEAD
266    // there reviews the wrong branch entirely and green-lights the pushed one
267    // unseen - the exact "unanalyzed reported as clean" failure the gate
268    // exists to prevent.
269    // No `EMPTY_TREE` fallback here, unlike `staged_diff`. A three-dot spec is
270    // a *symmetric difference between two commits*, and the empty tree is a
271    // tree - git rejects `<ref>...4b825dc` with "Invalid symmetric difference
272    // expression" whatever `<ref>` is. So the fallback could never produce a
273    // diff; it only turned "this repo has no commits" into a confusing message
274    // about symmetric differences. A repo with an unborn HEAD also has no ref
275    // to diff *from*, so there is nothing to salvage - say so plainly.
276    let ref_b = match tip {
277        Some(tip) => tip,
278        None if has_head(root).await => "HEAD",
279        None => {
280            return Err(GitError::NonZero {
281                code: None,
282                stderr: "this repository has no commits yet, so there is nothing to \
283                         diff against; use --staged before the first commit"
284                    .to_owned(),
285            });
286        }
287    };
288    let spec = format!("{git_ref}...{ref_b}");
289    run_git(root, &["diff", "--diff-filter=ACMR", mode, &spec]).await
290}
291
292/// Output mode: just the paths.
293const NAMES: &str = "--name-only";
294
295/// Files changed on this branch relative to `git_ref`, relative to `root`.
296///
297/// Three-dot diff (`<ref>...HEAD`) is the merge-base diff — *what my branch
298/// changed*. Two-dot would also report everything that landed on the other
299/// branch since the fork, which would gate a push on files the author never
300/// touched.
301///
302/// `git_ref` is the same string the user typed: a branch name, a SHA, or a
303/// remote-tracking ref like `origin/main`. A ref that does not exist makes
304/// git exit non-zero, and that surfaces here as `Err(GitError::NonZero)`
305/// rather than an empty Vec — see the module docs.
306pub async fn changed_since(root: &Path, git_ref: &str) -> Result<Vec<PathBuf>, GitError> {
307    Ok(filter_paths(
308        &since_diff(root, git_ref, None, NAMES).await?,
309        files::is_scan_target,
310    ))
311}
312
313/// How many lines of unchanged context to request around each change.
314///
315/// Generous on purpose. The model has no parser and no whole-file view, so
316/// this is the only thing giving it the surrounding function body to judge a
317/// change against. git merges hunks whose context windows overlap, so a large
318/// value cannot produce duplicate coverage of the same lines.
319pub const CONTEXT_LINES: u32 = 20;
320
321/// Hunks for the files staged for commit.
322///
323/// Same selection as `staged_files` — `--diff-filter=ACMR`, empty-tree
324/// fallback when there is no HEAD — but the diff itself rather than the
325/// names. `CONTEXT_LINES` of context is requested so the model reading each
326/// hunk has the surrounding function body to compare against.
327pub async fn staged_hunks(root: &Path, wanted: fn(&Path) -> bool) -> Result<Vec<Hunk>, GitError> {
328    Ok(hunks_for(&staged_diff(root, &unified()).await?, wanted))
329}
330
331/// The `--unified=N` flag, built from [`CONTEXT_LINES`].
332fn unified() -> String {
333    format!("--unified={CONTEXT_LINES}")
334}
335
336/// Parse a diff and keep only the hunks for the files the caller analyzes.
337///
338/// The file-class policy is applied here rather than inside the parser: which
339/// files a command reviews is a product decision, and `hunks.rs` answers only
340/// "what does this diff say". Same layer, and now same signature, as
341/// `filter_paths` over `--name-only` output: both queries take the class from
342/// their caller, so a command cannot get one of them right and the other
343/// wrong.
344fn hunks_for(diff_text: &str, wanted: fn(&Path) -> bool) -> Vec<Hunk> {
345    let mut hunks = parse_unified_diff(diff_text);
346    hunks.retain(|hunk| wanted(&hunk.file_path));
347    hunks
348}
349
350/// Hunks for what this branch changed relative to `git_ref`.
351///
352/// Three-dot (`<ref>...HEAD`), matching `changed_since`: the merge-base diff,
353/// so work that landed on the base branch after the fork is not attributed to
354/// this branch. `CONTEXT_LINES` of context is requested so the model has
355/// enough surrounding code to judge each change.
356pub async fn hunks_since(
357    root: &Path,
358    git_ref: &str,
359    wanted: fn(&Path) -> bool,
360) -> Result<Vec<Hunk>, GitError> {
361    hunks_between(root, git_ref, None, wanted).await
362}
363
364/// Hunks between `git_ref` and an explicit `tip`, or `HEAD` when `tip` is
365/// `None`.
366///
367/// The tip exists for the pre-push hook. git hands a hook the ref being
368/// pushed, which need not be the checked-out branch, and reviewing `HEAD`
369/// instead means the pushed code is never seen - so the hook passes the ref's
370/// own oid here.
371pub async fn hunks_between(
372    root: &Path,
373    git_ref: &str,
374    tip: Option<&str>,
375    wanted: fn(&Path) -> bool,
376) -> Result<Vec<Hunk>, GitError> {
377    Ok(hunks_for(
378        &since_diff(root, git_ref, tip, &unified()).await?,
379        wanted,
380    ))
381}
382
383/// The current commit's SHA, with `"unknown"` on any failure.
384///
385/// Deliberately lossy: this is the cache-key component for repeated LLM
386/// calls, and a cache-key component must never take analysis down. A hung
387/// git is a real failure mode in CI containers; the 5s timeout plus the
388/// "unknown" fallback means the worst case is a cache miss, not a gate
389/// stall.
390pub async fn current_commit_sha(root: &Path) -> String {
391    let result = tokio::time::timeout(SHA_TIMEOUT, run_git(root, &["rev-parse", "HEAD"])).await;
392    match result {
393        Ok(Ok(sha)) => normalize_sha(sha),
394        // Timed out, git failed, or git is absent. All mean the same thing to a
395        // cache key.
396        _ => UNKNOWN_SHA.to_owned(),
397    }
398}
399
400/// The placeholder used whenever the real SHA cannot be determined.
401pub(crate) const UNKNOWN_SHA: &str = "unknown";
402
403/// Empty output is as useless as an error.
404///
405/// Split out from `current_commit_sha` so it is reachable from a test: the
406/// empty-success case cannot be provoked through real git, which fails rather
407/// than succeeding with no output. Previously the guard sat inline and no test
408/// could distinguish it from an unconditional pass-through.
409pub(crate) fn normalize_sha(sha: String) -> String {
410    if sha.is_empty() {
411        UNKNOWN_SHA.to_owned()
412    } else {
413        sha
414    }
415}
416
417#[cfg(test)]
418mod tests;