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}