Skip to main content

githops_core/
sync_hooks.rs

1use anyhow::Result;
2use colored::Colorize;
3use std::path::Path;
4
5use crate::config::Config;
6use crate::hooks::ALL_HOOKS;
7
8/// Marker written into every hook script so we can identify githops-managed files.
9pub const GITHOPS_MARKER: &str = "# GITHOPS_MANAGED";
10
11/// Write/remove hook scripts in `hooks_dir` to match `config`.
12///
13/// When `force` is `true`, pre-existing hooks that are not managed by githops
14/// are overwritten instead of skipped.
15///
16/// Returns `(installed_count, skipped_count)`.
17pub fn sync_to_hooks(config: &Config, hooks_dir: &Path, force: bool) -> Result<(usize, usize)> {
18    std::fs::create_dir_all(hooks_dir)?;
19
20    let mut installed = 0usize;
21    let mut skipped = 0usize;
22
23    for hook_info in ALL_HOOKS {
24        let hook_cfg = match config.hooks.get(hook_info.name) {
25            Some(cfg) => cfg,
26            None => continue,
27        };
28
29        let resolved = hook_cfg.resolved_commands(&config.definitions);
30        let active_count = resolved.iter().filter(|c| !c.test).count();
31
32        if !hook_cfg.enabled || active_count == 0 {
33            continue;
34        }
35
36        let hook_path = hooks_dir.join(hook_info.name);
37        let script = build_hook_script(hook_info.name, active_count);
38
39        if hook_path.exists() {
40            let existing = std::fs::read_to_string(&hook_path).unwrap_or_default();
41            if !existing.contains(GITHOPS_MARKER) {
42                if !force {
43                    println!(
44                        "{} {} — not managed by githops, skipping (use {} to overwrite)",
45                        "skip:".yellow().bold(),
46                        hook_info.name,
47                        "githops sync --force".cyan()
48                    );
49                    skipped += 1;
50                    continue;
51                }
52                println!(
53                    "{} {} — overwriting unmanaged hook",
54                    "force:".yellow().bold(),
55                    hook_info.name
56                );
57            }
58        }
59
60        std::fs::write(&hook_path, &script)?;
61        make_executable(&hook_path)?;
62        println!("{} {}", "synced:".green().bold(), hook_info.name);
63        installed += 1;
64    }
65
66    // Remove hooks that were managed by githops but are no longer in config.
67    for hook_info in ALL_HOOKS {
68        let hook_path = hooks_dir.join(hook_info.name);
69        if !hook_path.exists() {
70            continue;
71        }
72        let existing = std::fs::read_to_string(&hook_path).unwrap_or_default();
73        if !existing.contains(GITHOPS_MARKER) {
74            continue;
75        }
76        let configured = config
77            .hooks
78            .get(hook_info.name)
79            .map(|c| {
80                let resolved = c.resolved_commands(&config.definitions);
81                c.enabled && resolved.iter().any(|cmd| !cmd.test)
82            })
83            .unwrap_or(false);
84        if !configured {
85            std::fs::remove_file(&hook_path)?;
86            println!("{} {}", "removed:".dimmed(), hook_info.name);
87        }
88    }
89
90    Ok((installed, skipped))
91}
92
93fn build_hook_script(hook_name: &str, command_count: usize) -> String {
94    format!(
95        r#"#!/bin/sh
96{marker}
97# Hook: {name}
98# Managed by githops — do not edit manually.
99# Run `githops sync` to regenerate.
100# Commands configured: {count}
101
102exec githops check {name} "$@"
103"#,
104        marker = GITHOPS_MARKER,
105        name = hook_name,
106        count = command_count
107    )
108}
109
110#[cfg(unix)]
111fn make_executable(path: &Path) -> Result<()> {
112    use std::os::unix::fs::PermissionsExt;
113    let mut perms = std::fs::metadata(path)?.permissions();
114    perms.set_mode(perms.mode() | 0o111);
115    std::fs::set_permissions(path, perms)?;
116    Ok(())
117}
118
119#[cfg(not(unix))]
120fn make_executable(_path: &Path) -> Result<()> {
121    Ok(())
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::config::{Command, CommandEntry, Config, HookConfig};
128    use std::collections::BTreeMap;
129    use tempfile::TempDir;
130
131    #[test]
132    fn test_build_hook_script_contains_marker() {
133        let script = build_hook_script("pre-commit", 3);
134        assert!(script.contains("GITHOPS_MANAGED"));
135    }
136
137    #[test]
138    fn test_build_hook_script_contains_hook_name() {
139        let script = build_hook_script("commit-msg", 1);
140        assert!(script.contains("commit-msg"));
141    }
142
143    #[test]
144    fn test_build_hook_script_has_shebang() {
145        let script = build_hook_script("pre-push", 2);
146        assert!(script.starts_with("#!/"));
147    }
148
149    #[test]
150    fn test_build_hook_script_calls_githops_check() {
151        let script = build_hook_script("pre-commit", 1);
152        assert!(script.contains("githops check"));
153    }
154
155    #[test]
156    fn test_sync_creates_hook_files() {
157        let dir = TempDir::new().unwrap();
158        let mut config = Config::default();
159        config.hooks.pre_commit = Some(HookConfig {
160            enabled: true,
161            parallel: false,
162            commands: vec![CommandEntry::Inline(Command {
163                name: "lint".into(),
164                run: "echo lint".into(),
165                depends: vec![],
166                env: BTreeMap::new(),
167                test: false,
168                cache: None,
169            })],
170        });
171
172        let (installed, skipped) = sync_to_hooks(&config, dir.path(), false).unwrap();
173        assert_eq!(installed, 1);
174        assert_eq!(skipped, 0);
175        assert!(dir.path().join("pre-commit").exists());
176    }
177
178    #[test]
179    fn test_sync_hook_script_content_is_correct() {
180        let dir = TempDir::new().unwrap();
181        let mut config = Config::default();
182        config.hooks.pre_commit = Some(HookConfig {
183            enabled: true,
184            parallel: false,
185            commands: vec![CommandEntry::Inline(Command {
186                name: "lint".into(),
187                run: "echo lint".into(),
188                depends: vec![],
189                env: BTreeMap::new(),
190                test: false,
191                cache: None,
192            })],
193        });
194
195        sync_to_hooks(&config, dir.path(), false).unwrap();
196        let content = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
197        assert!(content.contains("GITHOPS_MANAGED"));
198        assert!(content.contains("pre-commit"));
199    }
200
201    #[test]
202    fn test_sync_skips_disabled_hook() {
203        let dir = TempDir::new().unwrap();
204        let mut config = Config::default();
205        config.hooks.pre_commit = Some(HookConfig {
206            enabled: false, // disabled
207            parallel: false,
208            commands: vec![CommandEntry::Inline(Command {
209                name: "lint".into(),
210                run: "echo lint".into(),
211                depends: vec![],
212                env: BTreeMap::new(),
213                test: false,
214                cache: None,
215            })],
216        });
217
218        let (installed, _skipped) = sync_to_hooks(&config, dir.path(), false).unwrap();
219        assert_eq!(installed, 0);
220        assert!(!dir.path().join("pre-commit").exists());
221    }
222
223    #[test]
224    fn test_sync_skips_test_only_commands() {
225        let dir = TempDir::new().unwrap();
226        let mut config = Config::default();
227        config.hooks.pre_commit = Some(HookConfig {
228            enabled: true,
229            parallel: false,
230            commands: vec![CommandEntry::Inline(Command {
231                name: "lint".into(),
232                run: "echo lint".into(),
233                depends: vec![],
234                env: BTreeMap::new(),
235                test: true, // test-only, should not create hook
236                cache: None,
237            })],
238        });
239
240        let (installed, _) = sync_to_hooks(&config, dir.path(), false).unwrap();
241        assert_eq!(installed, 0);
242    }
243
244    #[test]
245    fn test_sync_does_not_overwrite_unmanaged_hook() {
246        let dir = TempDir::new().unwrap();
247        // Write an unmanaged hook (no GITHOPS_MANAGED marker)
248        std::fs::write(dir.path().join("pre-commit"), "#!/bin/sh\necho manual").unwrap();
249
250        let mut config = Config::default();
251        config.hooks.pre_commit = Some(HookConfig {
252            enabled: true,
253            parallel: false,
254            commands: vec![CommandEntry::Inline(Command {
255                name: "lint".into(),
256                run: "echo lint".into(),
257                depends: vec![],
258                env: BTreeMap::new(),
259                test: false,
260                cache: None,
261            })],
262        });
263
264        let (_installed, skipped) = sync_to_hooks(&config, dir.path(), false).unwrap();
265        assert_eq!(skipped, 1);
266        // Original content preserved
267        let content = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
268        assert!(content.contains("manual"));
269    }
270
271    #[test]
272    fn test_sync_force_overwrites_unmanaged_hook() {
273        let dir = TempDir::new().unwrap();
274        std::fs::write(dir.path().join("pre-commit"), "#!/bin/sh\necho manual").unwrap();
275
276        let mut config = Config::default();
277        config.hooks.pre_commit = Some(HookConfig {
278            enabled: true,
279            parallel: false,
280            commands: vec![CommandEntry::Inline(Command {
281                name: "lint".into(),
282                run: "echo lint".into(),
283                depends: vec![],
284                env: BTreeMap::new(),
285                test: false,
286                cache: None,
287            })],
288        });
289
290        let (installed, _) = sync_to_hooks(&config, dir.path(), true).unwrap();
291        assert_eq!(installed, 1);
292        let content = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
293        assert!(content.contains("GITHOPS_MANAGED"));
294    }
295
296    #[test]
297    fn test_sync_removes_obsolete_managed_hook() {
298        let dir = TempDir::new().unwrap();
299        // Write a managed hook that is no longer configured
300        let managed_content =
301            "#!/bin/sh\n# GITHOPS_MANAGED\nexec githops check pre-commit \"$@\"\n";
302        std::fs::write(dir.path().join("pre-commit"), managed_content).unwrap();
303
304        // Config has no pre-commit hook
305        let config = Config::default();
306
307        sync_to_hooks(&config, dir.path(), false).unwrap();
308        // Managed hook should be removed
309        assert!(!dir.path().join("pre-commit").exists());
310    }
311
312    #[test]
313    fn test_sync_is_idempotent() {
314        let dir = TempDir::new().unwrap();
315        let mut config = Config::default();
316        config.hooks.pre_commit = Some(HookConfig {
317            enabled: true,
318            parallel: false,
319            commands: vec![CommandEntry::Inline(Command {
320                name: "lint".into(),
321                run: "echo lint".into(),
322                depends: vec![],
323                env: BTreeMap::new(),
324                test: false,
325                cache: None,
326            })],
327        });
328
329        sync_to_hooks(&config, dir.path(), false).unwrap();
330        let content1 = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
331        sync_to_hooks(&config, dir.path(), false).unwrap();
332        let content2 = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
333        assert_eq!(content1, content2);
334    }
335}