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 a
14//! Rust const rather than a `setup/<forge>/` script: it belongs to no
15//! forge, and the forge trees hold one script per forge step by the
16//! parity rule.
17
18use std::path::PathBuf;
19
20use camino::Utf8Path;
21
22/// The marker line a reminder hook carries; its absence makes a hook
23/// foreign, and a foreign hook is never written over.
24pub const MARKER: &str = "# release-kit branch reminder";
25
26/// The whole hook, byte for byte.
27///
28/// No `set -eu` on purpose: the contract
29/// is exit 0 always, and the `|| :` plus the final line hold it. The
30/// probes are per verb, because during a transition a binary exists that
31/// carries one prune verb and not the other; one probe for both would
32/// silence the half that works or admit the half that does not.
33pub const HOOK_BODY: &str = "#!/bin/sh
34# release-kit branch reminder
35# Installed by rk setup step branch-reminder; rerunning that step rewrites it.
36# After a merge arrives, report the branches and worktrees the forge already
37# merged and retired. Prints nothing when there are none, never blocks a pull.
38if rk branches prune --help >/dev/null 2>&1; then
39  rk branches prune --quiet || :
40fi
41if rk worktree prune --help >/dev/null 2>&1; then
42  rk worktree prune --quiet || :
43fi
44exit 0
45";
46
47/// What the hook file holds today.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum HookState {
50    /// The file is this binary's body, executable.
51    Installed,
52    /// The marker is present but the body or the mode drifted.
53    Drifted,
54    /// A post-merge hook exists without the marker; it is someone else's.
55    Foreign,
56    /// No post-merge hook exists.
57    Absent,
58    /// The hooks directory or the file could not be read.
59    Unreadable(String),
60}
61
62/// Where git will look for the post-merge hook: `rev-parse --git-path`
63/// answers through gitfiles, linked worktrees, and `core.hooksPath`, and
64/// a relative answer is relative to the target it ran in.
65///
66/// # Errors
67///
68/// The detail of a git that did not run or did not answer.
69pub fn hook_path(target: &Utf8Path) -> Result<PathBuf, String> {
70    let answered = std::process::Command::new("git")
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.as_bytes() && executable {
128        HookState::Installed
129    } else {
130        HookState::Drifted
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    /// The body opens with a shebang, carries the marker, probes each
137    /// verb separately before its quiet prune, and ends by succeeding
138    /// whatever happened above.
139    #[test]
140    fn the_hook_body_carries_the_marker_and_never_fails() {
141        assert!(super::HOOK_BODY.starts_with("#!/bin/sh\n"));
142        assert!(super::HOOK_BODY.contains(super::MARKER));
143        for verb in ["branches", "worktree"] {
144            assert!(
145                super::HOOK_BODY
146                    .contains(&format!("if rk {verb} prune --help >/dev/null 2>&1; then")),
147                "the {verb} call is guarded by its own capability probe"
148            );
149            assert!(
150                super::HOOK_BODY.contains(&format!("rk {verb} prune --quiet || :")),
151                "the {verb} prune runs quiet and never fails the pull"
152            );
153        }
154        assert!(
155            !super::HOOK_BODY.contains("command -v"),
156            "a presence check answers the wrong question; the probe is per verb"
157        );
158        assert!(super::HOOK_BODY.ends_with("exit 0\n"));
159    }
160}