Skip to main content

git_valet/
hooks.rs

1//! Git hook installation and uninstallation for transparent valet syncing.
2
3use anyhow::Result;
4use std::path::Path;
5
6#[cfg(unix)]
7use std::os::unix::fs::PermissionsExt;
8
9const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
10# git-valet: sync valet repo before commit
11if command -v git-valet >/dev/null 2>&1; then
12    git-valet sync --message "chore: auto-sync before commit" 2>/dev/null || true
13fi
14"#;
15
16const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
17# git-valet: sync valet repo before push
18if command -v git-valet >/dev/null 2>&1; then
19    git-valet sync --message "chore: auto-sync before push" 2>/dev/null || true
20fi
21"#;
22
23const POST_MERGE_HOOK: &str = r"#!/bin/sh
24# git-valet: pull valet repo after merge
25if command -v git-valet >/dev/null 2>&1; then
26    git-valet pull 2>/dev/null || true
27fi
28";
29
30const POST_CHECKOUT_HOOK: &str = r"#!/bin/sh
31# git-valet: pull valet repo after checkout
32if command -v git-valet >/dev/null 2>&1; then
33    git-valet pull 2>/dev/null || true
34fi
35";
36
37pub struct HookInstall {
38    pub name: &'static str,
39    pub content: &'static str,
40}
41
42const HOOKS: &[HookInstall] = &[
43    HookInstall { name: "pre-commit", content: PRE_COMMIT_HOOK },
44    HookInstall { name: "pre-push", content: PRE_PUSH_HOOK },
45    HookInstall { name: "post-merge", content: POST_MERGE_HOOK },
46    HookInstall { name: "post-checkout", content: POST_CHECKOUT_HOOK },
47];
48
49const SHADOW_MARKER: &str = "# git-valet:";
50
51/// Installs git-valet hooks in the main repo
52pub fn install(git_dir: &Path) -> Result<()> {
53    let hooks_dir = git_dir.join("hooks");
54    std::fs::create_dir_all(&hooks_dir)?;
55
56    for hook in HOOKS {
57        let hook_path = hooks_dir.join(hook.name);
58
59        if hook_path.exists() {
60            let existing = std::fs::read_to_string(&hook_path)?;
61            if existing.contains(SHADOW_MARKER) {
62                continue; // Already installed
63            }
64            // Append without duplicating the shebang (handle both LF and CRLF)
65            let stripped =
66                hook.content.trim_start_matches("#!/bin/sh\r\n").trim_start_matches("#!/bin/sh\n");
67            let combined = format!("{}\n{stripped}", existing.trim_end());
68            std::fs::write(&hook_path, combined)?;
69        } else {
70            std::fs::write(&hook_path, hook.content)?;
71        }
72
73        // Make executable (Unix only)
74        #[cfg(unix)]
75        {
76            let mut perms = std::fs::metadata(&hook_path)?.permissions();
77            perms.set_mode(0o755);
78            std::fs::set_permissions(&hook_path, perms)?;
79        }
80    }
81
82    Ok(())
83}
84
85/// Uninstalls git-valet hooks
86pub fn uninstall(git_dir: &Path) -> Result<()> {
87    let hooks_dir = git_dir.join("hooks");
88
89    for hook in HOOKS {
90        let hook_path = hooks_dir.join(hook.name);
91        if !hook_path.exists() {
92            continue;
93        }
94
95        let content = std::fs::read_to_string(&hook_path)?;
96
97        if !content.contains(SHADOW_MARKER) {
98            continue;
99        }
100
101        // Filter out the git-valet block (marker + following lines until "fi")
102        let mut filtered = Vec::new();
103        let mut in_valet_block = false;
104        for line in content.lines() {
105            if line.contains(SHADOW_MARKER) {
106                in_valet_block = true;
107                continue;
108            }
109            if in_valet_block {
110                if line.trim() == "fi" {
111                    in_valet_block = false;
112                }
113                continue;
114            }
115            filtered.push(line);
116        }
117        let filtered = filtered.join("\n");
118
119        let trimmed = filtered.trim();
120
121        if trimmed.is_empty() || trimmed == "#!/bin/sh" {
122            // Hook is empty after removal — delete the file
123            std::fs::remove_file(&hook_path)?;
124        } else {
125            std::fs::write(&hook_path, format!("{trimmed}\n"))?;
126        }
127    }
128
129    Ok(())
130}