1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
//! Which files changed, in git terms.
//!
//! Every command in `drep` that looks at "what changed" comes through here.
//! `drep check --staged` for a pre-commit hook, `--diff <ref>` for a pre-push
//! gate, and the cache key that deduplicates repeated LLM calls all need a
//! stable answer to the same question. They shell out to git directly —
//! `tokio::process::Command` rather than libgit2, because the only operations
//! drep needs are the ones git's own CLI was built for, and a git CLI that
//! misbehaves would surface as a real OS-level error rather than a translated
//! library one.
//!
//! Two invariants matter more than the implementations:
//!
//! - "No files changed" must be **distinct** from "I could not ask git".
//! Conflating them is how a commit gate rubber-stamps the day the user's
//! git install breaks.
//! - `current_commit_sha` is the one place this is reversed: it only feeds
//! a cache key, and a cache-key component must never take the analysis down.
use ;
use Stdio;
use Duration;
use Command;
use cratefiles;
use ;
/// The well-known SHA for the empty git tree.
///
/// On a fresh `git init` (no commits yet) there is no `HEAD` to diff against,
/// so drep diffs against this instead. Otherwise every first commit on a new
/// repository would fail with "fatal: ambiguous argument 'HEAD'".
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
/// How long `current_commit_sha` is willing to wait for `git` to answer.
///
/// Used to be wall-clock unbounded, which was fine until a hung `git` stalled
/// the gate and blocked every commit. Five seconds is more than enough for a
/// local `git rev-parse`; if it does not answer by then the answer is
/// "unknown" and the cache key falls through.
const SHA_TIMEOUT: Duration = from_secs;
/// Ceiling on any single git invocation.
///
/// Generous compared with `SHA_TIMEOUT` because `git diff` on a large history
/// is legitimately slower than `rev-parse`, but bounded so a hung git cannot
/// stall a commit.
const GIT_TIMEOUT: Duration = from_secs;
/// What went wrong shelling out to git.
///
/// Distinct from `std::io::Error` because the most common cause — git exits
/// non-zero for "not a repository" — is not the same as "could not spawn
/// git", and the two should not be displayed the same way.
/// Whether `root` has any commit yet.
///
/// Single-purpose helper: kept here because it is git semantics, not file
/// discovery, and the diff commands need to know this both for the empty-tree
/// fallback and for the `changed_since` no-HEAD case.
async
/// Run `git <args>` in `root` and return trimmed stdout on success.
///
/// All the diff commands want the same shape: capture stdout, capture
/// stderr separately, never panic. `kill_on_drop` ensures a hung git cannot
/// outlive its caller.
/// Every git invocation is bounded.
///
/// The timeout lives here rather than at one call site: `current_commit_sha`
/// wrapped itself, but `staged_files`, `changed_since` and `has_head` called
/// this bare, so a hung git blocked the gate indefinitely. `kill_on_drop` only
/// helps when the future is dropped, which nothing was doing.
///
/// `pub(crate)` because it is the *only* place drep spawns git. `cli::init`
/// asks git where the hooks directory is and what `core.hooksPath` holds, and
/// a second spawn helper there would be a second place for the timeout, the
/// stdin-null and the non-zero handling to drift.
/// Run a git query whose answer is carried by its exit code.
///
/// `Ok(Some(stdout))` when git exited 0, `Ok(None)` when it exited **1**, and
/// an error for anything else. Exit 1 is git's "no" - not ignored, not tracked,
/// no such config key - while 2 and above mean the question could not be asked
/// at all, and collapsing the two would report a broken repository as a clean
/// answer.
///
/// Three call sites had transcribed this discrimination separately
/// (`hooks::run_git_config_path`, and `gitignore`'s ignored and tracked
/// probes), which is three places for the 1-versus-2 rule to drift.
pub async
pub async
/// Resolve a path printed by `git rev-parse` against the queried repository.
///
/// Git prints an absolute path for some worktree layouts and a repository-
/// relative path for others. Callers must not independently guess which form
/// they received.
pub async
/// The working tree's top-level directory.
///
/// Here rather than beside its caller for the reason this module's header
/// states: `run_git` is the only place drep spawns git, and its
/// `GIT_DIR`/`GIT_WORK_TREE` scrubbing is what stops a hook's inherited
/// environment answering about a different repository. Site policy is evaluated
/// against this answer, and a marker checked against the wrong tree is a policy
/// bypass rather than a cosmetic mistake.
///
/// Through `git_path` because `--show-toplevel` prints an absolute path for some
/// worktree layouts and a relative one for others - the guess that helper exists
/// to remove.
pub async
/// Parse the newline-delimited output of `git diff --name-only` into paths,
/// then keep only those the caller analyzes.
///
/// Empty lines are tolerated because git occasionally emits a trailing one
/// depending on version and locale settings; the filter is the load-bearing
/// half — it is what makes a diff query return files drep can do something
/// with, and keeps lock/build output from inflating the work set.
///
/// `wanted` is a parameter rather than a hardcoded `files::is_scan_target`
/// because the file classes are disjoint and one command owns each: `check`
/// asks for registered-language sources, `lint-docs` asks for markdown. With
/// the predicate baked in, `lint-docs --staged` could not be expressed at all
/// and the hook ran over the whole repository instead.
/// Files staged for commit, relative to `root`, that drep analyzes.
///
/// `--diff-filter=ACMR` excludes deletions on purpose: a deleted file
/// cannot be analyzed, and passing it on would look like an unreadable file
/// rather than an absent one. The empty-tree fallback covers the
/// initial-commit case (no `HEAD` yet).
pub async
/// `git diff --cached` in whichever output mode the caller wants.
///
/// The selection rules — `--diff-filter=ACMR` and the empty-tree fallback —
/// live here once rather than in each of `staged_files` and `staged_hunks`.
/// They were stated twice, and a change applied to one and not the other would
/// make the file list and the hunk set disagree about what is in scope: drep
/// would analyze a file the gate never listed, which is exactly the class of
/// failure this module exists to prevent.
async
/// `git diff <ref>...<HEAD|empty-tree>` in whichever output mode is wanted.
///
/// The three-dot spec is built once for the same reason as `staged_diff`: the
/// merge-base semantics are a decision, and `changed_since`/`hunks_since` must
/// not be able to drift apart on it.
///
/// A `git_ref` that begins with `-` is rejected before any git invocation.
/// Without this guard, `drep check --diff --output=/tmp/x` would reach git
/// as a flag — `--output=/tmp/x` is parsed by `git diff` as an option, not
/// a ref. Passing `--` does not help: after `--`, git treats arguments as
/// *paths*, and `--diff -- this/file` is "diff versus the path `this/file`"
/// rather than "diff versus the ref `--`".
async
/// Output mode: just the paths.
const NAMES: &str = "--name-only";
/// Files changed on this branch relative to `git_ref`, relative to `root`.
///
/// Three-dot diff (`<ref>...HEAD`) is the merge-base diff — *what my branch
/// changed*. Two-dot would also report everything that landed on the other
/// branch since the fork, which would gate a push on files the author never
/// touched.
///
/// `git_ref` is the same string the user typed: a branch name, a SHA, or a
/// remote-tracking ref like `origin/main`. A ref that does not exist makes
/// git exit non-zero, and that surfaces here as `Err(GitError::NonZero)`
/// rather than an empty Vec — see the module docs.
pub async
/// How many lines of unchanged context to request around each change.
///
/// Generous on purpose. The model has no parser and no whole-file view, so
/// this is the only thing giving it the surrounding function body to judge a
/// change against. git merges hunks whose context windows overlap, so a large
/// value cannot produce duplicate coverage of the same lines.
pub const CONTEXT_LINES: u32 = 20;
/// Hunks for the files staged for commit.
///
/// Same selection as `staged_files` — `--diff-filter=ACMR`, empty-tree
/// fallback when there is no HEAD — but the diff itself rather than the
/// names. `CONTEXT_LINES` of context is requested so the model reading each
/// hunk has the surrounding function body to compare against.
pub async
/// The `--unified=N` flag, built from [`CONTEXT_LINES`].
/// Parse a diff and keep only the hunks for the files the caller analyzes.
///
/// The file-class policy is applied here rather than inside the parser: which
/// files a command reviews is a product decision, and `hunks.rs` answers only
/// "what does this diff say". Same layer, and now same signature, as
/// `filter_paths` over `--name-only` output: both queries take the class from
/// their caller, so a command cannot get one of them right and the other
/// wrong.
/// Hunks for what this branch changed relative to `git_ref`.
///
/// Three-dot (`<ref>...HEAD`), matching `changed_since`: the merge-base diff,
/// so work that landed on the base branch after the fork is not attributed to
/// this branch. `CONTEXT_LINES` of context is requested so the model has
/// enough surrounding code to judge each change.
pub async
/// Hunks between `git_ref` and an explicit `tip`, or `HEAD` when `tip` is
/// `None`.
///
/// The tip exists for the pre-push hook. git hands a hook the ref being
/// pushed, which need not be the checked-out branch, and reviewing `HEAD`
/// instead means the pushed code is never seen - so the hook passes the ref's
/// own oid here.
pub async
/// The current commit's SHA, with `"unknown"` on any failure.
///
/// Deliberately lossy: this is the cache-key component for repeated LLM
/// calls, and a cache-key component must never take analysis down. A hung
/// git is a real failure mode in CI containers; the 5s timeout plus the
/// "unknown" fallback means the worst case is a cache miss, not a gate
/// stall.
pub async
/// The placeholder used whenever the real SHA cannot be determined.
pub const UNKNOWN_SHA: &str = "unknown";
/// Empty output is as useless as an error.
///
/// Split out from `current_commit_sha` so it is reachable from a test: the
/// empty-success case cannot be provoked through real git, which fails rather
/// than succeeding with no output. Previously the guard sat inline and no test
/// could distinguish it from an unconditional pass-through.
pub