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}