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        writeln!(out, "  core.hooksPath is set to {value}")?;
314        writeln!(
315            out,
316            "  git looks there and not in .git/hooks, so a repo hook needs a chainer."
317        )?;
318
319        let chainer_dir = resolve_hooks_dir(root, &value);
320        for name in names {
321            ensure_chainer(out, &chainer_dir, name)?;
322        }
323    }
324
325    Ok(())
326}
327
328/// Resolve the hooks directory git would consult for repo-local hooks.
329///
330/// `git rev-parse --git-common-dir` rather than `root/.git`: in a linked
331/// worktree or a submodule `.git` is a *file*, so the literal path does not
332/// exist and the hook silently never runs.
333async fn locate_hooks_dir(root: &Path) -> Result<PathBuf> {
334    let common = diff::git_path(root, &["rev-parse", "--git-common-dir"])
335        .await
336        .with_context(|| format!("could not locate git common dir under {}", root.display()))?;
337    Ok(common.join("hooks"))
338}
339
340/// Query `core.hooksPath`. `Ok(None)` when genuinely unset.
341///
342/// `git config --get` exits **1** for "not found" and >=2 for a real error, so
343/// the two are distinguishable and must be distinguished: swallowing an error
344/// as "unset" means skipping the chainer while `core.hooksPath` is in fact set,
345/// which leaves the hook drep just wrote unable to ever run - reported as
346/// success.
347///
348/// An empty value is "unset" for our purposes and is *not* an error: git reads
349/// a blank `core.hooksPath` back as present-but-empty, which disables hooks
350/// entirely rather than naming a directory.
351async fn run_git_config_path(root: &Path) -> Result<Option<String>> {
352    // An *empty* value reads back as present-but-blank, which drep treats as
353    // unset, so it collapses into the same `None` as "no such key".
354    match diff::git_query(root, &["config", "--get", "--type=path", "core.hooksPath"]).await {
355        Ok(Some(value)) if value.is_empty() => Ok(None),
356        Ok(value) => Ok(value),
357        Err(err) => Err(anyhow!(
358            "could not read core.hooksPath ({err}); refusing to install a hook that \
359             may never run"
360        )),
361    }
362}
363
364/// Make sure a chainer for `name` exists in `dir`, executable, and chains.
365///
366/// Leaves a foreign chainer alone, reports the situation. `git` ignores a
367/// non-executable hook silently, which is the entire reason this branch
368/// exists.
369fn ensure_chainer<W: Write>(out: &mut W, dir: &Path, name: &str) -> Result<()> {
370    let chainer = dir.join(name);
371    match std::fs::read(&chainer) {
372        Ok(bytes) if is_drep_managed(&String::from_utf8_lossy(&bytes)) => {
373            let body = String::from_utf8_lossy(&bytes);
374            let current = chainer_body(name);
375            if body != current {
376                write_executable(&chainer, &current)?;
377                writeln!(out, "  Wrote {}", chainer.display())?;
378                return Ok(());
379            }
380            ensure_executable(out, &chainer)?;
381            return Ok(());
382        }
383        Ok(bytes) => {
384            let body = String::from_utf8_lossy(&bytes);
385            if forwarding::appears_to_forward(&body, name) {
386                ensure_executable(out, &chainer)?;
387                return Ok(());
388            }
389            writeln!(
390                out,
391                "  {} exists but does not appear to chain to the repo-local hook.",
392                chainer.display()
393            )?;
394            writeln!(out, "  drep will not run until it does.")?;
395            return Ok(());
396        }
397        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
398        Err(err) => {
399            return Err(anyhow::Error::new(err).context(format!(
400                "could not read existing chainer {}",
401                chainer.display()
402            )));
403        }
404    }
405
406    std::fs::create_dir_all(dir)
407        .with_context(|| format!("could not create chainer dir {}", dir.display()))?;
408    write_executable(&chainer, &chainer_body(name))?;
409    // Named in full, and flagged as outside the repository: this is the one
410    // thing `drep init` writes that is not under `root`, and a shared hooks
411    // directory is shared with every other repo on the machine.
412    writeln!(
413        out,
414        "  Wrote a chainer at {} (outside this repository)",
415        chainer.display()
416    )?;
417    Ok(())
418}
419
420/// Make an existing forwarding hook executable, reporting only a repair.
421fn ensure_executable<W: Write>(out: &mut W, path: &Path) -> Result<()> {
422    let was_executable = crate::languages::runner::is_executable(path);
423    set_executable(path)?;
424    if !was_executable {
425        writeln!(out, "  {} is not executable; making it so.", path.display())?;
426    }
427    Ok(())
428}
429
430/// Publish a byte-for-byte backup without replacing an earlier recovery copy.
431fn write_backup(path: &Path, body: &[u8]) -> Result<()> {
432    let parent = path
433        .parent()
434        .ok_or_else(|| anyhow!("backup path {} has no parent", path.display()))?;
435    let mut temporary = tempfile::NamedTempFile::new_in(parent)
436        .with_context(|| format!("could not back up to {}", path.display()))?;
437    temporary
438        .write_all(body)
439        .with_context(|| format!("could not back up to {}", path.display()))?;
440    temporary
441        .as_file()
442        .sync_all()
443        .with_context(|| format!("could not back up to {}", path.display()))?;
444    temporary.persist_noclobber(path).map_err(|err| {
445        if err.error.kind() == std::io::ErrorKind::AlreadyExists {
446            anyhow::Error::new(err.error).context(format!(
447                "could not back up to {}; move the existing backup and retry",
448                path.display()
449            ))
450        } else {
451            anyhow::Error::new(err.error)
452                .context(format!("could not publish backup to {}", path.display()))
453        }
454    })?;
455    Ok(())
456}
457
458/// Write `body` to `path` and make it executable, atomically.
459///
460/// Via a sibling temp file and a rename, because `fs::write` truncates in
461/// place: an interruption mid-write leaves a *truncated but executable* hook,
462/// and since these bodies open with a shebang and comments, a truncated one
463/// exits 0 and waves every push through. A rename is atomic on the same
464/// filesystem, so a hook is either the old one or the new one.
465fn write_executable(path: &Path, body: &str) -> Result<()> {
466    let parent = path
467        .parent()
468        .ok_or_else(|| anyhow!("hook path {} has no parent", path.display()))?;
469    let mut temporary = tempfile::NamedTempFile::new_in(parent)
470        .with_context(|| format!("could not write hook {}", path.display()))?;
471    temporary
472        .write_all(body.as_bytes())
473        .with_context(|| format!("could not write hook {}", path.display()))?;
474    set_executable(temporary.path())?;
475    temporary
476        .as_file()
477        .sync_all()
478        .with_context(|| format!("could not write hook {}", path.display()))?;
479    temporary.persist(path).map_err(|err| {
480        anyhow::Error::new(err.error).context(format!("could not install hook {}", path.display()))
481    })?;
482    Ok(())
483}
484
485/// Make `path` executable. A no-op where the platform has no such bit.
486///
487/// One function with the `cfg` inside its body, not two cfg-gated
488/// definitions - the same rule `languages::runner::is_executable` follows and
489/// for the same reason: the inactive definition is unreachable on this
490/// platform, so every mutation of it survives by construction and shows up in
491/// `cargo mutants` as a finding no test can ever address.
492fn set_executable(path: &Path) -> Result<()> {
493    #[cfg(unix)]
494    {
495        use std::os::unix::fs::PermissionsExt;
496        let mut perms = std::fs::metadata(path)
497            .with_context(|| format!("could not stat {}", path.display()))?
498            .permissions();
499        // `| 0o111`, not `| 0o755`: adding the execute bits is the whole
500        // requirement, and OR-ing 0o755 onto a deliberately-private 0o600 file
501        // grants group and other read access to a file in what may be a shared
502        // hooks directory.
503        perms.set_mode(perms.mode() | 0o111);
504        std::fs::set_permissions(path, perms)
505            .with_context(|| format!("could not chmod {}", path.display()))?;
506    }
507    #[cfg(not(unix))]
508    let _ = path;
509    Ok(())
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    /// The three bodies are built from `managed_marker!`, so a rename really
517    /// does reach all of them.
518    ///
519    /// The bodies used to hardcode the marker text while the constant's own
520    /// doc claimed they referenced it. A rename would then have left
521    /// `is_drep_managed` unable to recognise a hook drep had just written -
522    /// so drep would refuse to update its own hook, and the constant would
523    /// have been documentation of an invariant it did not hold.
524    #[test]
525    fn every_body_is_built_from_the_marker_constant() {
526        for body in [
527            PRE_COMMIT_BODY.to_owned(),
528            PRE_PUSH_BODY.to_owned(),
529            chainer_body("pre-push"),
530        ] {
531            assert!(
532                body.contains(MANAGED_MARKER),
533                "body must carry the marker: {body}"
534            );
535            assert!(
536                is_drep_managed(&body),
537                "and must therefore be recognised as drep's own"
538            );
539        }
540        assert!(!is_drep_managed("#!/bin/sh\necho hi\n"));
541    }
542
543    /// The two hook bodies are distinct and each runs the mode it is for.
544    ///
545    /// Nothing pinned this: `"pre-commit" => Some(PRE_PUSH_BODY)` passed the
546    /// whole suite, because the tests compared installed bytes against
547    /// `hook_body(name)` - the implementation itself - and only pre-push was
548    /// ever executed.
549    #[test]
550    fn each_hook_body_runs_the_mode_it_is_named_for() {
551        let pre_commit = hook_body("pre-commit").expect("known");
552        let pre_push = hook_body("pre-push").expect("known");
553
554        assert!(
555            pre_commit.contains("drep check --staged"),
556            "pre-commit reviews what is staged: {pre_commit}"
557        );
558        assert!(
559            !pre_commit.contains("--diff"),
560            "and not a diff against a ref: {pre_commit}"
561        );
562        assert!(
563            pre_push.contains("drep check --push-gate --diff") && pre_push.contains("--tip"),
564            "pre-push reviews a range ending at the pushed ref: {pre_push}"
565        );
566        assert!(
567            !pre_push.contains("--staged"),
568            "nothing is staged at push time: {pre_push}"
569        );
570        assert_ne!(pre_commit, pre_push);
571        assert!(hook_body("unknown-hook").is_none());
572    }
573}