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
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
//! 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 ;
use Duration;
use StreamExt;
use cratefiles;
use ;
pub use ;
use ;
use ;
pub 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;
/// 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
/// 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.
///
/// Each line is decoded before the filter sees it: git quotes a name holding
/// a byte such as `é` or `"`, and the quoted form matched no file class, so a
/// staged `café.md` was silently absent from `lint-docs --staged`.
///
/// `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.
/// Every `git diff` drep runs starts with these, ahead of its selection.
///
/// drep parses the output, and git formats it from the user's configuration
/// and the repository's attributes. Each option pins one thing a setting or
/// attribute would otherwise change, and each such change made the parser find
/// no hunk, or a hunk other than the committed text, which the gate then
/// reports as reviewed:
///
/// - `core.quotePath=true` quotes every byte above 0x7f, so a name that is not
/// UTF-8 arrives as escapes `quoting::decode` reverses exactly, rather than
/// as raw bytes the lossy conversion of stdout replaces.
/// - `diff.suppressBlankEmpty=false` keeps the leading space on a blank context
/// line. Printed empty, the line was skipped and every line after it in the
/// hunk carried the wrong number.
/// - `--text` because `binary` or `-diff` in a committed `.gitattributes`, or
/// a single NUL byte in the content, turns a source file's hunks into one
/// `Binary files ... differ` line. Binary files outside the caller's file
/// class are rendered as text too, then dropped unread.
/// - `--no-textconv` and `--no-ext-diff` ignore a textconv driver,
/// `diff.external` and `GIT_EXTERNAL_DIFF`, whose program (difftastic, say)
/// prints something other than the committed text as a unified diff.
/// - `--no-color` overrides `color.ui=always`, which wraps each header in
/// escape codes even when stdout is a pipe.
/// - `--src-prefix`/`--dst-prefix` restore the `a/` and `b/` that
/// `diff.noprefix` removes and `diff.mnemonicPrefix` (`c/` and `i/` for
/// `--cached`) and `diff.srcPrefix`/`diff.dstPrefix` replace.
/// - `--no-relative` overrides `diff.relative`, which names each file from the
/// working directory and leaves out every file outside it, so a commit run
/// from a subdirectory passed its other files unreviewed. Every path is named
/// from the top level, as `:<path>` resolves it when a staged file's content
/// is read, and `prefix` converts it to the working directory's view before a
/// query returns it.
///
/// `--diff-filter=ACMRT` is selection rather than format, and is here so the
/// staged and branch queries state it once; `staged_files` says why deletions
/// are left out. `T` is kept because a type change is how a symlink becomes a
/// regular file, and leaving it out passed that file's whole content
/// unreviewed.
const DIFF: & = &;
/// Run `git diff` with its output format pinned by [`DIFF`], selecting with
/// `selection`.
async
/// Files staged for commit, relative to `root`, that drep analyzes.
///
/// `--diff-filter=ACMRT` 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
/// How many staged files have their content read at once: one `git cat-file`
/// each, bounded like drep's other child-process fan-outs.
const CONTENT_READ_CONCURRENCY: usize = 4;
/// Each staged file `wanted` accepts, with the content the commit records for it.
///
/// The content comes from the same index as the list, never from the working
/// tree, which differs from it after a partial `git add` and under an index the
/// committer chose. A file whose content cannot be read carries its error, as
/// reading it from disk would.
pub async
/// `git diff --cached` in whichever output mode the caller wants, and the
/// working directory's prefix its paths are to be read from.
///
/// The selection rules — the empty-tree fallback here, `--diff-filter=ACMRT`
/// in [`DIFF`] — live 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
/// What the staged queries read: whether HEAD exists, the index the commit is being made from, and where `root` sits in the working tree.
///
/// Built once per query so the file list and the content read for it come from the same index.
/// `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
/// Refuse a ref that git would parse as an option.
///
/// Every query that hands a caller's ref to git applies it before spawning,
/// for the reason [`since_diff`] gives: `--output=<file>` in a ref position is
/// an option to `git diff`, and `--` cannot help because what follows it is a
/// path.
pub
/// 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=ACMRT`, 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