Skip to main content

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