drep/cli/init/hooks.rs
1//! `drep init`'s git-hook installer.
2//!
3//! This is the only part of `drep init` that can damage something. It writes
4//! into `.git/hooks/`, which the user owns, so every branch below is
5//! deliberate: a foreign hook is left alone, a chainer is rewritten only when
6//! it does not already chain, and `core.hooksPath` is resolved the way git
7//! resolves it (relative to the *repository*, not the cwd).
8//!
9//! Two hooks are installed today: `pre-commit` (one `drep check --staged`)
10//! and `pre-push` (one `drep check --diff <remote-oid>` per ref on stdin).
11//! Both begin with `# Managed by \`drep init\`.` - that marker is how this
12//! module recognises a hook it wrote and may rewrite.
13
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result, anyhow};
18
19use crate::diff;
20
21/// The marker every drep-managed hook and chainer begins with.
22///
23/// Every body below is built with `concat!`/`format!` around this constant
24/// rather than repeating the literal, so a rename really does update
25/// everywhere. It did not: the three bodies each hardcoded the string, which
26/// meant a rename would leave `is_drep_managed` unable to recognise the hooks
27/// drep had just written - and drep would then refuse to update its own hook.
28/// The marker text as a *literal*, so `concat!` can build the hook bodies
29/// from it. `concat!` accepts only literals, not consts, which is why this is
30/// a macro rather than a plain `const` alone; [`MANAGED_MARKER`] is the value
31/// every non-literal caller uses.
32macro_rules! managed_marker {
33 () => {
34 "# Managed by `drep init`."
35 };
36}
37
38pub const MANAGED_MARKER: &str = managed_marker!();
39
40/// The body drep writes for `pre-commit`.
41///
42/// Two commands, in this order. `lint-docs` is rule-based and takes ~10 ms, so
43/// an obvious documentation defect does not cost an LLM round trip; `check`
44/// sends the staged code to a model and is the expensive half.
45///
46/// `--fail-on error` rather than `--strict`: under the severity scale the doc
47/// checks use, `--strict` blocks on *any* finding, which over a real
48/// repository is dominated by line length and trailing whitespace. Measured on
49/// drep's own tree that is 75 findings, none above `info`. A hook that blocks
50/// a commit over a long line is a hook that gets deleted. `error` is one
51/// check - an unclosed fence, which renders the rest of the document as code.
52///
53/// `exec` on the last command is what keeps the LLM client's exit status,
54/// which is what aborts the commit when a finding gates it. The first command
55/// cannot be `exec`ed, so its status is propagated explicitly.
56pub const PRE_COMMIT_BODY: &str = concat!(
57 "#!/bin/sh\n",
58 managed_marker!(),
59 r##"
60# Runs the linters this repo configures, and an LLM review of the staged code.
61if ! command -v drep > /dev/null 2>&1; then
62 echo "drep: not found on PATH; refusing to let the commit through unreviewed." >&2
63 exit 1
64fi
65drep lint-docs --staged --fail-on error || exit $?
66exec drep check --staged
67"##
68);
69
70/// The body drep writes for `pre-push`.
71///
72/// git sends one line per ref on stdin:
73/// `<local ref> <local oid> <remote ref> <remote oid>`
74/// An all-zero remote oid means the branch does not exist upstream yet, so
75/// there is no previous state to diff against; fall back to the remote's
76/// default branch. An all-zero *local* oid is a branch deletion, which has
77/// no content to review.
78pub const PRE_PUSH_BODY: &str = concat!(
79 "#!/bin/sh\n",
80 managed_marker!(),
81 r##"
82# git runs this as: pre-push <remote-name> <remote-url>, and sends one line per
83# ref on stdin:
84# <local ref> <local oid> <remote ref> <remote oid>
85#
86# Three things here are not obvious, and each was a real defect:
87#
88# * The ref being pushed is NOT always the checked-out branch
89# (`git push origin feature:feature` from elsewhere, or `git push --all`),
90# so `--tip` names the oid actually being pushed. Reviewing HEAD instead
91# lets the pushed code through unseen.
92# * The base search is BOUNDED. An all-zero remote oid means the branch is
93# new upstream; falling back to the root commit there sends the repository's
94# entire history to the model, which on a mature repo is hours of wall clock
95# and real money from one `git push`.
96# * `drep` reads no stdin, but `< /dev/null` makes that structural: a command
97# inside a `while read` loop that did would swallow the remaining refs and
98# the push would go green having reviewed one of them.
99remote="${1:-origin}"
100zeros=0000000000000000000000000000000000000000
101status=0
102
103if ! command -v drep > /dev/null 2>&1; then
104 echo "drep: not found on PATH; refusing to let the push through unreviewed." >&2
105 echo " (GUI git clients often use a minimal PATH - see the drep README.)" >&2
106 exit 1
107fi
108
109while read -r _local_ref local_oid _remote_ref remote_oid; do
110 # A branch deletion has no content to review.
111 case "$local_oid" in "$zeros"*) continue ;; esac
112
113 case "$remote_oid" in
114 "$zeros"*)
115 # New upstream: find the nearest sensible base, cheapest first, and
116 # never scan further back than 50 commits.
117 base=$(git rev-parse --verify --quiet "$remote/HEAD") ||
118 base=$(git rev-parse --verify --quiet "$remote/main") ||
119 base=$(git rev-parse --verify --quiet "$remote/master") ||
120 base=$(git rev-parse --verify --quiet "$local_oid~50") ||
121 base=$(git rev-list --max-parents=0 "$local_oid" | tail -n 1)
122 ;;
123 *) base=$remote_oid ;;
124 esac
125
126 [ -n "$base" ] || continue
127
128 drep check --diff "$base" --tip "$local_oid" < /dev/null
129 rc=$?
130 # Highest exit code wins, not the last one. 2 ("could not analyze") must
131 # not be downgraded to 1 ("found issues") by a later ref that merely had
132 # findings - the two mean different things to whoever reads the output.
133 [ "$rc" -gt "$status" ] && status=$rc
134done
135
136exit $status
137"##
138);
139
140/// The chainer body, parameterised on the hook name.
141///
142/// This is what goes in the `core.hooksPath` directory: an `exec` shim that
143/// forwards to the repo-local hook git would otherwise ignore entirely. With
144/// `core.hooksPath` set, git does not look in `.git/hooks` at all, so without
145/// a chainer a perfectly good repo-local hook simply never runs.
146///
147/// The body names no repository, so a chainer written into a shared directory
148/// is safe for every repo that uses it: it forwards when a repo-local hook
149/// exists and falls through silently when one does not.
150pub fn chainer_body(name: &str) -> String {
151 format!(
152 "\
153#!/bin/sh
154{MANAGED_MARKER}
155# Chains to the repo-local {name} hook, which git ignores while core.hooksPath
156# is set. `exec` matters twice: it keeps the local hook's exit status (that is
157# what aborts the operation) and hands over stdin unread, which is how git
158# delivers the refs being pushed.
159LOCAL_HOOK=\"$(git rev-parse --git-common-dir)/hooks/{name}\"
160if [ -x \"$LOCAL_HOOK\" ]; then
161 exec \"$LOCAL_HOOK\" \"$@\"
162fi
163"
164 )
165}
166
167/// Which git hook to install.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
169pub enum HookKind {
170 /// `git push` triggers `drep check --diff <remote-oid>`. The default.
171 PrePush,
172 /// `git commit` triggers `drep check --staged`.
173 PreCommit,
174 /// Both `pre-commit` and `pre-push`.
175 Both,
176 /// Neither. `drep init` writes `drep.toml` and skips the hooks.
177 None,
178}
179
180/// The names `kind` installs.
181///
182/// `Both` yields `["pre-commit", "pre-push"]` in that order, so `pre-commit`
183/// is installed before `pre-push` and a failure in one does not change the
184/// other.
185pub fn hook_names(kind: HookKind) -> &'static [&'static str] {
186 match kind {
187 HookKind::None => &[],
188 HookKind::PrePush => &["pre-push"],
189 HookKind::PreCommit => &["pre-commit"],
190 HookKind::Both => &["pre-commit", "pre-push"],
191 }
192}
193
194/// The body drep writes for `name`. `None` for an unknown name.
195pub fn hook_body(name: &str) -> Option<&'static str> {
196 match name {
197 "pre-commit" => Some(PRE_COMMIT_BODY),
198 "pre-push" => Some(PRE_PUSH_BODY),
199 _ => None,
200 }
201}
202
203/// `core.hooksPath`, resolved the way git resolves it: an absolute value is
204/// used as-is, a relative one is relative to the *repository*, not the cwd.
205///
206/// Resolving against the cwd would write a chainer into whatever directory
207/// the caller happened to be in.
208pub fn resolve_hooks_dir(root: &Path, value: &str) -> PathBuf {
209 let candidate = Path::new(value);
210 if candidate.is_absolute() {
211 candidate.to_path_buf()
212 } else {
213 root.join(value)
214 }
215}
216
217/// True when `body` is a hook drep wrote and may therefore rewrite.
218pub fn is_drep_managed(body: &str) -> bool {
219 body.contains(MANAGED_MARKER)
220}
221
222/// Install the hooks. Writes to `out`; never panics.
223pub async fn install<W: Write>(
224 out: &mut W,
225 root: &Path,
226 kind: HookKind,
227 force: bool,
228) -> Result<()> {
229 let names = hook_names(kind);
230 // `--hooks none` must not create directories or ask git anything. It is
231 // the escape hatch for "write me a config, leave my repo alone", and an
232 // escape hatch with side effects is not one.
233 if names.is_empty() {
234 return Ok(());
235 }
236 let hooks_dir = locate_hooks_dir(root).await?;
237 std::fs::create_dir_all(&hooks_dir)
238 .with_context(|| format!("could not create {}", hooks_dir.display()))?;
239
240 for name in names {
241 // Total rather than `expect`: `hook_names` and `hook_body` are two
242 // matches over the same vocabulary, and a future hook added to one and
243 // not the other must not panic inside an installer the user is
244 // trusting with their `.git` directory.
245 let body =
246 hook_body(name).ok_or_else(|| anyhow!("no hook body is defined for `{name}`"))?;
247 let path = hooks_dir.join(name);
248 if path.exists() {
249 let existing = std::fs::read_to_string(&path)
250 .with_context(|| format!("could not read existing hook {}", path.display()))?;
251 if is_drep_managed(&existing) {
252 write_executable(&path, body)?;
253 writeln!(out, " Wrote {}", path.display())?;
254 } else if force {
255 // `--force` is one flag serving two destinations, and
256 // `config_file::write` is what tells the user to reach for it
257 // ("Re-run with --force to replace it") - so someone with an
258 // existing drep.toml AND a hand-written hook is steered
259 // straight into destroying the hook. Keeping a copy makes that
260 // recoverable instead of silent data loss.
261 let backup = path.with_extension("drep-backup");
262 std::fs::write(&backup, &existing)
263 .with_context(|| format!("could not back up to {}", backup.display()))?;
264 write_executable(&path, body)?;
265 writeln!(out, " Wrote {}", path.display())?;
266 writeln!(out, " Your previous hook is saved at {}", backup.display())?;
267 } else {
268 writeln!(
269 out,
270 " {} already exists and was not written by drep; leaving it alone.",
271 path.display()
272 )?;
273 writeln!(out, " Re-run with --force to replace it.")?;
274 }
275 continue;
276 }
277 write_executable(&path, body)?;
278 writeln!(out, " Wrote {}", path.display())?;
279 }
280
281 // core.hooksPath chainer: query with --type=path so git expands ~ and
282 // ~user itself; hand-rolled expansion mangled `~alice/hooks`, and $HOME
283 // is unset in some environments.
284 let configured = run_git_config_path(root).await?;
285 if let Some(value) = configured {
286 writeln!(out, " core.hooksPath is set to {value}")?;
287 writeln!(
288 out,
289 " git looks there and not in .git/hooks, so a repo hook needs a chainer."
290 )?;
291
292 let chainer_dir = resolve_hooks_dir(root, &value);
293 for name in names {
294 ensure_chainer(out, &chainer_dir, name).await?;
295 }
296 }
297
298 Ok(())
299}
300
301/// Resolve the hooks directory git would consult for repo-local hooks.
302///
303/// `git rev-parse --git-common-dir` rather than `root/.git`: in a linked
304/// worktree or a submodule `.git` is a *file*, so the literal path does not
305/// exist and the hook silently never runs.
306async fn locate_hooks_dir(root: &Path) -> Result<PathBuf> {
307 let common = diff::run_git(root, &["rev-parse", "--git-common-dir"])
308 .await
309 .with_context(|| format!("could not locate git common dir under {}", root.display()))?;
310 let common = PathBuf::from(common);
311 let hooks_dir = if common.is_absolute() {
312 common
313 } else {
314 root.join(common)
315 };
316 Ok(hooks_dir.join("hooks"))
317}
318
319/// Query `core.hooksPath`. `None` when unset.
320/// Query `core.hooksPath`. `Ok(None)` when genuinely unset.
321///
322/// `git config --get` exits **1** for "not found" and >=2 for a real error, so
323/// the two are distinguishable and must be distinguished: swallowing an error
324/// as "unset" means skipping the chainer while `core.hooksPath` is in fact set,
325/// which leaves the hook drep just wrote unable to ever run - reported as
326/// success.
327///
328/// An empty value is "unset" for our purposes and is *not* an error: git reads
329/// a blank `core.hooksPath` back as present-but-empty, which disables hooks
330/// entirely rather than naming a directory.
331async fn run_git_config_path(root: &Path) -> Result<Option<String>> {
332 // An *empty* value reads back as present-but-blank, which drep treats as
333 // unset, so it collapses into the same `None` as "no such key".
334 match diff::git_query(root, &["config", "--get", "--type=path", "core.hooksPath"]).await {
335 Ok(Some(value)) if value.is_empty() => Ok(None),
336 Ok(value) => Ok(value),
337 Err(err) => Err(anyhow!(
338 "could not read core.hooksPath ({err}); refusing to install a hook that \
339 may never run"
340 )),
341 }
342}
343
344/// Make sure a chainer for `name` exists in `dir`, executable, and chains.
345///
346/// Leaves a foreign chainer alone, reports the situation. `git` ignores a
347/// non-executable hook silently, which is the entire reason this branch
348/// exists.
349async fn ensure_chainer<W: Write>(out: &mut W, dir: &Path, name: &str) -> Result<()> {
350 let chainer = dir.join(name);
351 if chainer.exists() {
352 let body = std::fs::read_to_string(&chainer)
353 .with_context(|| format!("could not read existing chainer {}", chainer.display()))?;
354 let marker = format!("hooks/{name}");
355 if !body.contains(&marker) {
356 writeln!(
357 out,
358 " {} exists but does not appear to chain to the repo-local hook.",
359 chainer.display()
360 )?;
361 writeln!(out, " drep will not run until it does.")?;
362 return Ok(());
363 }
364 // Ensure the bit unconditionally, and report only when it was
365 // actually missing. Guarding the *chmod* on the check instead meant
366 // `set_executable` was only ever handed a non-executable file, which
367 // makes OR and XOR indistinguishable there - the operator's
368 // correctness was unobservable, and an operator nothing can observe is
369 // one nothing protects.
370 let was_executable = crate::languages::runner::is_executable(&chainer);
371 set_executable(&chainer)?;
372 if !was_executable {
373 writeln!(
374 out,
375 " {} is not executable; making it so.",
376 chainer.display()
377 )?;
378 }
379 return Ok(());
380 }
381
382 std::fs::create_dir_all(dir)
383 .with_context(|| format!("could not create chainer dir {}", dir.display()))?;
384 write_executable(&chainer, &chainer_body(name))?;
385 // Named in full, and flagged as outside the repository: this is the one
386 // thing `drep init` writes that is not under `root`, and a shared hooks
387 // directory is shared with every other repo on the machine.
388 writeln!(
389 out,
390 " Wrote a chainer at {} (outside this repository)",
391 chainer.display()
392 )?;
393 Ok(())
394}
395
396/// Write `body` to `path` and make it executable, atomically.
397///
398/// Via a sibling temp file and a rename, because `fs::write` truncates in
399/// place: an interruption mid-write leaves a *truncated but executable* hook,
400/// and since these bodies open with a shebang and comments, a truncated one
401/// exits 0 and waves every push through. A rename is atomic on the same
402/// filesystem, so a hook is either the old one or the new one.
403fn write_executable(path: &Path, body: &str) -> Result<()> {
404 let temp = path.with_extension("drep-tmp");
405 std::fs::write(&temp, body)
406 .with_context(|| format!("could not write hook {}", temp.display()))?;
407 set_executable(&temp)?;
408 std::fs::rename(&temp, path).map_err(|err| {
409 // A failed rename leaves the temporary behind, and `drep init` is a
410 // command people re-run - so without this a repeatedly-failing install
411 // litters `.git/hooks` with one file per attempt. The quirks cache's
412 // write does the same thing for the same reason.
413 let _ = std::fs::remove_file(&temp);
414 anyhow::Error::new(err).context(format!("could not install hook {}", path.display()))
415 })?;
416 Ok(())
417}
418
419/// Make `path` executable. A no-op where the platform has no such bit.
420///
421/// One function with the `cfg` inside its body, not two cfg-gated
422/// definitions - the same rule `languages::runner::is_executable` follows and
423/// for the same reason: the inactive definition is unreachable on this
424/// platform, so every mutation of it survives by construction and shows up in
425/// `cargo mutants` as a finding no test can ever address.
426fn set_executable(path: &Path) -> Result<()> {
427 #[cfg(unix)]
428 {
429 use std::os::unix::fs::PermissionsExt;
430 let mut perms = std::fs::metadata(path)
431 .with_context(|| format!("could not stat {}", path.display()))?
432 .permissions();
433 // `| 0o111`, not `| 0o755`: adding the execute bits is the whole
434 // requirement, and OR-ing 0o755 onto a deliberately-private 0o600 file
435 // grants group and other read access to a file in what may be a shared
436 // hooks directory.
437 perms.set_mode(perms.mode() | 0o111);
438 std::fs::set_permissions(path, perms)
439 .with_context(|| format!("could not chmod {}", path.display()))?;
440 }
441 #[cfg(not(unix))]
442 let _ = path;
443 Ok(())
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 /// The three bodies are built from `managed_marker!`, so a rename really
451 /// does reach all of them.
452 ///
453 /// The bodies used to hardcode the marker text while the constant's own
454 /// doc claimed they referenced it. A rename would then have left
455 /// `is_drep_managed` unable to recognise a hook drep had just written -
456 /// so drep would refuse to update its own hook, and the constant would
457 /// have been documentation of an invariant it did not hold.
458 #[test]
459 fn every_body_is_built_from_the_marker_constant() {
460 for body in [
461 PRE_COMMIT_BODY.to_owned(),
462 PRE_PUSH_BODY.to_owned(),
463 chainer_body("pre-push"),
464 ] {
465 assert!(
466 body.contains(MANAGED_MARKER),
467 "body must carry the marker: {body}"
468 );
469 assert!(
470 is_drep_managed(&body),
471 "and must therefore be recognised as drep's own"
472 );
473 }
474 assert!(!is_drep_managed("#!/bin/sh\necho hi\n"));
475 }
476
477 /// The two hook bodies are distinct and each runs the mode it is for.
478 ///
479 /// Nothing pinned this: `"pre-commit" => Some(PRE_PUSH_BODY)` passed the
480 /// whole suite, because the tests compared installed bytes against
481 /// `hook_body(name)` - the implementation itself - and only pre-push was
482 /// ever executed.
483 #[test]
484 fn each_hook_body_runs_the_mode_it_is_named_for() {
485 let pre_commit = hook_body("pre-commit").expect("known");
486 let pre_push = hook_body("pre-push").expect("known");
487
488 assert!(
489 pre_commit.contains("drep check --staged"),
490 "pre-commit reviews what is staged: {pre_commit}"
491 );
492 assert!(
493 !pre_commit.contains("--diff"),
494 "and not a diff against a ref: {pre_commit}"
495 );
496 assert!(
497 pre_push.contains("drep check --diff") && pre_push.contains("--tip"),
498 "pre-push reviews a range ending at the pushed ref: {pre_push}"
499 );
500 assert!(
501 !pre_push.contains("--staged"),
502 "nothing is staged at push time: {pre_push}"
503 );
504 assert_ne!(pre_commit, pre_push);
505 assert!(hook_body("unknown-hook").is_none());
506 }
507}