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