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