use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub const HOOKS_DIR: &str = ".githooks";
pub const PRE_COMMIT: &str = "pre-commit";
#[derive(Debug)]
pub struct InstallReport {
pub hook_path: PathBuf,
pub hook_changed: bool,
pub hookspath_changed: bool,
}
pub fn pre_commit_script(repo_name: &str) -> String {
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
)
}
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()))?;
}
set_executable(&hook_path)?;
let hookspath_changed = set_hookspath(repo_root)?;
Ok(InstallReport { hook_path, hook_changed, hookspath_changed })
}
#[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();
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<()> {
Ok(())
}
pub fn current_hookspath(repo_root: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["config", "--local", "--get", "core.hooksPath"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
pub fn set_hookspath(repo_root: &Path) -> Result<bool> {
if current_hookspath(repo_root).as_deref() == Some(HOOKS_DIR) {
return Ok(false);
}
let status = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["config", "--local", "core.hooksPath", HOOKS_DIR])
.status()
.with_context(|| format!("git -C {} config core.hooksPath", repo_root.display()))?;
if !status.success() {
anyhow::bail!(
"git config core.hooksPath {HOOKS_DIR} failed 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
}
#[test]
fn install_writes_script_and_sets_hookspath() {
let t = git_repo();
let report = install_hooks(t.path(), "znippy").unwrap();
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();
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"));
assert!(!body.contains("exit 1"), "hook must never block a commit");
assert!(!body.contains("docs book"), "hook must not build the book");
assert!(!body.contains("docs export"), "hook must not run export");
assert!(report.hookspath_changed);
assert_eq!(current_hookspath(t.path()).as_deref(), Some(HOOKS_DIR));
#[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}");
}
}
#[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");
assert_eq!(
std::fs::read_to_string(r1.hook_path).unwrap(),
std::fs::read_to_string(r2.hook_path).unwrap()
);
}
#[test]
fn script_targets_named_repo() {
let s = pre_commit_script("holger");
assert!(s.contains("REPO='holger'"));
assert!(s.contains("install-hooks holger"));
}
#[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();
std::fs::write(root.join("README.md"), "# original\n").unwrap();
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();
assert!(
out.status.success(),
"hook must exit 0 even when render fails; status={:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("docs render failed"),
"hook should warn on render failure; stderr={stderr}"
);
assert_eq!(
std::fs::read_to_string(root.join("README.md")).unwrap(),
"# original\n"
);
}
#[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);
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}");
}
}