use std::path::PathBuf;
use camino::Utf8Path;
pub const MARKER: &str = "# release-kit branch reminder";
static BODY: &str = include_str!("../../blocks/post-merge-hook.sh");
#[must_use]
pub fn hook_body() -> &'static [u8] {
BODY.as_bytes()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookState {
Installed,
Drifted,
Foreign,
Absent,
Unreadable(String),
}
pub fn hook_path(target: &Utf8Path) -> Result<PathBuf, String> {
let mut command = std::process::Command::new("git");
for var in crate::maintenance::GIT_HOOK_VARS {
command.env_remove(var);
}
let answered = command
.arg("-C")
.arg(target.as_std_path())
.args(["rev-parse", "--git-path", "hooks"])
.output()
.map_err(|source| format!("git did not run: {source}"))?;
if !answered.status.success() {
return Err(format!("{target} is not a git repository"));
}
let hooks = String::from_utf8_lossy(&answered.stdout).trim().to_owned();
if hooks.is_empty() {
return Err("git named no hooks directory".to_owned());
}
let hooks = PathBuf::from(hooks);
let hooks = if hooks.is_absolute() {
hooks
} else {
target.as_std_path().join(hooks)
};
Ok(hooks.join("post-merge"))
}
#[must_use]
pub fn observe_hook(target: &Utf8Path) -> HookState {
let path = match hook_path(target) {
Ok(path) => path,
Err(detail) => return HookState::Unreadable(detail),
};
match std::fs::symlink_metadata(&path) {
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return HookState::Absent,
Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
Ok(meta) if !meta.is_file() => return HookState::Foreign,
Ok(_) => {}
}
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
};
if !String::from_utf8_lossy(&bytes).contains(MARKER) {
return HookState::Foreign;
}
let executable = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(&path).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
true
}
};
if bytes == hook_body() && executable {
HookState::Installed
} else {
HookState::Drifted
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
#[test]
fn the_hook_body_is_the_authored_file_verbatim() {
let disk = std::fs::read(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("blocks/post-merge-hook.sh"),
)
.expect("the authored hook body exists");
assert_eq!(super::hook_body(), disk.as_slice());
}
#[test]
fn the_hook_body_carries_the_marker_and_never_fails() {
let body = std::str::from_utf8(super::hook_body()).expect("the hook body is UTF-8");
assert!(body.starts_with("#!/bin/sh\n"));
assert!(body.contains(super::MARKER));
for verb in ["branches", "worktree"] {
assert!(
body.contains(&format!("if rk {verb} prune --help >/dev/null 2>&1; then")),
"the {verb} call is guarded by its own capability probe"
);
assert!(
body.contains(&format!("rk {verb} prune --quiet || :")),
"the {verb} prune runs quiet and never fails the pull"
);
}
assert!(
!body.contains("command -v"),
"a presence check answers the wrong question; the probe is per verb"
);
assert!(body.ends_with("exit 0\n"));
}
}