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