use crate::error::{GwmError, Result};
use std::path::{Path, PathBuf};
pub fn install_commit_msg(repo_root: &Path, force: bool) -> Result<PathBuf> {
let repo = git2::Repository::discover(repo_root).map_err(|_| {
GwmError::Other(format!(
"no git repository discovered from {} — `gwm hooks install` must be run from inside a git repo",
repo_root.display()
))
})?;
let hooks_dir = resolve_hooks_dir(&repo)?;
std::fs::create_dir_all(&hooks_dir)?;
let hook_path = hooks_dir.join("commit-msg");
if hook_path.exists() && !force {
return Err(GwmError::Other(format!(
"commit-msg hook already exists at {} — pass --force to overwrite",
hook_path.display()
)));
}
std::fs::write(&hook_path, commit_msg_script())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms)?;
}
Ok(hook_path)
}
fn resolve_hooks_dir(repo: &git2::Repository) -> Result<PathBuf> {
if let Ok(cfg) = repo.config() {
if let Ok(custom) = cfg.get_string("core.hooksPath") {
let trimmed = custom.trim();
if !trimmed.is_empty() {
let candidate = Path::new(trimmed);
let resolved = if candidate.is_absolute() {
candidate.to_path_buf()
} else if let Some(wd) = repo.workdir() {
wd.join(candidate)
} else {
repo.path().join("hooks")
};
return Ok(resolved);
}
}
}
Ok(repo.path().join("hooks"))
}
pub fn commit_msg_script() -> String {
r#"#!/bin/sh
# gwm commit-msg hook — auto-prepends the gitmoji + type prefix when
# the commit message doesn't already start with one. Installed by
# `gwm hooks install commit-msg` (issue #85). Re-running the installer
# with --force overwrites this file.
#
# Best-effort by design: every filesystem step is guarded so a
# transient failure (full /tmp, noexec mount, …) never blocks the
# commit — the user just loses the auto-prefix for that one commit.
set -u
# Skip when `gwm` isn't on $PATH — never block a commit because of us.
if ! command -v gwm >/dev/null 2>&1; then
exit 0
fi
msg_file="${1:-}"
[ -n "$msg_file" ] || exit 0
[ -f "$msg_file" ] || exit 0
# Find the first non-empty / non-comment line. `grep -nvE` returns
# `<lineno>:<text>` for every line that is NOT pure whitespace AND NOT
# a `#`-prefixed comment; we take the first match. Falling back to an
# empty string lets the downstream check no-op cleanly when the buffer
# only contains the git template (i.e. the user aborted with an empty
# message — git itself will reject that commit, no need for us to).
first_line="$(grep -nvE '^([[:space:]]*#|[[:space:]]*$)' "$msg_file" 2>/dev/null | sed -n '1s/^[0-9]*://p')"
[ -n "$first_line" ] || exit 0
# Already-prefixed messages are passed through untouched. We detect
# both the shortcode form (`:sparkles: feat(…)`) and the unicode form
# (✨ feat(…)) — any non-space first byte that looks like an emoji
# fence covers the latter. The shortcode check is tighter so common
# `:foo:` mentions in PR / issue subjects don't false-positive.
case "$first_line" in
:[a-z_]*:\ *) exit 0 ;;
esac
# Unicode emoji check via grep: the leading character is well into
# the BMP, so a `[^[:alnum:][:space:][:punct:]]` heuristic catches it
# without needing a full emoji table.
if printf '%s' "$first_line" | grep -qE '^[^[:alnum:][:space:][:punct:]]'; then
exit 0
fi
prefix="$(gwm commit-prefix --unicode 2>/dev/null)" || exit 0
[ -n "$prefix" ] || exit 0
# Prepend `<prefix> ` + the original body. Every fs step below is
# guarded with `|| exit 0` so a failure (read-only mount, full /tmp,
# noexec /tmp, …) leaves the user's commit message intact rather than
# aborting the commit.
tmp_file="$(mktemp "${TMPDIR:-/tmp}/gwm-commit-msg.XXXXXX" 2>/dev/null)" || exit 0
{ printf '%s ' "$prefix" > "$tmp_file"; } || { rm -f "$tmp_file"; exit 0; }
cat "$msg_file" >> "$tmp_file" 2>/dev/null || { rm -f "$tmp_file"; exit 0; }
mv "$tmp_file" "$msg_file" 2>/dev/null || { rm -f "$tmp_file"; exit 0; }
"#
.to_string()
}