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