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/// Resolve a path printed by `git rev-parse` against the queried repository.
185///
186/// Git prints an absolute path for some worktree layouts and a repository-
187/// relative path for others. Callers must not independently guess which form
188/// they received.
189pub(crate) async fn git_path(root: &Path, args: &[&str]) -> Result<PathBuf, GitError> {
190 let raw = run_git(root, args).await?;
191 let path = PathBuf::from(raw);
192 Ok(if path.is_absolute() {
193 path
194 } else {
195 root.join(path)
196 })
197}
198
199/// Parse the newline-delimited output of `git diff --name-only` into paths,
200/// then keep only those the caller analyzes.
201///
202/// Empty lines are tolerated because git occasionally emits a trailing one
203/// depending on version and locale settings; the filter is the load-bearing
204/// half — it is what makes a diff query return files drep can do something
205/// with, and keeps lock/build output from inflating the work set.
206///
207/// `wanted` is a parameter rather than a hardcoded `files::is_scan_target`
208/// because the file classes are disjoint and one command owns each: `check`
209/// asks for registered-language sources, `lint-docs` asks for markdown. With
210/// the predicate baked in, `lint-docs --staged` could not be expressed at all
211/// and the hook ran over the whole repository instead.
212fn filter_paths(output: &str, wanted: fn(&Path) -> bool) -> Vec<PathBuf> {
213 output
214 .lines()
215 .filter(|line| !line.trim().is_empty())
216 .map(PathBuf::from)
217 .filter(|path| wanted(path))
218 .collect()
219}
220
221/// Files staged for commit, relative to `root`, that drep analyzes.
222///
223/// `--diff-filter=ACMR` excludes deletions on purpose: a deleted file
224/// cannot be analyzed, and passing it on would look like an unreadable file
225/// rather than an absent one. The empty-tree fallback covers the
226/// initial-commit case (no `HEAD` yet).
227pub async fn staged_files(
228 root: &Path,
229 wanted: fn(&Path) -> bool,
230) -> Result<Vec<PathBuf>, GitError> {
231 Ok(filter_paths(&staged_diff(root, NAMES).await?, wanted))
232}
233
234/// `git diff --cached` in whichever output mode the caller wants.
235///
236/// The selection rules — `--diff-filter=ACMR` and the empty-tree fallback —
237/// live here once rather than in each of `staged_files` and `staged_hunks`.
238/// They were stated twice, and a change applied to one and not the other would
239/// make the file list and the hunk set disagree about what is in scope: drep
240/// would analyze a file the gate never listed, which is exactly the class of
241/// failure this module exists to prevent.
242async fn staged_diff(root: &Path, mode: &str) -> Result<String, GitError> {
243 let args: &[&str] = if has_head(root).await {
244 &["diff", "--cached", "--diff-filter=ACMR", mode]
245 } else {
246 &["diff", "--cached", "--diff-filter=ACMR", mode, EMPTY_TREE]
247 };
248 run_git(root, args).await
249}
250
251/// `git diff <ref>...<HEAD|empty-tree>` in whichever output mode is wanted.
252///
253/// The three-dot spec is built once for the same reason as `staged_diff`: the
254/// merge-base semantics are a decision, and `changed_since`/`hunks_since` must
255/// not be able to drift apart on it.
256///
257/// A `git_ref` that begins with `-` is rejected before any git invocation.
258/// Without this guard, `drep check --diff --output=/tmp/x` would reach git
259/// as a flag — `--output=/tmp/x` is parsed by `git diff` as an option, not
260/// a ref. Passing `--` does not help: after `--`, git treats arguments as
261/// *paths*, and `--diff -- this/file` is "diff versus the path `this/file`"
262/// rather than "diff versus the ref `--`".
263async fn since_diff(
264 root: &Path,
265 git_ref: &str,
266 tip: Option<&str>,
267 mode: &str,
268) -> Result<String, GitError> {
269 for candidate in [Some(git_ref), tip].into_iter().flatten() {
270 if candidate.starts_with('-') {
271 return Err(GitError::NonZero {
272 code: None,
273 stderr: format!("ref `{candidate}` looks like a flag; refusing to pass it to git"),
274 });
275 }
276 }
277 // An explicit tip names the commit the caller means, which is not always
278 // the checked-out one. The pre-push hook is the case that forced this:
279 // git can push a ref that is not HEAD (`git push origin feature:feature`
280 // from another branch, or `git push --all`), and diffing against HEAD
281 // there reviews the wrong branch entirely and green-lights the pushed one
282 // unseen - the exact "unanalyzed reported as clean" failure the gate
283 // exists to prevent.
284 // No `EMPTY_TREE` fallback here, unlike `staged_diff`. A three-dot spec is
285 // a *symmetric difference between two commits*, and the empty tree is a
286 // tree - git rejects `<ref>...4b825dc` with "Invalid symmetric difference
287 // expression" whatever `<ref>` is. So the fallback could never produce a
288 // diff; it only turned "this repo has no commits" into a confusing message
289 // about symmetric differences. A repo with an unborn HEAD also has no ref
290 // to diff *from*, so there is nothing to salvage - say so plainly.
291 let ref_b = match tip {
292 Some(tip) => tip,
293 None if has_head(root).await => "HEAD",
294 None => {
295 return Err(GitError::NonZero {
296 code: None,
297 stderr: "this repository has no commits yet, so there is nothing to \
298 diff against; use --staged before the first commit"
299 .to_owned(),
300 });
301 }
302 };
303 let spec = format!("{git_ref}...{ref_b}");
304 run_git(root, &["diff", "--diff-filter=ACMR", mode, &spec]).await
305}
306
307/// Output mode: just the paths.
308const NAMES: &str = "--name-only";
309
310/// Files changed on this branch relative to `git_ref`, relative to `root`.
311///
312/// Three-dot diff (`<ref>...HEAD`) is the merge-base diff — *what my branch
313/// changed*. Two-dot would also report everything that landed on the other
314/// branch since the fork, which would gate a push on files the author never
315/// touched.
316///
317/// `git_ref` is the same string the user typed: a branch name, a SHA, or a
318/// remote-tracking ref like `origin/main`. A ref that does not exist makes
319/// git exit non-zero, and that surfaces here as `Err(GitError::NonZero)`
320/// rather than an empty Vec — see the module docs.
321pub async fn changed_since(root: &Path, git_ref: &str) -> Result<Vec<PathBuf>, GitError> {
322 Ok(filter_paths(
323 &since_diff(root, git_ref, None, NAMES).await?,
324 files::is_scan_target,
325 ))
326}
327
328/// How many lines of unchanged context to request around each change.
329///
330/// Generous on purpose. The model has no parser and no whole-file view, so
331/// this is the only thing giving it the surrounding function body to judge a
332/// change against. git merges hunks whose context windows overlap, so a large
333/// value cannot produce duplicate coverage of the same lines.
334pub const CONTEXT_LINES: u32 = 20;
335
336/// Hunks for the files staged for commit.
337///
338/// Same selection as `staged_files` — `--diff-filter=ACMR`, empty-tree
339/// fallback when there is no HEAD — but the diff itself rather than the
340/// names. `CONTEXT_LINES` of context is requested so the model reading each
341/// hunk has the surrounding function body to compare against.
342pub async fn staged_hunks(root: &Path, wanted: fn(&Path) -> bool) -> Result<Vec<Hunk>, GitError> {
343 Ok(hunks_for(&staged_diff(root, &unified()).await?, wanted))
344}
345
346/// The `--unified=N` flag, built from [`CONTEXT_LINES`].
347fn unified() -> String {
348 format!("--unified={CONTEXT_LINES}")
349}
350
351/// Parse a diff and keep only the hunks for the files the caller analyzes.
352///
353/// The file-class policy is applied here rather than inside the parser: which
354/// files a command reviews is a product decision, and `hunks.rs` answers only
355/// "what does this diff say". Same layer, and now same signature, as
356/// `filter_paths` over `--name-only` output: both queries take the class from
357/// their caller, so a command cannot get one of them right and the other
358/// wrong.
359fn hunks_for(diff_text: &str, wanted: fn(&Path) -> bool) -> Vec<Hunk> {
360 let mut hunks = parse_unified_diff(diff_text);
361 hunks.retain(|hunk| wanted(&hunk.file_path));
362 hunks
363}
364
365/// Hunks for what this branch changed relative to `git_ref`.
366///
367/// Three-dot (`<ref>...HEAD`), matching `changed_since`: the merge-base diff,
368/// so work that landed on the base branch after the fork is not attributed to
369/// this branch. `CONTEXT_LINES` of context is requested so the model has
370/// enough surrounding code to judge each change.
371pub async fn hunks_since(
372 root: &Path,
373 git_ref: &str,
374 wanted: fn(&Path) -> bool,
375) -> Result<Vec<Hunk>, GitError> {
376 hunks_between(root, git_ref, None, wanted).await
377}
378
379/// Hunks between `git_ref` and an explicit `tip`, or `HEAD` when `tip` is
380/// `None`.
381///
382/// The tip exists for the pre-push hook. git hands a hook the ref being
383/// pushed, which need not be the checked-out branch, and reviewing `HEAD`
384/// instead means the pushed code is never seen - so the hook passes the ref's
385/// own oid here.
386pub async fn hunks_between(
387 root: &Path,
388 git_ref: &str,
389 tip: Option<&str>,
390 wanted: fn(&Path) -> bool,
391) -> Result<Vec<Hunk>, GitError> {
392 Ok(hunks_for(
393 &since_diff(root, git_ref, tip, &unified()).await?,
394 wanted,
395 ))
396}
397
398/// The current commit's SHA, with `"unknown"` on any failure.
399///
400/// Deliberately lossy: this is the cache-key component for repeated LLM
401/// calls, and a cache-key component must never take analysis down. A hung
402/// git is a real failure mode in CI containers; the 5s timeout plus the
403/// "unknown" fallback means the worst case is a cache miss, not a gate
404/// stall.
405pub async fn current_commit_sha(root: &Path) -> String {
406 let result = tokio::time::timeout(SHA_TIMEOUT, run_git(root, &["rev-parse", "HEAD"])).await;
407 match result {
408 Ok(Ok(sha)) => normalize_sha(sha),
409 // Timed out, git failed, or git is absent. All mean the same thing to a
410 // cache key.
411 _ => UNKNOWN_SHA.to_owned(),
412 }
413}
414
415/// The placeholder used whenever the real SHA cannot be determined.
416pub(crate) const UNKNOWN_SHA: &str = "unknown";
417
418/// Empty output is as useless as an error.
419///
420/// Split out from `current_commit_sha` so it is reachable from a test: the
421/// empty-success case cannot be provoked through real git, which fails rather
422/// than succeeding with no output. Previously the guard sat inline and no test
423/// could distinguish it from an unconditional pass-through.
424pub(crate) fn normalize_sha(sha: String) -> String {
425 if sha.is_empty() {
426 UNKNOWN_SHA.to_owned()
427 } else {
428 sha
429 }
430}
431
432#[cfg(test)]
433mod tests;