1use git2::Repository;
2
3use crate::{error::Result, HookResult, HooksError};
4
5use std::{
6 ffi::{OsStr, OsString},
7 path::{Path, PathBuf},
8 process::Command,
9 str::FromStr,
10};
11
12pub struct HookPaths {
13 pub git: PathBuf,
14 pub hook: PathBuf,
15 pub pwd: PathBuf,
16}
17
18const CONFIG_HOOKS_PATH: &str = "core.hooksPath";
19const DEFAULT_HOOKS_PATH: &str = "hooks";
20const ENOEXEC: i32 = 8;
21
22impl HookPaths {
23 pub fn new(
31 repo: &Repository,
32 other_paths: Option<&[&str]>,
33 hook: &str,
34 ) -> Result<Self> {
35 let pwd = repo
36 .workdir()
37 .unwrap_or_else(|| repo.path())
38 .to_path_buf();
39
40 let git_dir = repo.path().to_path_buf();
41
42 if let Some(config_path) = Self::config_hook_path(repo)? {
43 let hooks_path = PathBuf::from(config_path);
44
45 let hook =
46 Self::expand_path(&hooks_path.join(hook), &pwd)?;
47
48 return Ok(Self {
49 git: git_dir,
50 hook,
51 pwd,
52 });
53 }
54
55 Ok(Self {
56 git: git_dir,
57 hook: Self::find_hook(repo, other_paths, hook),
58 pwd,
59 })
60 }
61
62 fn expand_path(path: &Path, pwd: &Path) -> Result<PathBuf> {
65 let hook_expanded = shellexpand::full(
66 path.as_os_str()
67 .to_str()
68 .ok_or(HooksError::PathToString)?,
69 )?;
70 let hook_expanded = PathBuf::from_str(hook_expanded.as_ref())
71 .map_err(|_| HooksError::PathToString)?;
72
73 Ok({
89 if hook_expanded.is_absolute() {
90 hook_expanded
91 } else {
92 pwd.join(hook_expanded)
93 }
94 })
95 }
96
97 fn config_hook_path(repo: &Repository) -> Result<Option<String>> {
98 Ok(repo.config()?.get_string(CONFIG_HOOKS_PATH).ok())
99 }
100
101 fn find_hook(
104 repo: &Repository,
105 other_paths: Option<&[&str]>,
106 hook: &str,
107 ) -> PathBuf {
108 let mut paths = vec![DEFAULT_HOOKS_PATH.to_string()];
109 if let Some(others) = other_paths {
110 paths.extend(
111 others
112 .iter()
113 .map(|p| p.trim_end_matches('/').to_string()),
114 );
115 }
116
117 for p in paths {
118 let p = repo.path().to_path_buf().join(p).join(hook);
119 if p.exists() {
120 return p;
121 }
122 }
123
124 repo.path()
125 .to_path_buf()
126 .join(DEFAULT_HOOKS_PATH)
127 .join(hook)
128 }
129
130 pub fn found(&self) -> bool {
132 self.hook.exists() && is_executable(&self.hook)
133 }
134
135 pub fn run_hook(&self, args: &[&str]) -> Result<HookResult> {
138 self.run_hook_os_str(args)
139 }
140
141 pub fn run_hook_os_str<I, S>(&self, args: I) -> Result<HookResult>
144 where
145 I: IntoIterator<Item = S> + Copy,
146 S: AsRef<OsStr>,
147 {
148 let hook = self.hook.clone();
149 log::trace!("run hook '{:?}' in '{:?}'", hook, self.pwd);
150
151 let run_command = |command: &mut Command| {
152 command
153 .args(args)
154 .current_dir(&self.pwd)
155 .with_no_window()
156 .output()
157 };
158
159 let output = if cfg!(windows) {
160 let command = {
162 const REPLACEMENT: &str = concat!(
166 "'", "\\'", "'", );
170
171 let mut os_str = OsString::new();
172 os_str.push("'");
173 if let Some(hook) = hook.to_str() {
174 os_str.push(hook.replace('\'', REPLACEMENT));
175 } else {
176 #[cfg(windows)]
177 {
178 use std::os::windows::ffi::OsStrExt;
179 if hook
180 .as_os_str()
181 .encode_wide()
182 .any(|x| x == u16::from(b'\''))
183 {
184 return Err(HooksError::PathToString);
186 }
187 }
188
189 os_str.push(hook.as_os_str());
190 }
191 os_str.push("'");
192 os_str.push(" \"$@\"");
193
194 os_str
195 };
196 run_command(
197 sh_command().arg("-c").arg(command).arg(&hook),
198 )
199 } else {
200 match run_command(&mut Command::new(&hook)) {
202 Err(err) if err.raw_os_error() == Some(ENOEXEC) => {
203 run_command(sh_command().arg(&hook))
204 }
205 result => result,
206 }
207 }?;
208
209 if output.status.success() {
210 Ok(HookResult::Ok { hook })
211 } else {
212 let stderr =
213 String::from_utf8_lossy(&output.stderr).to_string();
214 let stdout =
215 String::from_utf8_lossy(&output.stdout).to_string();
216
217 Ok(HookResult::RunNotSuccessful {
218 code: output.status.code(),
219 stdout,
220 stderr,
221 hook,
222 })
223 }
224 }
225}
226
227fn sh_command() -> Command {
228 let mut command = Command::new(gix_path::env::shell());
229
230 if cfg!(windows) {
231 command.env(
235 "DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS",
236 "FixPathHandlingOnWindows",
237 );
238
239 command.arg("-l");
241 }
242
243 command
244}
245
246#[cfg(unix)]
247fn is_executable(path: &Path) -> bool {
248 use std::os::unix::fs::PermissionsExt;
249
250 let metadata = match path.metadata() {
251 Ok(metadata) => metadata,
252 Err(e) => {
253 log::error!("metadata error: {}", e);
254 return false;
255 }
256 };
257
258 let permissions = metadata.permissions();
259
260 permissions.mode() & 0o111 != 0
261}
262
263#[cfg(windows)]
264const fn is_executable(_: &Path) -> bool {
267 true
268}
269
270trait CommandExt {
271 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
280
281 fn with_no_window(&mut self) -> &mut Self;
282}
283
284impl CommandExt for Command {
285 #[inline]
290 fn with_no_window(&mut self) -> &mut Self {
291 #[cfg(windows)]
292 {
293 use std::os::windows::process::CommandExt;
294 self.creation_flags(Self::CREATE_NO_WINDOW);
295 }
296
297 self
298 }
299}
300
301#[cfg(test)]
302mod test {
303 use super::HookPaths;
304 use std::path::Path;
305
306 #[test]
307 fn test_hookspath_relative() {
308 assert_eq!(
309 HookPaths::expand_path(
310 Path::new("pre-commit"),
311 Path::new("example_git_root"),
312 )
313 .unwrap(),
314 Path::new("example_git_root").join("pre-commit")
315 );
316 }
317
318 #[test]
319 fn test_hookspath_absolute() {
320 let absolute_hook =
321 std::env::current_dir().unwrap().join("pre-commit");
322 assert_eq!(
323 HookPaths::expand_path(
324 &absolute_hook,
325 Path::new("example_git_root"),
326 )
327 .unwrap(),
328 absolute_hook
329 );
330 }
331}