crab_hooks/
git_hook.rs

1use git2::{DiffOptions, Repository, StatusOptions};
2use globset::GlobBuilder;
3use serde::{Deserialize, Serialize};
4use std::{
5    fs::{self, set_permissions, OpenOptions},
6    io::{BufRead, BufReader, Write},
7    os::unix::fs::PermissionsExt,
8    path::PathBuf,
9    process::Command,
10};
11
12use crate::{hook_types::HookTypes, sqllite::SqlLiteConfig};
13
14#[derive(Debug, Serialize, Deserialize)]
15pub struct CommandConfig {
16    pub cmd: String,
17    pub args: Option<String>,
18    pub directory: Option<PathBuf>,
19}
20
21#[derive(Debug, Serialize, Deserialize)]
22pub struct GitHook {
23    pub name: String,
24    pub command: CommandConfig,
25    pub glob_pattern: Vec<String>,
26    pub description: Option<String>,
27}
28
29impl std::fmt::Display for GitHook {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        write!(f, " - {}: \n  {{", self.name)?;
32        write!(f, "\n    path: {:?}", self.command)?;
33        write!(f, "\n    glob_pattern: {:?}", self.glob_pattern)?;
34        match &self.description {
35            Some(text) => write!(f, "\n    description: {}", text),
36            None => Ok(()),
37        }?;
38        write!(f, "\n  }}")
39    }
40}
41
42impl GitHook {
43    fn find_changed_or_to_be_pushed_files(
44        &self,
45    ) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
46        let repo = Repository::discover(".")?;
47        let workdir = repo
48            .workdir()
49            .ok_or_else(|| git2::Error::from_str("not a workdir"))?;
50
51        // 1) Gather unstaged + staged changes
52        let mut opts = StatusOptions::new();
53        opts.include_untracked(true)
54            .recurse_untracked_dirs(true)
55            .include_ignored(false)
56            .renames_head_to_index(true);
57
58        let statuses = repo.statuses(Some(&mut opts))?;
59        let mut paths: Vec<PathBuf> = statuses
60            .iter()
61            .filter_map(|e| {
62                let s = e.status();
63                let changed = s.is_index_new()
64                    || s.is_index_modified()
65                    || s.is_index_deleted()
66                    || s.is_wt_new()
67                    || s.is_wt_modified()
68                    || s.is_wt_deleted();
69                if changed {
70                    e.path().map(|p| workdir.join(p))
71                } else {
72                    None
73                }
74            })
75            .collect();
76
77        // 2) Now diff upstream → HEAD to pick up committed‑but‑not‑pushed files
78        if let Ok(upstream_obj) = repo.revparse_single("@{u}") {
79            // peel to commits
80            let upstream_commit = upstream_obj.peel_to_commit()?;
81            let head_commit = repo.head()?.peel_to_commit()?;
82
83            let upstream_tree = upstream_commit.tree()?;
84            let head_tree = head_commit.tree()?;
85
86            let mut diff_opts = DiffOptions::new();
87            let diff = repo.diff_tree_to_tree(
88                Some(&upstream_tree),
89                Some(&head_tree),
90                Some(&mut diff_opts),
91            )?;
92
93            for delta in diff.deltas() {
94                // new_file() covers added/modified/deleted
95                if let Some(p) = delta.new_file().path() {
96                    paths.push(workdir.join(p));
97                }
98            }
99        }
100        // else: no upstream configured → skip this part
101
102        // 3) Dedupe & return
103        paths.sort();
104        paths.dedup();
105        Ok(paths)
106    }
107
108    fn check_files_match_glob(&self) -> bool {
109        let file_result = self.find_changed_or_to_be_pushed_files();
110        if let Ok(files) = file_result {
111            for pattern in &self.glob_pattern {
112                if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
113                    let glob_matcher = glob.compile_matcher();
114                    for path in &files {
115                        let relative_path =
116                            path.strip_prefix(std::env::current_dir().unwrap()).unwrap();
117                        if glob_matcher.is_match(relative_path) {
118                            println!("pattern {} matched {:?}", pattern, relative_path);
119                            return true;
120                        }
121                    }
122                };
123            }
124        };
125        false
126    }
127
128    pub fn run(&self, sql_config: &SqlLiteConfig) -> Result<(), Box<dyn std::error::Error>> {
129        if !self.check_files_match_glob() {
130            println!("Pattern does not match the glob provided, skipping this!");
131            return Ok(());
132        }
133
134        println!("Running {}", self.command.cmd);
135        let mut cmd = Command::new(&self.command.cmd);
136        if let Some(v) = &self.command.args {
137            let args = v.split(" ");
138            cmd.args(args);
139        };
140        if let Some(v) = &self.command.directory {
141            cmd.current_dir(v);
142        };
143        let status = cmd
144            .spawn()
145            .unwrap_or_else(|_| panic!("Failed to execute {:?}", self.command.cmd))
146            .wait()?;
147        if status.success() {
148            // exit code was zero
149            sql_config.add_successful_run(&self.name)?;
150            Ok(())
151        } else {
152            // non‐zero or signal‐terminated
153            sql_config.add_failed_run(&self.name)?;
154            match status.code() {
155                // exited with some code != 0
156                Some(code) => Err(format!("Command failed with status {}", code).into()),
157                // e.g. killed by signal on Unix
158                None => Err("Cmd terminated by signal".into()),
159            }
160        }
161    }
162
163    pub fn apply_hook(
164        &self,
165        hook_type: &HookTypes,
166        sql_config: &SqlLiteConfig,
167    ) -> Result<(), Box<dyn std::error::Error>> {
168        println!("Apply hook {} as {}", self.name, hook_type);
169        let cd = std::env::current_dir()?
170            .to_str()
171            .expect("Failed to get current dir")
172            .to_string();
173
174        // First check if the current directory is a git repo
175        fs::read_dir("./.git/")?;
176
177        // Check if there is already a git hook
178        let mut already_managed = false;
179
180        let file_path = format!("./.git/hooks/{}", hook_type);
181        match sql_config.check_if_hook_is_known(&cd, hook_type) {
182            Ok(true) => match sql_config.check_if_hook_is_same(&cd, hook_type, &self.name) {
183                Ok(false) => {
184                    println!("There is already a existing managed git hook, will try to truncate exisiting config");
185                    already_managed = true;
186                }
187                Ok(true) => {
188                    return Err(
189                        "Git hooks is already setup for this repo with this type, aborting".into(),
190                    );
191                }
192                Err(e) => {
193                    return Err(format!(
194                        "Failed to check for pre exisiting similar hooks, error: {}",
195                        e
196                    )
197                    .into());
198                }
199            },
200            Ok(false) => {
201                if fs::read(&file_path).is_ok() {
202                    return Err(
203                        "Failed to apply hook, the selected hook type already exists, and may not be managed".into(),
204                    );
205                }
206            }
207            Err(e) => {
208                return Err(
209                    format!("Failed to check for pre exisiting hooks, error: {}", e).into(),
210                );
211            }
212        }
213
214        let exe_location = std::env::current_exe()?;
215        let file_content = format!("{} run {}", exe_location.to_str().expect(""), self.name);
216        if !already_managed {
217            let mut hook_file = fs::File::create(&file_path)?;
218            writeln!(hook_file, "#!/usr/bin/env sh")?;
219            writeln!(hook_file, "set -e")?;
220            writeln!(hook_file, "{}", file_content)?;
221            drop(hook_file);
222
223            let mut permissions = fs::metadata(&file_path)?.permissions();
224            permissions.set_mode(0o755);
225            set_permissions(file_path, permissions)?;
226        } else {
227            let mut hook_file = OpenOptions::new().append(true).open(file_path)?;
228            writeln!(hook_file, "{}", file_content)?;
229            drop(hook_file);
230        }
231
232        sql_config.add_hook(&self.name)?;
233        sql_config.add_hook_to_repo(&self.name, &cd, hook_type)?;
234
235        Ok(())
236    }
237
238    pub fn remove_hook(
239        self,
240        hook_type: &HookTypes,
241        sql_config: &SqlLiteConfig,
242    ) -> Result<(), Box<dyn std::error::Error>> {
243        // find current directory
244        // Find the current directory and hooktype match in sql config
245        // Remove that entry from the hook file and sql
246        let cd = std::env::current_dir()?;
247        let cd_str = cd.into_os_string().into_string().unwrap();
248        match sql_config.check_if_hook_is_same(cd_str.as_str(), hook_type, self.name.as_str()) {
249            Ok(true) => (),
250            _ => return Err("Trying to remove unknown hook, aborting!".into()),
251        }
252
253        // Remove the execution from the hook
254        let file_path = format!("./.git/hooks/{}", hook_type);
255        let file = fs::File::open(&file_path)?;
256        let reader = BufReader::new(file);
257
258        let lines: Vec<String> = reader
259            .lines()
260            .map_while(Result::ok)
261            .filter(|line| -> bool { !line.contains(self.name.as_str()) })
262            .collect();
263
264        let only_shebang = lines.len() == 1 && lines[0].starts_with("#!");
265        let is_empty = lines.is_empty();
266
267        if is_empty || only_shebang {
268            fs::remove_file(file_path)?;
269        } else {
270            let mut file = fs::File::create(&file_path)?;
271            for line in lines {
272                writeln!(file, "{}", line)?;
273            }
274        }
275
276        // Remove the hook from sqllite
277        sql_config.remove_hook(cd_str.as_str(), hook_type, self.name.as_str())
278    }
279
280    pub fn delete_hook(
281        self,
282        sql_config: &SqlLiteConfig,
283        config_file: PathBuf,
284    ) -> Result<(), Box<dyn std::error::Error>> {
285        // First check the hook is not used by any
286        if matches!(sql_config.check_if_hook_is_used(&self.name), Ok(true)) {
287            return Err("The hook is in use; please remove those first.".into());
288        }
289        // Then remove from config.yml
290        let f = std::fs::File::open(&config_file)?;
291        let mut hooks: Vec<GitHook> = serde_yaml::from_reader(f)?;
292        hooks.retain(|h| h.name != self.name);
293        let yaml_str = serde_yaml::to_string(&hooks)?;
294        fs::write(&config_file, yaml_str)?;
295
296        Ok(())
297    }
298}