release-kit 0.2.10

A canonical release workflow: a technology-agnostic method, per-technology bindings, and the rk CLI that lands and serves them.
Documentation
//! The post-merge reminder hook `rk setup step branch-reminder` writes.
//!
//! No git event fires when the forge squash-merges and deletes a branch;
//! the nearest local event is the pull that fetches the result, which is a
//! merge, so `post-merge` fires with the `[gone]` marker freshly true.
//! The hook only reminds — the quiet prunes print nothing when the clone
//! is clean and never delete — and it never blocks a pull. Each call is
//! guarded by a capability probe on its own verb (`rk <verb> --help`),
//! not by `command -v rk`: the probe answers the question the hook
//! actually has — can this `rk` prune this resource? — so a missing
//! binary, one too old for the verb, and one that renamed it all fail
//! identically and print nothing, while the real invocations keep their
//! stderr so a genuine refusal still reaches the operator. The body is
//! authored as `blocks/post-merge-hook.sh` and embedded verbatim: it
//! belongs to no forge, so it lives with the other host-written texts
//! rather than in a `setup/<forge>/` tree, per
//! `ADR-author-every-host-written-text-as-payload`.

use std::path::PathBuf;

use camino::Utf8Path;

/// The marker line a reminder hook carries; its absence makes a hook
/// foreign, and a foreign hook is never written over.
pub const MARKER: &str = "# release-kit branch reminder";

/// The authored hook body, `blocks/post-merge-hook.sh`, embedded whole.
static BODY: &str = include_str!("../../blocks/post-merge-hook.sh");

/// The whole hook, byte for byte: `blocks/post-merge-hook.sh` verbatim,
/// final newline included.
///
/// No `set -eu` on purpose: the contract
/// is exit 0 always, and the `|| :` plus the final line hold it. The
/// probes are per verb, because during a transition a binary exists that
/// carries one prune verb and not the other; one probe for both would
/// silence the half that works or admit the half that does not.
#[must_use]
pub fn hook_body() -> &'static [u8] {
    BODY.as_bytes()
}

/// What the hook file holds today.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookState {
    /// The file is this binary's body, executable.
    Installed,
    /// The marker is present but the body or the mode drifted.
    Drifted,
    /// A post-merge hook exists without the marker; it is someone else's.
    Foreign,
    /// No post-merge hook exists.
    Absent,
    /// The hooks directory or the file could not be read.
    Unreadable(String),
}

/// Where git will look for the post-merge hook: `rev-parse --git-path`
/// answers through gitfiles, linked worktrees, and `core.hooksPath`, and
/// a relative answer is relative to the target it ran in.
///
/// # Errors
///
/// The detail of a git that did not run or did not answer.
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"))
}

/// Read the hook file and judge it against this binary's body.
#[must_use]
pub fn observe_hook(target: &Utf8Path) -> HookState {
    let path = match hook_path(target) {
        Ok(path) => path,
        Err(detail) => return HookState::Unreadable(detail),
    };
    // Judge the entry itself, not what it points at: a symlink - dangling
    // or not - is another manager's installation style, and a read
    // through it would misclassify the dangling case as absent and let
    // the atomic writer's rename replace the link.
    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)]

    /// The body opens with a shebang, carries the marker, probes each
    /// verb separately before its quiet prune, and ends by succeeding
    /// whatever happened above.
    /// The body is `blocks/post-merge-hook.sh` byte for byte — no strip,
    /// no render — so what the step writes is exactly what is authored.
    #[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"));
    }
}