release_kit/setup/
branch_reminder.rs1use std::path::PathBuf;
13
14use camino::Utf8Path;
15
16pub const MARKER: &str = "# release-kit branch reminder";
19
20pub const HOOK_BODY: &str = "#!/bin/sh
23# release-kit branch reminder
24# Installed by rk setup step branch-reminder; rerunning that step rewrites it.
25# After a merge arrives, report local branches the forge already merged and
26# deleted. Prints nothing when there are none, and never blocks a pull.
27if command -v rk >/dev/null 2>&1; then
28 rk branches prune --quiet || :
29fi
30exit 0
31";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum HookState {
36 Installed,
38 Drifted,
40 Foreign,
42 Absent,
44 Unreadable(String),
46}
47
48pub fn hook_path(target: &Utf8Path) -> Result<PathBuf, String> {
56 let answered = std::process::Command::new("git")
57 .arg("-C")
58 .arg(target.as_std_path())
59 .args(["rev-parse", "--git-path", "hooks"])
60 .output()
61 .map_err(|source| format!("git did not run: {source}"))?;
62 if !answered.status.success() {
63 return Err(format!("{target} is not a git repository"));
64 }
65 let hooks = String::from_utf8_lossy(&answered.stdout).trim().to_owned();
66 if hooks.is_empty() {
67 return Err("git named no hooks directory".to_owned());
68 }
69 let hooks = PathBuf::from(hooks);
70 let hooks = if hooks.is_absolute() {
71 hooks
72 } else {
73 target.as_std_path().join(hooks)
74 };
75 Ok(hooks.join("post-merge"))
76}
77
78#[must_use]
80pub fn observe_hook(target: &Utf8Path) -> HookState {
81 let path = match hook_path(target) {
82 Ok(path) => path,
83 Err(detail) => return HookState::Unreadable(detail),
84 };
85 match std::fs::symlink_metadata(&path) {
90 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return HookState::Absent,
91 Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
92 Ok(meta) if !meta.is_file() => return HookState::Foreign,
93 Ok(_) => {}
94 }
95 let bytes = match std::fs::read(&path) {
96 Ok(bytes) => bytes,
97 Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
98 };
99 if !String::from_utf8_lossy(&bytes).contains(MARKER) {
100 return HookState::Foreign;
101 }
102 let executable = {
103 #[cfg(unix)]
104 {
105 use std::os::unix::fs::PermissionsExt as _;
106 std::fs::metadata(&path).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0)
107 }
108 #[cfg(not(unix))]
109 {
110 true
111 }
112 };
113 if bytes == HOOK_BODY.as_bytes() && executable {
114 HookState::Installed
115 } else {
116 HookState::Drifted
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 #[test]
125 fn the_hook_body_carries_the_marker_and_never_fails() {
126 assert!(super::HOOK_BODY.starts_with("#!/bin/sh\n"));
127 assert!(super::HOOK_BODY.contains(super::MARKER));
128 assert!(super::HOOK_BODY.contains("rk branches prune --quiet"));
129 assert!(super::HOOK_BODY.ends_with("exit 0\n"));
130 }
131}