Skip to main content

sr_core/
hooks.rs

1//! Automatic git hook management.
2//!
3//! Keeps `.githooks/` in sync with the `hooks` section of `sr.yaml`.
4//! Hook scripts are thin shims that delegate to `sr hook run <name>`.
5
6use std::collections::BTreeSet;
7use std::hash::{Hash, Hasher};
8use std::path::Path;
9
10use crate::config::{DEFAULT_CONFIG_FILE, HooksConfig};
11
12/// Marker comment embedded in generated hook scripts to identify sr-managed hooks.
13const GENERATED_MARKER: &str = "# Generated by sr";
14
15/// File storing the hash of the hooks config for staleness detection.
16const HASH_FILE: &str = ".sr-hooks-hash";
17
18/// Sync `.githooks/` with the hooks config. Returns `Ok(true)` if changes were made.
19///
20/// - Writes shim scripts for configured hooks
21/// - Removes sr-managed shims no longer in config
22/// - Backs up conflicting non-sr-managed hooks
23/// - Updates the hash file
24/// - Sets `core.hooksPath`
25pub fn sync_hooks(
26    repo_root: &Path,
27    config: &HooksConfig,
28) -> Result<bool, crate::error::ReleaseError> {
29    let hooks_dir = repo_root.join(".githooks");
30    let hash_path = hooks_dir.join(HASH_FILE);
31    let current_hash = config_hash(config);
32
33    // Fast path: already in sync.
34    if let Ok(stored) = std::fs::read_to_string(&hash_path)
35        && stored.trim() == current_hash
36    {
37        return Ok(false);
38    }
39
40    let configured: BTreeSet<&str> = config
41        .hooks
42        .iter()
43        .filter(|(_, entries)| !entries.is_empty())
44        .map(|(name, _)| name.as_str())
45        .collect();
46
47    if configured.is_empty() {
48        let removed = remove_stale_hooks(&hooks_dir, &configured)?;
49        // Clean up hash file too.
50        let _ = std::fs::remove_file(&hash_path);
51        return Ok(removed);
52    }
53
54    std::fs::create_dir_all(&hooks_dir).map_err(|e| {
55        crate::error::ReleaseError::Config(format!("failed to create .githooks: {e}"))
56    })?;
57
58    let mut changed = false;
59
60    for &hook_name in &configured {
61        let hook_path = hooks_dir.join(hook_name);
62        let expected = shim_script(hook_name);
63
64        match std::fs::read_to_string(&hook_path) {
65            Ok(existing) if existing == expected => {
66                // Already correct.
67            }
68            Ok(existing) if existing.contains(GENERATED_MARKER) => {
69                // Sr-managed but outdated — overwrite.
70                write_shim(&hook_path, &expected)?;
71                changed = true;
72            }
73            Ok(_) => {
74                // Non-sr-managed hook conflicts — back up and replace.
75                let backup = hooks_dir.join(format!("{hook_name}.bak"));
76                std::fs::rename(&hook_path, &backup).map_err(|e| {
77                    crate::error::ReleaseError::Config(format!(
78                        "failed to backup .githooks/{hook_name}: {e}"
79                    ))
80                })?;
81                eprintln!("backed up .githooks/{hook_name} → .githooks/{hook_name}.bak");
82                write_shim(&hook_path, &expected)?;
83                changed = true;
84            }
85            Err(_) => {
86                // Does not exist — create.
87                write_shim(&hook_path, &expected)?;
88                changed = true;
89            }
90        }
91    }
92
93    if remove_stale_hooks(&hooks_dir, &configured)? {
94        changed = true;
95    }
96
97    // Write hash file.
98    std::fs::write(&hash_path, &current_hash).map_err(|e| {
99        crate::error::ReleaseError::Config(format!("failed to write hooks hash: {e}"))
100    })?;
101
102    if changed {
103        set_hooks_path(repo_root);
104    }
105
106    Ok(changed)
107}
108
109/// Check whether hooks need syncing (cheap hash comparison).
110pub fn needs_sync(repo_root: &Path, config: &HooksConfig) -> bool {
111    let hash_path = repo_root.join(".githooks").join(HASH_FILE);
112    match std::fs::read_to_string(&hash_path) {
113        Ok(stored) => stored.trim() != config_hash(config),
114        Err(_) => {
115            // No hash file — need sync if there are hooks configured.
116            !config.hooks.is_empty()
117        }
118    }
119}
120
121/// Compute a deterministic hash of the hooks config.
122fn config_hash(config: &HooksConfig) -> String {
123    let json = serde_json::to_string(&config.hooks).unwrap_or_default();
124    let mut hasher = std::collections::hash_map::DefaultHasher::new();
125    json.hash(&mut hasher);
126    format!("{:016x}", hasher.finish())
127}
128
129/// Generate the canonical shim script for a hook.
130fn shim_script(hook_name: &str) -> String {
131    format!(
132        "#!/usr/bin/env sh\n\
133         {GENERATED_MARKER} — edit the hooks section in {config} to modify.\n\
134         exec sr hook run {hook_name} -- \"$@\"\n",
135        config = DEFAULT_CONFIG_FILE,
136    )
137}
138
139/// Write a shim script and set it executable.
140fn write_shim(path: &Path, content: &str) -> Result<(), crate::error::ReleaseError> {
141    std::fs::write(path, content)
142        .map_err(|e| crate::error::ReleaseError::Config(format!("failed to write hook: {e}")))?;
143
144    #[cfg(unix)]
145    {
146        use std::os::unix::fs::PermissionsExt;
147        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
148            crate::error::ReleaseError::Config(format!("failed to chmod hook: {e}"))
149        })?;
150    }
151
152    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
153        eprintln!("synced .githooks/{name}");
154    }
155
156    Ok(())
157}
158
159/// Remove sr-managed hooks not in the configured set. Returns `true` if any were removed.
160fn remove_stale_hooks(
161    hooks_dir: &Path,
162    configured: &BTreeSet<&str>,
163) -> Result<bool, crate::error::ReleaseError> {
164    if !hooks_dir.is_dir() {
165        return Ok(false);
166    }
167
168    let mut removed = false;
169    let entries = std::fs::read_dir(hooks_dir).map_err(|e| {
170        crate::error::ReleaseError::Config(format!("failed to read .githooks: {e}"))
171    })?;
172
173    for entry in entries {
174        let entry = entry.map_err(|e| crate::error::ReleaseError::Config(e.to_string()))?;
175        let path = entry.path();
176
177        if !path.is_file() {
178            continue;
179        }
180
181        let name = match path.file_name().and_then(|n| n.to_str()) {
182            Some(n) => n.to_string(),
183            None => continue,
184        };
185
186        // Skip the hash file and backup files.
187        if name == HASH_FILE || name.ends_with(".bak") {
188            continue;
189        }
190
191        // Only remove sr-managed hooks.
192        if !is_sr_managed(&path) {
193            continue;
194        }
195
196        if !configured.contains(name.as_str()) {
197            std::fs::remove_file(&path).map_err(|e| {
198                crate::error::ReleaseError::Config(format!(
199                    "failed to remove .githooks/{name}: {e}"
200                ))
201            })?;
202            eprintln!("removed stale .githooks/{name}");
203            removed = true;
204        }
205    }
206
207    Ok(removed)
208}
209
210/// Check if a hook file was generated by sr.
211fn is_sr_managed(path: &Path) -> bool {
212    std::fs::read_to_string(path)
213        .map(|content| content.contains(GENERATED_MARKER))
214        .unwrap_or(false)
215}
216
217/// Set `core.hooksPath` to `.githooks/`.
218fn set_hooks_path(repo_root: &Path) {
219    let _ = std::process::Command::new("git")
220        .args(["config", "core.hooksPath", ".githooks/"])
221        .current_dir(repo_root)
222        .status();
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::config::HookEntry;
229    use std::collections::BTreeMap;
230
231    fn make_config(hooks: &[(&str, Vec<HookEntry>)]) -> HooksConfig {
232        let mut map = BTreeMap::new();
233        for (name, entries) in hooks {
234            map.insert(name.to_string(), entries.clone());
235        }
236        HooksConfig { hooks: map }
237    }
238
239    #[test]
240    fn creates_hook_scripts() {
241        let dir = tempfile::tempdir().unwrap();
242        let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
243
244        let changed = sync_hooks(dir.path(), &config).unwrap();
245        assert!(changed);
246
247        let hook = dir.path().join(".githooks/pre-commit");
248        assert!(hook.exists());
249        let content = std::fs::read_to_string(&hook).unwrap();
250        assert!(content.contains("sr hook run pre-commit"));
251        assert!(content.contains(GENERATED_MARKER));
252    }
253
254    #[test]
255    fn idempotent_returns_false() {
256        let dir = tempfile::tempdir().unwrap();
257        let config = make_config(&[(
258            "commit-msg",
259            vec![HookEntry::Simple("sr hook commit-msg".into())],
260        )]);
261
262        assert!(sync_hooks(dir.path(), &config).unwrap());
263        // Second call — hash matches, no changes.
264        assert!(!sync_hooks(dir.path(), &config).unwrap());
265    }
266
267    #[test]
268    fn removes_stale_hooks() {
269        let dir = tempfile::tempdir().unwrap();
270        let hooks_dir = dir.path().join(".githooks");
271        std::fs::create_dir_all(&hooks_dir).unwrap();
272
273        // Write a sr-managed hook that won't be in config.
274        std::fs::write(
275            hooks_dir.join("pre-push"),
276            format!("{GENERATED_MARKER}\nold script"),
277        )
278        .unwrap();
279
280        // Write a non-sr-managed hook that should be left alone.
281        std::fs::write(hooks_dir.join("post-checkout"), "#!/bin/sh\necho custom").unwrap();
282
283        let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
284
285        sync_hooks(dir.path(), &config).unwrap();
286
287        assert!(
288            !hooks_dir.join("pre-push").exists(),
289            "stale sr-managed hook should be removed"
290        );
291        assert!(
292            hooks_dir.join("post-checkout").exists(),
293            "non-sr-managed hook should be preserved"
294        );
295        assert!(hooks_dir.join("pre-commit").exists());
296    }
297
298    #[test]
299    fn backs_up_conflicting_hooks() {
300        let dir = tempfile::tempdir().unwrap();
301        let hooks_dir = dir.path().join(".githooks");
302        std::fs::create_dir_all(&hooks_dir).unwrap();
303
304        // Write a non-sr-managed hook with the same name as a configured hook.
305        let custom_content = "#!/bin/sh\necho custom commit-msg hook";
306        std::fs::write(hooks_dir.join("commit-msg"), custom_content).unwrap();
307
308        let config = make_config(&[(
309            "commit-msg",
310            vec![HookEntry::Simple("sr hook commit-msg".into())],
311        )]);
312
313        sync_hooks(dir.path(), &config).unwrap();
314
315        // Original should be backed up.
316        let backup = hooks_dir.join("commit-msg.bak");
317        assert!(backup.exists());
318        assert_eq!(std::fs::read_to_string(&backup).unwrap(), custom_content);
319
320        // New hook should be the shim.
321        let content = std::fs::read_to_string(hooks_dir.join("commit-msg")).unwrap();
322        assert!(content.contains("sr hook run commit-msg"));
323    }
324
325    #[test]
326    fn empty_config_cleans_up() {
327        let dir = tempfile::tempdir().unwrap();
328        let hooks_dir = dir.path().join(".githooks");
329        std::fs::create_dir_all(&hooks_dir).unwrap();
330
331        std::fs::write(
332            hooks_dir.join("pre-commit"),
333            format!("{GENERATED_MARKER}\nscript"),
334        )
335        .unwrap();
336        std::fs::write(hooks_dir.join(".sr-hooks-hash"), "oldhash").unwrap();
337
338        let config = make_config(&[]);
339        sync_hooks(dir.path(), &config).unwrap();
340
341        assert!(!hooks_dir.join("pre-commit").exists());
342        assert!(!hooks_dir.join(".sr-hooks-hash").exists());
343    }
344
345    #[test]
346    fn needs_sync_detects_changes() {
347        let dir = tempfile::tempdir().unwrap();
348        let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
349
350        assert!(needs_sync(dir.path(), &config));
351
352        sync_hooks(dir.path(), &config).unwrap();
353        assert!(!needs_sync(dir.path(), &config));
354
355        // Change config.
356        let config2 =
357            make_config(&[("pre-commit", vec![HookEntry::Simple("echo changed".into())])]);
358        assert!(needs_sync(dir.path(), &config2));
359    }
360}