Skip to main content

sr_core/
hooks.rs

1//! Git hook management — sync, execution, and validation.
2//!
3//! Keeps `.githooks/` in sync with the `hooks` section of `sr.yaml` and
4//! provides the runtime for executing configured hook entries.
5
6use std::collections::BTreeSet;
7use std::hash::{Hash, Hasher};
8use std::path::Path;
9
10use crate::config::{DEFAULT_CONFIG_FILE, HookEntry, HooksConfig, ReleaseConfig};
11use crate::error::ReleaseError;
12
13/// Marker comment embedded in generated hook scripts to identify sr-managed hooks.
14const GENERATED_MARKER: &str = "# Generated by sr";
15
16/// File storing the hash of the hooks config for staleness detection.
17const HASH_FILE: &str = ".sr-hooks-hash";
18
19/// Sync `.githooks/` with the hooks config. Returns `Ok(true)` if changes were made.
20///
21/// - Writes shim scripts for configured hooks
22/// - Removes sr-managed shims no longer in config
23/// - Backs up conflicting non-sr-managed hooks
24/// - Updates the hash file
25/// - Sets `core.hooksPath`
26pub fn sync_hooks(
27    repo_root: &Path,
28    config: &HooksConfig,
29) -> Result<bool, crate::error::ReleaseError> {
30    let hooks_dir = repo_root.join(".githooks");
31    let hash_path = hooks_dir.join(HASH_FILE);
32    let current_hash = config_hash(config);
33
34    // Fast path: already in sync.
35    if let Ok(stored) = std::fs::read_to_string(&hash_path)
36        && stored.trim() == current_hash
37    {
38        return Ok(false);
39    }
40
41    let configured: BTreeSet<&str> = config
42        .hooks
43        .iter()
44        .filter(|(_, entries)| !entries.is_empty())
45        .map(|(name, _)| name.as_str())
46        .collect();
47
48    if configured.is_empty() {
49        let removed = remove_stale_hooks(&hooks_dir, &configured)?;
50        // Clean up hash file too.
51        let _ = std::fs::remove_file(&hash_path);
52        return Ok(removed);
53    }
54
55    std::fs::create_dir_all(&hooks_dir).map_err(|e| {
56        crate::error::ReleaseError::Config(format!("failed to create .githooks: {e}"))
57    })?;
58
59    let mut changed = false;
60
61    for &hook_name in &configured {
62        let hook_path = hooks_dir.join(hook_name);
63        let expected = shim_script(hook_name);
64
65        match std::fs::read_to_string(&hook_path) {
66            Ok(existing) if existing == expected => {
67                // Already correct.
68            }
69            Ok(existing) if existing.contains(GENERATED_MARKER) => {
70                // Sr-managed but outdated — overwrite.
71                write_shim(&hook_path, &expected)?;
72                changed = true;
73            }
74            Ok(_) => {
75                // Non-sr-managed hook conflicts — back up and replace.
76                let backup = hooks_dir.join(format!("{hook_name}.bak"));
77                std::fs::rename(&hook_path, &backup).map_err(|e| {
78                    crate::error::ReleaseError::Config(format!(
79                        "failed to backup .githooks/{hook_name}: {e}"
80                    ))
81                })?;
82                eprintln!("backed up .githooks/{hook_name} → .githooks/{hook_name}.bak");
83                write_shim(&hook_path, &expected)?;
84                changed = true;
85            }
86            Err(_) => {
87                // Does not exist — create.
88                write_shim(&hook_path, &expected)?;
89                changed = true;
90            }
91        }
92    }
93
94    if remove_stale_hooks(&hooks_dir, &configured)? {
95        changed = true;
96    }
97
98    // Write hash file.
99    std::fs::write(&hash_path, &current_hash).map_err(|e| {
100        crate::error::ReleaseError::Config(format!("failed to write hooks hash: {e}"))
101    })?;
102
103    if changed {
104        set_hooks_path(repo_root);
105    }
106
107    Ok(changed)
108}
109
110/// Check whether hooks need syncing (cheap hash comparison).
111pub fn needs_sync(repo_root: &Path, config: &HooksConfig) -> bool {
112    let hash_path = repo_root.join(".githooks").join(HASH_FILE);
113    match std::fs::read_to_string(&hash_path) {
114        Ok(stored) => stored.trim() != config_hash(config),
115        Err(_) => {
116            // No hash file — need sync if there are hooks configured.
117            !config.hooks.is_empty()
118        }
119    }
120}
121
122/// Compute a deterministic hash of the hooks config.
123fn config_hash(config: &HooksConfig) -> String {
124    let json = serde_json::to_string(&config.hooks).unwrap_or_default();
125    let mut hasher = std::collections::hash_map::DefaultHasher::new();
126    json.hash(&mut hasher);
127    format!("{:016x}", hasher.finish())
128}
129
130/// Generate the canonical shim script for a hook.
131fn shim_script(hook_name: &str) -> String {
132    format!(
133        "#!/usr/bin/env sh\n\
134         {GENERATED_MARKER} — edit the hooks section in {config} to modify.\n\
135         exec sr hook run {hook_name} -- \"$@\"\n",
136        config = DEFAULT_CONFIG_FILE,
137    )
138}
139
140/// Write a shim script and set it executable.
141fn write_shim(path: &Path, content: &str) -> Result<(), crate::error::ReleaseError> {
142    std::fs::write(path, content)
143        .map_err(|e| crate::error::ReleaseError::Config(format!("failed to write hook: {e}")))?;
144
145    #[cfg(unix)]
146    {
147        use std::os::unix::fs::PermissionsExt;
148        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
149            crate::error::ReleaseError::Config(format!("failed to chmod hook: {e}"))
150        })?;
151    }
152
153    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
154        eprintln!("synced .githooks/{name}");
155    }
156
157    Ok(())
158}
159
160/// Remove sr-managed hooks not in the configured set. Returns `true` if any were removed.
161fn remove_stale_hooks(
162    hooks_dir: &Path,
163    configured: &BTreeSet<&str>,
164) -> Result<bool, crate::error::ReleaseError> {
165    if !hooks_dir.is_dir() {
166        return Ok(false);
167    }
168
169    let mut removed = false;
170    let entries = std::fs::read_dir(hooks_dir).map_err(|e| {
171        crate::error::ReleaseError::Config(format!("failed to read .githooks: {e}"))
172    })?;
173
174    for entry in entries {
175        let entry = entry.map_err(|e| crate::error::ReleaseError::Config(e.to_string()))?;
176        let path = entry.path();
177
178        if !path.is_file() {
179            continue;
180        }
181
182        let name = match path.file_name().and_then(|n| n.to_str()) {
183            Some(n) => n.to_string(),
184            None => continue,
185        };
186
187        // Skip the hash file and backup files.
188        if name == HASH_FILE || name.ends_with(".bak") {
189            continue;
190        }
191
192        // Only remove sr-managed hooks.
193        if !is_sr_managed(&path) {
194            continue;
195        }
196
197        if !configured.contains(name.as_str()) {
198            std::fs::remove_file(&path).map_err(|e| {
199                crate::error::ReleaseError::Config(format!(
200                    "failed to remove .githooks/{name}: {e}"
201                ))
202            })?;
203            eprintln!("removed stale .githooks/{name}");
204            removed = true;
205        }
206    }
207
208    Ok(removed)
209}
210
211/// Check if a hook file was generated by sr.
212fn is_sr_managed(path: &Path) -> bool {
213    std::fs::read_to_string(path)
214        .map(|content| content.contains(GENERATED_MARKER))
215        .unwrap_or(false)
216}
217
218/// Set `core.hooksPath` to `.githooks/`.
219fn set_hooks_path(repo_root: &Path) {
220    let _ = std::process::Command::new("git")
221        .args(["config", "core.hooksPath", ".githooks/"])
222        .current_dir(repo_root)
223        .status();
224}
225
226// ---------------------------------------------------------------------------
227// Shell execution
228// ---------------------------------------------------------------------------
229
230/// Run a shell command (`sh -c`), optionally piping data to stdin and/or
231/// injecting environment variables. Returns an error if the command exits
232/// non-zero.
233pub fn run_shell(
234    cmd: &str,
235    stdin_data: Option<&str>,
236    env: &[(&str, &str)],
237) -> Result<(), ReleaseError> {
238    let mut child = {
239        let mut builder = std::process::Command::new("sh");
240        builder.args(["-c", cmd]);
241        for &(k, v) in env {
242            builder.env(k, v);
243        }
244        if stdin_data.is_some() {
245            builder.stdin(std::process::Stdio::piped());
246        } else {
247            builder.stdin(std::process::Stdio::inherit());
248        }
249        builder
250            .spawn()
251            .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?
252    };
253
254    if let Some(data) = stdin_data
255        && let Some(ref mut stdin) = child.stdin
256    {
257        use std::io::Write;
258        let _ = stdin.write_all(data.as_bytes());
259    }
260
261    let status = child
262        .wait()
263        .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?;
264
265    if !status.success() {
266        let code = status.code().unwrap_or(1);
267        return Err(ReleaseError::Hook(format!("{cmd} exited with code {code}")));
268    }
269
270    Ok(())
271}
272
273// ---------------------------------------------------------------------------
274// Hook execution
275// ---------------------------------------------------------------------------
276
277/// Build a JSON context object for a git hook based on its name and positional args.
278pub fn build_hook_json(hook_name: &str, args: &[String]) -> serde_json::Value {
279    let mut obj = serde_json::Map::new();
280    obj.insert("hook".into(), serde_json::Value::String(hook_name.into()));
281    obj.insert(
282        "args".into(),
283        serde_json::Value::Array(
284            args.iter()
285                .map(|a| serde_json::Value::String(a.clone()))
286                .collect(),
287        ),
288    );
289
290    // Add named fields for well-known hooks
291    match hook_name {
292        "commit-msg" => {
293            if let Some(f) = args.first() {
294                obj.insert("message_file".into(), serde_json::Value::String(f.clone()));
295            }
296        }
297        "prepare-commit-msg" => {
298            if let Some(f) = args.first() {
299                obj.insert("message_file".into(), serde_json::Value::String(f.clone()));
300            }
301            if let Some(s) = args.get(1) {
302                obj.insert("source".into(), serde_json::Value::String(s.clone()));
303            }
304            if let Some(s) = args.get(2) {
305                obj.insert("sha".into(), serde_json::Value::String(s.clone()));
306            }
307        }
308        "pre-push" => {
309            if let Some(r) = args.first() {
310                obj.insert("remote_name".into(), serde_json::Value::String(r.clone()));
311            }
312            if let Some(u) = args.get(1) {
313                obj.insert("remote_url".into(), serde_json::Value::String(u.clone()));
314            }
315        }
316        "pre-rebase" => {
317            if let Some(u) = args.first() {
318                obj.insert("upstream".into(), serde_json::Value::String(u.clone()));
319            }
320            if let Some(b) = args.get(1) {
321                obj.insert("branch".into(), serde_json::Value::String(b.clone()));
322            }
323        }
324        "post-checkout" => {
325            if let Some(r) = args.first() {
326                obj.insert("prev_ref".into(), serde_json::Value::String(r.clone()));
327            }
328            if let Some(r) = args.get(1) {
329                obj.insert("new_ref".into(), serde_json::Value::String(r.clone()));
330            }
331            if let Some(f) = args.get(2) {
332                obj.insert(
333                    "branch_checkout".into(),
334                    serde_json::Value::String(f.clone()),
335                );
336            }
337        }
338        "post-merge" => {
339            if let Some(s) = args.first() {
340                obj.insert("squash".into(), serde_json::Value::String(s.clone()));
341            }
342        }
343        _ => {}
344    }
345
346    serde_json::Value::Object(obj)
347}
348
349/// Get staged files from git (excluding deletes).
350fn staged_files() -> Result<Vec<String>, ReleaseError> {
351    let output = std::process::Command::new("git")
352        .args(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])
353        .output()
354        .map_err(|e| ReleaseError::Hook(format!("git diff --cached: {e}")))?;
355    let stdout = String::from_utf8_lossy(&output.stdout);
356    Ok(stdout
357        .lines()
358        .filter(|l| !l.is_empty())
359        .map(|l| l.to_string())
360        .collect())
361}
362
363/// Match files against glob patterns. Matches against both the full path and the basename.
364fn match_files(files: &[String], patterns: &[String]) -> Vec<String> {
365    let compiled: Vec<glob::Pattern> = patterns
366        .iter()
367        .filter_map(|p| glob::Pattern::new(p).ok())
368        .collect();
369
370    files
371        .iter()
372        .filter(|f| {
373            let basename = Path::new(f)
374                .file_name()
375                .and_then(|n| n.to_str())
376                .unwrap_or(f);
377            compiled
378                .iter()
379                .any(|pat| pat.matches(f) || pat.matches(basename))
380        })
381        .cloned()
382        .collect()
383}
384
385/// Run all entries for a configured git hook.
386///
387/// Simple entries run as shell commands with JSON context piped to stdin.
388/// Step entries match staged files against patterns and run rules only when matches exist.
389/// Rules containing `{files}` receive the matched file list.
390pub fn run_hook(
391    config: &ReleaseConfig,
392    hook_name: &str,
393    args: &[String],
394) -> Result<(), ReleaseError> {
395    let entries = config
396        .hooks
397        .hooks
398        .get(hook_name)
399        .ok_or_else(|| ReleaseError::Hook(format!("no hook configured for '{hook_name}'")))?;
400
401    if entries.is_empty() {
402        return Ok(());
403    }
404
405    let json = build_hook_json(hook_name, args);
406    let json_str = serde_json::to_string(&json)
407        .map_err(|e| ReleaseError::Hook(format!("failed to serialize hook context: {e}")))?;
408
409    // Lazily fetch staged files only when a Step entry needs them.
410    let mut cached_staged: Option<Vec<String>> = None;
411
412    for entry in entries {
413        match entry {
414            HookEntry::Simple(cmd) => {
415                run_shell(cmd, Some(&json_str), &[])?;
416            }
417            HookEntry::Step {
418                step,
419                patterns,
420                rules,
421            } => {
422                let all_staged = match &mut cached_staged {
423                    Some(files) => files,
424                    None => {
425                        cached_staged = Some(staged_files().unwrap_or_default());
426                        cached_staged.as_mut().unwrap()
427                    }
428                };
429
430                if all_staged.is_empty() {
431                    eprintln!("{hook_name}: no staged files, skipping steps.");
432                    break;
433                }
434
435                let matched = match_files(all_staged, patterns);
436                if matched.is_empty() {
437                    eprintln!("{hook_name} [{step}]: no files match {patterns:?}, skipping.");
438                    continue;
439                }
440
441                let files_str = matched.join(" ");
442
443                for rule in rules {
444                    let cmd = if rule.contains("{files}") {
445                        rule.replace("{files}", &files_str)
446                    } else {
447                        rule.clone()
448                    };
449
450                    eprintln!("{hook_name} [{step}]: {cmd}");
451                    run_shell(&cmd, None, &[])?;
452                }
453            }
454        }
455    }
456
457    Ok(())
458}
459
460/// Validate a commit message file against the configured conventional commit pattern and types.
461/// Reads hook JSON from stdin to get the message_file path.
462pub fn validate_commit_msg(config: &ReleaseConfig) -> Result<(), ReleaseError> {
463    use std::io::Read;
464    let mut input = String::new();
465    std::io::stdin()
466        .read_to_string(&mut input)
467        .map_err(|e| ReleaseError::Hook(format!("failed to read stdin: {e}")))?;
468
469    let json: serde_json::Value = serde_json::from_str(&input)
470        .map_err(|e| ReleaseError::Hook(format!("invalid JSON on stdin: {e}")))?;
471
472    let file = json["message_file"]
473        .as_str()
474        .ok_or_else(|| ReleaseError::Hook("missing 'message_file' in hook JSON".into()))?;
475
476    let content = std::fs::read_to_string(file)
477        .map_err(|e| ReleaseError::Hook(format!("cannot read commit message file: {e}")))?;
478
479    let first_line = content.lines().next().unwrap_or("").trim();
480
481    // Allow merge commits
482    if first_line.starts_with("Merge ") {
483        return Ok(());
484    }
485
486    // Allow fixup/squash/amend commits (from rebase -i)
487    if first_line.starts_with("fixup! ")
488        || first_line.starts_with("squash! ")
489        || first_line.starts_with("amend! ")
490    {
491        return Ok(());
492    }
493
494    let re = regex::Regex::new(&config.commit_pattern)
495        .map_err(|e| ReleaseError::Hook(format!("invalid commit_pattern: {e}")))?;
496
497    if !re.is_match(first_line) {
498        let type_names: Vec<&str> = config.types.iter().map(|t| t.name.as_str()).collect();
499        return Err(ReleaseError::Hook(format!(
500            "commit message does not follow Conventional Commits.\n\n\
501             \x20 Expected: <type>(<scope>): <description>\n\
502             \x20 Got:      {first_line}\n\n\
503             \x20 Valid types: {}\n\
504             \x20 Breaking:    append '!' before the colon, e.g. feat!: ...\n\n\
505             \x20 Examples:\n\
506             \x20   feat: add release dry-run flag\n\
507             \x20   fix(core): handle empty tag list\n\
508             \x20   feat!: redesign config format",
509            type_names.join(", "),
510        )));
511    }
512
513    // Extract and validate the type
514    if let Some(caps) = re.captures(first_line) {
515        let msg_type = caps.name("type").map(|m| m.as_str()).unwrap_or_default();
516
517        if !config.types.iter().any(|t| t.name == msg_type) {
518            let type_names: Vec<&str> = config.types.iter().map(|t| t.name.as_str()).collect();
519            return Err(ReleaseError::Hook(format!(
520                "commit type '{msg_type}' is not allowed.\n\n\
521                 \x20 Valid types: {}",
522                type_names.join(", "),
523            )));
524        }
525    }
526
527    Ok(())
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::config::HookEntry;
534    use std::collections::BTreeMap;
535
536    fn make_config(hooks: &[(&str, Vec<HookEntry>)]) -> HooksConfig {
537        let mut map = BTreeMap::new();
538        for (name, entries) in hooks {
539            map.insert(name.to_string(), entries.clone());
540        }
541        HooksConfig { hooks: map }
542    }
543
544    #[test]
545    fn creates_hook_scripts() {
546        let dir = tempfile::tempdir().unwrap();
547        let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
548
549        let changed = sync_hooks(dir.path(), &config).unwrap();
550        assert!(changed);
551
552        let hook = dir.path().join(".githooks/pre-commit");
553        assert!(hook.exists());
554        let content = std::fs::read_to_string(&hook).unwrap();
555        assert!(content.contains("sr hook run pre-commit"));
556        assert!(content.contains(GENERATED_MARKER));
557    }
558
559    #[test]
560    fn idempotent_returns_false() {
561        let dir = tempfile::tempdir().unwrap();
562        let config = make_config(&[(
563            "commit-msg",
564            vec![HookEntry::Simple("sr hook commit-msg".into())],
565        )]);
566
567        assert!(sync_hooks(dir.path(), &config).unwrap());
568        // Second call — hash matches, no changes.
569        assert!(!sync_hooks(dir.path(), &config).unwrap());
570    }
571
572    #[test]
573    fn removes_stale_hooks() {
574        let dir = tempfile::tempdir().unwrap();
575        let hooks_dir = dir.path().join(".githooks");
576        std::fs::create_dir_all(&hooks_dir).unwrap();
577
578        // Write a sr-managed hook that won't be in config.
579        std::fs::write(
580            hooks_dir.join("pre-push"),
581            format!("{GENERATED_MARKER}\nold script"),
582        )
583        .unwrap();
584
585        // Write a non-sr-managed hook that should be left alone.
586        std::fs::write(hooks_dir.join("post-checkout"), "#!/bin/sh\necho custom").unwrap();
587
588        let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
589
590        sync_hooks(dir.path(), &config).unwrap();
591
592        assert!(
593            !hooks_dir.join("pre-push").exists(),
594            "stale sr-managed hook should be removed"
595        );
596        assert!(
597            hooks_dir.join("post-checkout").exists(),
598            "non-sr-managed hook should be preserved"
599        );
600        assert!(hooks_dir.join("pre-commit").exists());
601    }
602
603    #[test]
604    fn backs_up_conflicting_hooks() {
605        let dir = tempfile::tempdir().unwrap();
606        let hooks_dir = dir.path().join(".githooks");
607        std::fs::create_dir_all(&hooks_dir).unwrap();
608
609        // Write a non-sr-managed hook with the same name as a configured hook.
610        let custom_content = "#!/bin/sh\necho custom commit-msg hook";
611        std::fs::write(hooks_dir.join("commit-msg"), custom_content).unwrap();
612
613        let config = make_config(&[(
614            "commit-msg",
615            vec![HookEntry::Simple("sr hook commit-msg".into())],
616        )]);
617
618        sync_hooks(dir.path(), &config).unwrap();
619
620        // Original should be backed up.
621        let backup = hooks_dir.join("commit-msg.bak");
622        assert!(backup.exists());
623        assert_eq!(std::fs::read_to_string(&backup).unwrap(), custom_content);
624
625        // New hook should be the shim.
626        let content = std::fs::read_to_string(hooks_dir.join("commit-msg")).unwrap();
627        assert!(content.contains("sr hook run commit-msg"));
628    }
629
630    #[test]
631    fn empty_config_cleans_up() {
632        let dir = tempfile::tempdir().unwrap();
633        let hooks_dir = dir.path().join(".githooks");
634        std::fs::create_dir_all(&hooks_dir).unwrap();
635
636        std::fs::write(
637            hooks_dir.join("pre-commit"),
638            format!("{GENERATED_MARKER}\nscript"),
639        )
640        .unwrap();
641        std::fs::write(hooks_dir.join(".sr-hooks-hash"), "oldhash").unwrap();
642
643        let config = make_config(&[]);
644        sync_hooks(dir.path(), &config).unwrap();
645
646        assert!(!hooks_dir.join("pre-commit").exists());
647        assert!(!hooks_dir.join(".sr-hooks-hash").exists());
648    }
649
650    #[test]
651    fn needs_sync_detects_changes() {
652        let dir = tempfile::tempdir().unwrap();
653        let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
654
655        assert!(needs_sync(dir.path(), &config));
656
657        sync_hooks(dir.path(), &config).unwrap();
658        assert!(!needs_sync(dir.path(), &config));
659
660        // Change config.
661        let config2 =
662            make_config(&[("pre-commit", vec![HookEntry::Simple("echo changed".into())])]);
663        assert!(needs_sync(dir.path(), &config2));
664    }
665}