Skip to main content

release_kit/setup/
branch_reminder.rs

1//! The post-merge reminder hook `rk setup step branch-reminder` writes.
2//!
3//! No git event fires when the forge squash-merges and deletes a branch;
4//! the nearest local event is the pull that fetches the result, which is a
5//! merge, so `post-merge` fires with the `[gone]` marker freshly true.
6//! The hook only reminds — the quiet prunes print nothing when the clone
7//! is clean and never delete — and it never blocks a pull. Each call is
8//! guarded by a capability probe on its own verb (`rk <verb> --help`),
9//! not by `command -v rk`: the probe answers the question the hook
10//! actually has — can this `rk` prune this resource? — so a missing
11//! binary, one too old for the verb, and one that renamed it all fail
12//! identically and print nothing, while the real invocations keep their
13//! stderr so a genuine refusal still reaches the operator. The body is
14//! authored as `blocks/post-merge-hook.sh` and embedded verbatim: it
15//! belongs to no forge, so it lives with the other host-written texts
16//! rather than in a `setup/<forge>/` tree, per
17//! `ADR-author-every-host-written-text-as-payload`.
18
19use std::path::PathBuf;
20
21use camino::Utf8Path;
22
23/// The marker line a reminder hook carries; its absence makes a hook
24/// foreign, and a foreign hook is never written over.
25pub const MARKER: &str = "# release-kit branch reminder";
26
27/// The authored hook body, `blocks/post-merge-hook.sh`, embedded whole.
28static BODY: &str = include_str!("../../blocks/post-merge-hook.sh");
29
30/// The whole hook, byte for byte: `blocks/post-merge-hook.sh` verbatim,
31/// final newline included.
32///
33/// No `set -eu` on purpose: the contract
34/// is exit 0 always, and the `|| :` plus the final line hold it. The
35/// probes are per verb, because during a transition a binary exists that
36/// carries one prune verb and not the other; one probe for both would
37/// silence the half that works or admit the half that does not.
38#[must_use]
39pub fn hook_body() -> &'static [u8] {
40    BODY.as_bytes()
41}
42
43/// What the hook file holds today.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum HookState {
46    /// The file is this binary's body, executable.
47    Installed,
48    /// The marker is present but the body or the mode drifted.
49    Drifted,
50    /// A post-merge hook exists without the marker; it is someone else's.
51    Foreign,
52    /// No post-merge hook exists.
53    Absent,
54    /// The hooks directory or the file could not be read.
55    Unreadable(String),
56}
57
58/// Where git will look for the post-merge hook: `rev-parse --git-path`
59/// answers through gitfiles, linked worktrees, and `core.hooksPath`, and
60/// a relative answer is relative to the target it ran in.
61///
62/// # Errors
63///
64/// The detail of a git that did not run or did not answer.
65pub fn hook_path(target: &Utf8Path) -> Result<PathBuf, String> {
66    let mut command = std::process::Command::new("git");
67    for var in crate::maintenance::GIT_HOOK_VARS {
68        command.env_remove(var);
69    }
70    let answered = command
71        .arg("-C")
72        .arg(target.as_std_path())
73        .args(["rev-parse", "--git-path", "hooks"])
74        .output()
75        .map_err(|source| format!("git did not run: {source}"))?;
76    if !answered.status.success() {
77        return Err(format!("{target} is not a git repository"));
78    }
79    let hooks = String::from_utf8_lossy(&answered.stdout).trim().to_owned();
80    if hooks.is_empty() {
81        return Err("git named no hooks directory".to_owned());
82    }
83    let hooks = PathBuf::from(hooks);
84    let hooks = if hooks.is_absolute() {
85        hooks
86    } else {
87        target.as_std_path().join(hooks)
88    };
89    Ok(hooks.join("post-merge"))
90}
91
92/// Read the hook file and judge it against this binary's body.
93#[must_use]
94pub fn observe_hook(target: &Utf8Path) -> HookState {
95    let path = match hook_path(target) {
96        Ok(path) => path,
97        Err(detail) => return HookState::Unreadable(detail),
98    };
99    // Judge the entry itself, not what it points at: a symlink - dangling
100    // or not - is another manager's installation style, and a read
101    // through it would misclassify the dangling case as absent and let
102    // the atomic writer's rename replace the link.
103    match std::fs::symlink_metadata(&path) {
104        Err(source) if source.kind() == std::io::ErrorKind::NotFound => return HookState::Absent,
105        Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
106        Ok(meta) if !meta.is_file() => return HookState::Foreign,
107        Ok(_) => {}
108    }
109    let bytes = match std::fs::read(&path) {
110        Ok(bytes) => bytes,
111        Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
112    };
113    if !String::from_utf8_lossy(&bytes).contains(MARKER) {
114        return HookState::Foreign;
115    }
116    let executable = {
117        #[cfg(unix)]
118        {
119            use std::os::unix::fs::PermissionsExt as _;
120            std::fs::metadata(&path).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0)
121        }
122        #[cfg(not(unix))]
123        {
124            true
125        }
126    };
127    if bytes == hook_body() && executable {
128        HookState::Installed
129    } else {
130        HookState::Drifted
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    #![allow(clippy::expect_used)]
137
138    /// The body opens with a shebang, carries the marker, probes each
139    /// verb separately before its quiet prune, and ends by succeeding
140    /// whatever happened above.
141    /// The body is `blocks/post-merge-hook.sh` byte for byte — no strip,
142    /// no render — so what the step writes is exactly what is authored.
143    #[test]
144    fn the_hook_body_is_the_authored_file_verbatim() {
145        let disk = std::fs::read(
146            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("blocks/post-merge-hook.sh"),
147        )
148        .expect("the authored hook body exists");
149        assert_eq!(super::hook_body(), disk.as_slice());
150    }
151
152    #[test]
153    fn the_hook_body_carries_the_marker_and_never_fails() {
154        let body = std::str::from_utf8(super::hook_body()).expect("the hook body is UTF-8");
155        assert!(body.starts_with("#!/bin/sh\n"));
156        assert!(body.contains(super::MARKER));
157        for verb in ["branches", "worktree"] {
158            assert!(
159                body.contains(&format!("if rk {verb} prune --help >/dev/null 2>&1; then")),
160                "the {verb} call is guarded by its own capability probe"
161            );
162            assert!(
163                body.contains(&format!("rk {verb} prune --quiet || :")),
164                "the {verb} prune runs quiet and never fails the pull"
165            );
166        }
167        assert!(
168            !body.contains("command -v"),
169            "a presence check answers the wrong question; the probe is per verb"
170        );
171        assert!(body.ends_with("exit 0\n"));
172    }
173}