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