Skip to main content

git_side/commands/
hook.rs

1use std::fs;
2#[cfg(unix)]
3use std::os::unix::fs::PermissionsExt;
4use std::path::PathBuf;
5
6use colored::Colorize;
7
8use crate::error::{Error, Result};
9use crate::git;
10
11const HOOK_MARKER_START: &str = "# >>> git-side auto >>>";
12const HOOK_MARKER_END: &str = "# <<< git-side auto <<<";
13const HOOK_CONTENT: &str = r"
14# Auto-sync side-tracked files
15git side auto
16";
17
18/// Get the path to a git hook.
19fn hook_path(hook_name: &str) -> Result<PathBuf> {
20    let git_dir = git::git_dir()?;
21    Ok(git_dir.join("hooks").join(hook_name))
22}
23
24/// Check if our hook is already installed.
25fn is_installed(hook_name: &str) -> Result<bool> {
26    let path = hook_path(hook_name)?;
27    if !path.exists() {
28        return Ok(false);
29    }
30
31    let content = fs::read_to_string(&path).map_err(|e| Error::ReadFile {
32        path: path.clone(),
33        source: e,
34    })?;
35
36    Ok(content.contains(HOOK_MARKER_START))
37}
38
39/// Install the git-side hook.
40///
41/// # Errors
42///
43/// Returns an error if the hook is already installed or if file operations fail.
44pub fn install(hook_name: &str) -> Result<()> {
45    if is_installed(hook_name)? {
46        return Err(Error::HookAlreadyInstalled(hook_name.to_string()));
47    }
48
49    let path = hook_path(hook_name)?;
50
51    // Ensure hooks directory exists
52    if let Some(parent) = path.parent() {
53        fs::create_dir_all(parent).map_err(|e| Error::CreateDir {
54            path: parent.to_path_buf(),
55            source: e,
56        })?;
57    }
58
59    // Read existing content or create new
60    let existing = if path.exists() {
61        fs::read_to_string(&path).map_err(|e| Error::ReadFile {
62            path: path.clone(),
63            source: e,
64        })?
65    } else {
66        "#!/bin/sh\n".to_string()
67    };
68
69    // Append our hook
70    let new_content = format!(
71        "{existing}\n{HOOK_MARKER_START}{HOOK_CONTENT}{HOOK_MARKER_END}\n"
72    );
73
74    fs::write(&path, new_content).map_err(|e| Error::WriteFile {
75        path: path.clone(),
76        source: e,
77    })?;
78
79    // Make executable (Unix only - Windows doesn't need this)
80    #[cfg(unix)]
81    {
82        let mut perms = fs::metadata(&path)
83            .map_err(|e| Error::ReadFile {
84                path: path.clone(),
85                source: e,
86            })?
87            .permissions();
88        perms.set_mode(0o755);
89        fs::set_permissions(&path, perms).map_err(|e| Error::WriteFile {
90            path: path.clone(),
91            source: e,
92        })?;
93    }
94
95    println!(
96        "{} {} hook installed",
97        "Done.".green().bold(),
98        hook_name.cyan()
99    );
100
101    Ok(())
102}
103
104/// Uninstall the git-side hook.
105///
106/// # Errors
107///
108/// Returns an error if the hook is not installed or if file operations fail.
109pub fn uninstall(hook_name: &str) -> Result<()> {
110    if !is_installed(hook_name)? {
111        return Err(Error::HookNotInstalled(hook_name.to_string()));
112    }
113
114    let path = hook_path(hook_name)?;
115
116    let content = fs::read_to_string(&path).map_err(|e| Error::ReadFile {
117        path: path.clone(),
118        source: e,
119    })?;
120
121    // Remove our section
122    let mut new_lines = Vec::new();
123    let mut in_our_section = false;
124
125    for line in content.lines() {
126        if line.contains(HOOK_MARKER_START) {
127            in_our_section = true;
128            continue;
129        }
130        if line.contains(HOOK_MARKER_END) {
131            in_our_section = false;
132            continue;
133        }
134        if !in_our_section {
135            new_lines.push(line);
136        }
137    }
138
139    let new_content = new_lines.join("\n");
140
141    // Check if only shebang remains
142    let trimmed = new_content.trim();
143    if trimmed.is_empty() || trimmed == "#!/bin/sh" || trimmed == "#!/bin/bash" {
144        // Remove the file entirely
145        fs::remove_file(&path).map_err(|e| Error::WriteFile {
146            path: path.clone(),
147            source: e,
148        })?;
149    } else {
150        fs::write(&path, new_content).map_err(|e| Error::WriteFile {
151            path: path.clone(),
152            source: e,
153        })?;
154    }
155
156    println!(
157        "{} {} hook removed",
158        "Done.".green().bold(),
159        hook_name.cyan()
160    );
161
162    Ok(())
163}