Skip to main content

gwm/
hooks.rs

1//! Git hook installer (issue #85).
2//!
3//! Currently exposes a single hook: `commit-msg`. The installed script
4//! shells out to `gwm commit-prefix --unicode` and auto-prepends the
5//! resolved Gitmoji + Conventional Commits prefix when the user's
6//! commit message doesn't already start with one.
7//!
8//! Design:
9//! - **Opt-in** — `gwm` never installs hooks implicitly. The
10//!   `gwm hooks install` subcommand is the only entry point.
11//! - **Non-destructive by default** — refuses to overwrite a
12//!   pre-existing `commit-msg` (husky, commitlint, pre-commit, …)
13//!   unless `--force` is passed.
14//! - **Linked-worktree aware** — `gwm`'s primary use case is linked
15//!   worktrees whose `.git` is a file pointing at the real admin dir
16//!   (`<main>/.git/worktrees/<name>/`). The installer resolves the
17//!   effective gitdir via `git2::Repository::discover` rather than
18//!   blindly joining `.git/hooks/` to the workdir path.
19//! - **`core.hooksPath` aware** — when the repo config sets a custom
20//!   hooks directory (this project even recommends
21//!   `git config core.hooksPath .githooks`), the installer writes
22//!   into that directory. Writing into `.git/hooks/` while
23//!   `core.hooksPath` is set is dead code: git never invokes hooks
24//!   from the default location once the override is in play.
25//! - **Self-contained, best-effort script** — the generated POSIX
26//!   `sh` script uses only widely-available tools (`grep`, `sed`,
27//!   `printf`, `cat`, `mv`, `mktemp`, `command -v`). It deliberately
28//!   does NOT enable `set -e`: a transient filesystem failure must
29//!   not abort the user's commit. The shell-out to
30//!   `gwm commit-prefix` degrades gracefully when `gwm` is not on
31//!   `$PATH` (silent no-op rather than blocking the commit).
32//!
33//! The script is materialised by [`commit_msg_script`] and
34//! installed by [`install_commit_msg`]. Tests against the rendered
35//! script live in `tests/hooks_tests.rs` so a regression on the
36//! detection clause (the "is the message already prefixed?" guard)
37//! surfaces immediately rather than at the user's next `git commit`.
38
39use crate::error::{GwmError, Result};
40use std::path::{Path, PathBuf};
41
42/// Install `commit-msg` for the repository rooted at `repo_root`. The
43/// effective target directory is resolved as follows:
44///
45/// 1. Open the repository via `git2::Repository::discover` so a
46///    linked worktree's `.git` file pointer is followed transparently.
47/// 2. If `core.hooksPath` is set in the repo config (the project's
48///    recommended setup is `core.hooksPath = .githooks`), resolve it
49///    against the workdir and install there. The directory is created
50///    if missing.
51/// 3. Otherwise fall back to `<gitdir>/hooks/` (where `<gitdir>` is
52///    the worktree's admin dir — e.g.
53///    `<main>/.git/worktrees/<name>/` for a linked worktree, or
54///    `<root>/.git/` for the main worktree).
55///
56/// Returns the written path on success. When `force` is `false`
57/// (default) and the target file already exists, returns
58/// `GwmError::Other("commit-msg hook already exists at … — pass
59/// --force to overwrite")` without touching the file. Bare repos and
60/// non-git dirs are rejected with an error; the goal is "fail
61/// loudly, leak nothing into the filesystem".
62pub fn install_commit_msg(repo_root: &Path, force: bool) -> Result<PathBuf> {
63  let repo = git2::Repository::discover(repo_root).map_err(|_| {
64    GwmError::Other(format!(
65      "no git repository discovered from {} — `gwm hooks install` must be run from inside a git repo",
66      repo_root.display()
67    ))
68  })?;
69
70  let hooks_dir = resolve_hooks_dir(&repo)?;
71  std::fs::create_dir_all(&hooks_dir)?;
72
73  let hook_path = hooks_dir.join("commit-msg");
74  if hook_path.exists() && !force {
75    return Err(GwmError::Other(format!(
76      "commit-msg hook already exists at {} — pass --force to overwrite",
77      hook_path.display()
78    )));
79  }
80
81  std::fs::write(&hook_path, commit_msg_script())?;
82
83  // Mark the hook executable. Git refuses to run a non-executable
84  // hook silently — which would be the worst failure mode for a
85  // "commit-msg" hook: the user thinks their commits are getting
86  // auto-prefixed, but git is just skipping the hook entirely.
87  #[cfg(unix)]
88  {
89    use std::os::unix::fs::PermissionsExt;
90    let mut perms = std::fs::metadata(&hook_path)?.permissions();
91    perms.set_mode(0o755);
92    std::fs::set_permissions(&hook_path, perms)?;
93  }
94
95  Ok(hook_path)
96}
97
98/// Resolve the directory git will actually load hooks from. Priority
99/// order matches git itself: `core.hooksPath` (if set) > `<gitdir>/hooks`.
100///
101/// `core.hooksPath` is resolved against the workdir when it's
102/// relative (matches git's own behaviour — `git config
103/// core.hooksPath .githooks` puts hooks at `<repo>/.githooks/`, not
104/// `<cwd>/.githooks/`). Absolute paths are honoured verbatim.
105fn resolve_hooks_dir(repo: &git2::Repository) -> Result<PathBuf> {
106  // `Repository::config` opens a snapshot view that includes the
107  // global / system / repo layers in priority order — exactly what
108  // git uses when invoking hooks at commit time.
109  if let Ok(cfg) = repo.config() {
110    if let Ok(custom) = cfg.get_string("core.hooksPath") {
111      let trimmed = custom.trim();
112      if !trimmed.is_empty() {
113        let candidate = Path::new(trimmed);
114        let resolved = if candidate.is_absolute() {
115          candidate.to_path_buf()
116        } else if let Some(wd) = repo.workdir() {
117          wd.join(candidate)
118        } else {
119          // Bare repo + relative `core.hooksPath` — exceedingly rare
120          // and ill-defined in git itself. Fall through to the
121          // default gitdir-based location so we at least produce a
122          // deterministic path.
123          repo.path().join("hooks")
124        };
125        return Ok(resolved);
126      }
127    }
128  }
129  // `repo.path()` returns the gitdir — `<main>/.git/` for the main
130  // worktree, `<main>/.git/worktrees/<name>/` for a linked one. This
131  // is the path git itself uses to locate the default hooks dir.
132  Ok(repo.path().join("hooks"))
133}
134
135/// Return the body of the generated `commit-msg` hook. Exposed as a
136/// pure function so tests can assert on the rendered script without
137/// touching the filesystem, and so future hook variants (commit-msg,
138/// pre-push, …) can share helpers from this module.
139///
140/// The script:
141/// 1. Reads the in-progress commit message (path passed by git as `$1`).
142/// 2. Locates the *first non-empty non-comment* line — git's own
143///    commit template puts `# Please enter the commit message…`
144///    above the user's first real line, and `git commit -v` appends
145///    a diff dump prefixed with `#`. Both are stripped before the
146///    "is the message already prefixed?" check.
147/// 3. Bails out (exit 0) if that line already starts with an
148///    emoji-ish prefix (a `case` match against the `:shortcode:`
149///    form + a `grep -E` for unicode emoji codepoints).
150/// 4. Shells out to `gwm commit-prefix --unicode`. If `gwm` is
151///    missing from `$PATH`, or the shell-out fails for any reason,
152///    the hook exits 0 cleanly — the goal is "never block a commit
153///    because the hook itself broke".
154/// 5. Prepends the resolved prefix + a space to the message and
155///    writes the result back to the same file. Every filesystem
156///    step here is guarded by `|| exit 0` so a transient failure
157///    (full /tmp, noexec mount, read-only fs, …) lets the original
158///    commit through unmodified rather than aborting it.
159pub fn commit_msg_script() -> String {
160  // The raw string literal keeps escaping minimal. We deliberately do
161  // NOT enable `set -e`: the script is best-effort, and any failure
162  // path must return 0 so `git commit` proceeds with the user's
163  // original message. `set -u` is kept so referencing an unset
164  // variable surfaces as a real bug (rather than silently producing
165  // empty output).
166  r#"#!/bin/sh
167# gwm commit-msg hook — auto-prepends the gitmoji + type prefix when
168# the commit message doesn't already start with one. Installed by
169# `gwm hooks install commit-msg` (issue #85). Re-running the installer
170# with --force overwrites this file.
171#
172# Best-effort by design: every filesystem step is guarded so a
173# transient failure (full /tmp, noexec mount, …) never blocks the
174# commit — the user just loses the auto-prefix for that one commit.
175
176set -u
177
178# Skip when `gwm` isn't on $PATH — never block a commit because of us.
179if ! command -v gwm >/dev/null 2>&1; then
180  exit 0
181fi
182
183msg_file="${1:-}"
184[ -n "$msg_file" ] || exit 0
185[ -f "$msg_file" ] || exit 0
186
187# Find the first non-empty / non-comment line. `grep -nvE` returns
188# `<lineno>:<text>` for every line that is NOT pure whitespace AND NOT
189# a `#`-prefixed comment; we take the first match. Falling back to an
190# empty string lets the downstream check no-op cleanly when the buffer
191# only contains the git template (i.e. the user aborted with an empty
192# message — git itself will reject that commit, no need for us to).
193first_line="$(grep -nvE '^([[:space:]]*#|[[:space:]]*$)' "$msg_file" 2>/dev/null | sed -n '1s/^[0-9]*://p')"
194[ -n "$first_line" ] || exit 0
195
196# Already-prefixed messages are passed through untouched. We detect
197# both the shortcode form (`:sparkles: feat(…)`) and the unicode form
198# (✨ feat(…)) — any non-space first byte that looks like an emoji
199# fence covers the latter. The shortcode check is tighter so common
200# `:foo:` mentions in PR / issue subjects don't false-positive.
201case "$first_line" in
202  :[a-z_]*:\ *) exit 0 ;;
203esac
204
205# Unicode emoji check via grep: the leading character is well into
206# the BMP, so a `[^[:alnum:][:space:][:punct:]]` heuristic catches it
207# without needing a full emoji table.
208if printf '%s' "$first_line" | grep -qE '^[^[:alnum:][:space:][:punct:]]'; then
209  exit 0
210fi
211
212prefix="$(gwm commit-prefix --unicode 2>/dev/null)" || exit 0
213[ -n "$prefix" ] || exit 0
214
215# Prepend `<prefix> ` + the original body. Every fs step below is
216# guarded with `|| exit 0` so a failure (read-only mount, full /tmp,
217# noexec /tmp, …) leaves the user's commit message intact rather than
218# aborting the commit.
219tmp_file="$(mktemp "${TMPDIR:-/tmp}/gwm-commit-msg.XXXXXX" 2>/dev/null)" || exit 0
220{ printf '%s ' "$prefix" > "$tmp_file"; } || { rm -f "$tmp_file"; exit 0; }
221cat "$msg_file" >> "$tmp_file" 2>/dev/null || { rm -f "$tmp_file"; exit 0; }
222mv "$tmp_file" "$msg_file" 2>/dev/null || { rm -f "$tmp_file"; exit 0; }
223"#
224  .to_string()
225}