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 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 if let Ok(upstream_obj) = repo.revparse_single("@{u}") {
79 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 if let Some(p) = delta.new_file().path() {
96 paths.push(workdir.join(p));
97 }
98 }
99 }
100 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 sql_config.add_successful_run(&self.name)?;
150 Ok(())
151 } else {
152 sql_config.add_failed_run(&self.name)?;
154 match status.code() {
155 Some(code) => Err(format!("Command failed with status {}", code).into()),
157 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 fs::read_dir("./.git/")?;
176
177 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 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 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 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 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 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}