crates/nornir-docgen/src/hooks.rs
//! Pre-commit hook installer for the docs **auto-trigger**.
//!
//! `nornir docs install-hooks <repo>` writes a *tracked* pre-commit hook to
//! `<repo>/.githooks/pre-commit` and points the repo's git at it with
//! `git config core.hooksPath .githooks`. The hook re-renders the repo's
//! managed markdown (`README.md` / `CHANGELOG.md`) **markdown-only** (never the
//! noisy SVG/PDF) and `git add`s the regenerated docs so each commit ships fresh
//! docs without a manual `docs render`.
//!
//! Two hard rules baked into the script:
//!
//! 1. **Markdown only.** It runs `nornir docs render <repo> --markdown-only`, so
//! a commit never regenerates the non-deterministic depgraph SVG / book PDF.
//! Those heavy artifacts are refreshed by `docs render` (full) and the release
//! backstop (nornir's `release::gate::render_and_stage_docs`).
//! 2. **Never block an unrelated commit.** If `nornir` isn't on `PATH`, the
//! warehouse is unreachable, or render fails for *any* reason, the hook prints
//! a warning and **exits 0**. Docs freshness is a convenience, not a gate —
//! the authoritative check is the release `docs_fresh` gate.
//!
//! Why `.githooks/` + `core.hooksPath` and not `.git/hooks/`: the former is
//! tracked and shareable (every clone that runs `install-hooks`, or that we tell
//! git to honour, gets the same hook), whereas `.git/hooks/` is untracked and
//! per-clone.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// Directory (relative to the repo root) holding the tracked hooks.
pub const HOOKS_DIR: &str = ".githooks";
/// The pre-commit hook file name.
pub const PRE_COMMIT: &str = "pre-commit";
/// Outcome of [`install_hooks`].
#[derive(Debug)]
pub struct InstallReport {
/// Absolute path of the written hook script.
pub hook_path: PathBuf,
/// `true` if the hook content changed (or was newly created); `false` if it
/// was already byte-identical (idempotent re-run).
pub hook_changed: bool,
/// `true` if `core.hooksPath` was set/updated; `false` if already correct.
pub hookspath_changed: bool,
}
/// Render the POSIX-sh pre-commit hook body for `repo_name`.
///
/// The script is deliberately conservative: it discovers `nornir` on `PATH`,
/// bails *softly* (exit 0) when anything is missing or fails, and only ever
/// `git add`s the managed markdown so it can never sweep unrelated changes into
/// the commit.
pub fn pre_commit_script(repo_name: &str) -> String {
// `repo_name` is a config key (alnum/_/-) — safe to embed. We still
// single-quote it in the script for defence in depth.
format!(
r#"#!/bin/sh
# nornir docs auto-trigger — re-render managed markdown (README.md / CHANGELOG.md)
# before each commit and stage the result, so published docs never drift.
#
# GENERATED by `nornir docs install-hooks {repo}` — safe to re-generate.
#
# HARD RULES:
# * markdown only — never regenerates the depgraph SVG or the rendered book.
# * never blocks a commit — any failure prints a warning and exits 0.
set -u
REPO='{repo}'
# Degrade gracefully if nornir isn't installed.
if ! command -v nornir >/dev/null 2>&1; then
echo "nornir: docs pre-commit skipped (nornir not on PATH)" >&2
exit 0
fi
# Re-render managed markdown only (no assets). Capture output; on ANY failure
# (warehouse unreachable, render error, …) warn and exit 0 — never block.
if ! out=$(nornir docs render "$REPO" --markdown-only 2>&1); then
echo "nornir: docs render failed — committing without doc refresh" >&2
echo "$out" | sed 's/^/nornir: /' >&2
exit 0
fi
# Stage only the managed docs nornir owns, and only if they exist + changed.
for f in README.md CHANGELOG.md; do
if [ -f "$f" ]; then
git add -- "$f" 2>/dev/null || true
fi
done
exit 0
"#,
repo = repo_name
)
}
/// Install (or refresh) the tracked pre-commit hook for `repo_root` and point
/// the repo's git at `.githooks` via `core.hooksPath`. Idempotent.
///
/// `repo_name` is the nornir config key passed through to the script's
/// `nornir docs render <repo>` invocation.
pub fn install_hooks(repo_root: &Path, repo_name: &str) -> Result<InstallReport> {
let hooks_dir = repo_root.join(HOOKS_DIR);
std::fs::create_dir_all(&hooks_dir)
.with_context(|| format!("create {}", hooks_dir.display()))?;
let hook_path = hooks_dir.join(PRE_COMMIT);
let want = pre_commit_script(repo_name);
let prev = std::fs::read_to_string(&hook_path).ok();
let hook_changed = prev.as_deref() != Some(want.as_str());
if hook_changed {
std::fs::write(&hook_path, &want)
.with_context(|| format!("write {}", hook_path.display()))?;
}
// Make it executable (POSIX). Git honours the bit when running the hook.
set_executable(&hook_path)?;
let hookspath_changed = set_hookspath(repo_root)?;
Ok(InstallReport {
hook_path,
hook_changed,
hookspath_changed,
})
}
/// `chmod +x` equivalent: ensure the owner/group/other execute bits are set.
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?
.permissions();
let mode = perm.mode();
// Add execute for user/group/other where read is present.
perm.set_mode(mode | 0o755);
std::fs::set_permissions(path, perm).with_context(|| format!("chmod +x {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
// No-op on non-unix: git on Windows runs `sh` hooks regardless of the bit.
Ok(())
}
/// Read the current `core.hooksPath` (trimmed), if any. Pure-gix (reads the
/// repo's config snapshot via the `nornir-git` leaf — no `git config` subprocess).
pub fn current_hookspath(repo_root: &Path) -> Option<String> {
nornir_git::gitops::read_local_config(repo_root, "core.hooksPath")
.ok()
.flatten()
}
/// Set `core.hooksPath` to `.githooks` for `repo_root`. Returns `true` if the
/// value changed (was unset or different). No-op (returns `false`) if already
/// correct.
pub fn set_hookspath(repo_root: &Path) -> Result<bool> {
if current_hookspath(repo_root).as_deref() == Some(HOOKS_DIR) {
return Ok(false);
}
// Pure-gix local-config write (no `git config` subprocess).
nornir_git::gitops::set_local_config(repo_root, "core.hooksPath", HOOKS_DIR)
.with_context(|| format!("set core.hooksPath in {}", repo_root.display()))?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn git_repo() -> TempDir {
let t = TempDir::new().unwrap();
let ok = std::process::Command::new("git")
.arg("-C")
.arg(t.path())
.arg("init")
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "git init failed (is git installed?)");
t
}
/// install_hooks writes the script AND sets core.hooksPath = .githooks.
#[test]
fn install_writes_script_and_sets_hookspath() {
let t = git_repo();
let report = install_hooks(t.path(), "znippy").unwrap();
// The script exists at the tracked path and is non-empty.
let hook = t.path().join(HOOKS_DIR).join(PRE_COMMIT);
assert_eq!(report.hook_path, hook);
assert!(report.hook_changed, "first install should write the hook");
let body = std::fs::read_to_string(&hook).unwrap();
// It is a POSIX-sh script that renders markdown-only for THIS repo and
// never blocks (exit 0 on failure).
assert!(body.starts_with("#!/bin/sh"), "must be POSIX sh: {body:?}");
assert!(body.contains("nornir docs render \"$REPO\" --markdown-only"));
assert!(body.contains("REPO='znippy'"));
assert!(body.contains("exit 0"));
// Never blocks: there is no `exit 1` anywhere in the script.
assert!(!body.contains("exit 1"), "hook must never block a commit");
// Markdown only: it must invoke render with --markdown-only and never the
// heavy book/export verbs.
assert!(!body.contains("docs book"), "hook must not build the book");
assert!(!body.contains("docs export"), "hook must not run export");
// core.hooksPath now points at .githooks.
assert!(report.hookspath_changed);
assert_eq!(current_hookspath(t.path()).as_deref(), Some(HOOKS_DIR));
// Executable bit set on unix.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&hook).unwrap().permissions().mode();
assert!(mode & 0o111 != 0, "hook must be executable; mode={mode:o}");
}
}
/// Re-running install_hooks is a no-op: same bytes, hooksPath already set.
#[test]
fn install_is_idempotent() {
let t = git_repo();
let r1 = install_hooks(t.path(), "skade").unwrap();
assert!(r1.hook_changed);
assert!(r1.hookspath_changed);
let r2 = install_hooks(t.path(), "skade").unwrap();
assert!(!r2.hook_changed, "second run should not rewrite the hook");
assert!(!r2.hookspath_changed, "core.hooksPath already set");
// Bytes unchanged across runs.
assert_eq!(
std::fs::read_to_string(r1.hook_path).unwrap(),
std::fs::read_to_string(r2.hook_path).unwrap()
);
}
/// The script embeds the exact repo name it was generated for.
#[test]
fn script_targets_named_repo() {
let s = pre_commit_script("holger");
assert!(s.contains("REPO='holger'"));
assert!(s.contains("install-hooks holger"));
}
/// GRACEFUL DEGRADE (the critical rule): when `nornir docs render` FAILS, the
/// hook must print a warning and exit **0** — never block the commit. We run
/// the generated script with a fake `nornir` on PATH that exits non-zero, and
/// assert: exit status 0, the warning was printed, and the managed markdown
/// was NOT staged (a failed render stages nothing).
#[cfg(unix)]
#[test]
fn render_failure_exits_zero_and_does_not_block() {
let t = git_repo();
let root = t.path();
install_hooks(root, "znippy").unwrap();
// A README that must remain UNTOUCHED when render fails.
std::fs::write(root.join("README.md"), "# original\n").unwrap();
// Fake `nornir` that always FAILS — simulates warehouse-unreachable /
// render error. Put it on a PATH-shadowing bin dir.
let bin = root.join("fakebin");
std::fs::create_dir_all(&bin).unwrap();
let fake = bin.join("nornir");
std::fs::write(
&fake,
"#!/bin/sh\necho 'boom: warehouse unreachable' >&2\nexit 7\n",
)
.unwrap();
set_executable(&fake).unwrap();
let hook = root.join(HOOKS_DIR).join(PRE_COMMIT);
let path_env = format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = std::process::Command::new("/bin/sh")
.arg(&hook)
.current_dir(root)
.env("PATH", &path_env)
.output()
.unwrap();
// EXIT 0 — the commit is never blocked.
assert!(
out.status.success(),
"hook must exit 0 even when render fails; status={:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
// It warned the user.
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("docs render failed"),
"hook should warn on render failure; stderr={stderr}"
);
// The README is left untouched (a failed render writes nothing).
assert_eq!(
std::fs::read_to_string(root.join("README.md")).unwrap(),
"# original\n"
);
}
/// When `nornir` is entirely absent from PATH the hook still exits 0 (it
/// degrades to a skip) — the other half of "never block a commit".
#[cfg(unix)]
#[test]
fn missing_nornir_exits_zero() {
let t = git_repo();
let root = t.path();
install_hooks(root, "skade").unwrap();
let hook = root.join(HOOKS_DIR).join(PRE_COMMIT);
// Empty PATH ⇒ `command -v nornir` fails ⇒ skip + exit 0. Provide a
// minimal PATH that contains neither nornir nor git tooling beyond sh.
let out = std::process::Command::new("/bin/sh")
.arg(&hook)
.current_dir(root)
.env("PATH", "/nonexistent-empty-path")
.output()
.unwrap();
assert!(
out.status.success(),
"hook must exit 0 when nornir is missing"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("not on PATH"),
"should warn nornir missing; stderr={stderr}"
);
}
}