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}