git2_hooks/
hookspath.rs

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	/// `core.hooksPath` always takes precedence.
24	/// If its defined and there is no hook `hook` this is not considered
25	/// an error or a reason to search in other paths.
26	/// If the config is not set we go into search mode and
27	/// first check standard `.git/hooks` folder and any sub path provided in `other_paths`.
28	///
29	/// Note: we try to model as closely as possible what git shell is doing.
30	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	/// Expand path according to the rule of githooks and config
63	/// core.hooksPath
64	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		// `man git-config`:
74		//
75		// > A relative path is taken as relative to the
76		// > directory where the hooks are run (see the
77		// > "DESCRIPTION" section of githooks[5]).
78		//
79		// `man githooks`:
80		//
81		// > Before Git invokes a hook, it changes its
82		// > working directory to either $GIT_DIR in a bare
83		// > repository or the root of the working tree in a
84		// > non-bare repository.
85		//
86		// I.e. relative paths in core.hooksPath in non-bare
87		// repositories are always relative to GIT_WORK_TREE.
88		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	/// check default hook path first and then followed by `other_paths`.
102	/// if no hook is found we return the default hook path
103	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	/// was a hook file found and is it executable
131	pub fn found(&self) -> bool {
132		self.hook.exists() && is_executable(&self.hook)
133	}
134
135	/// this function calls hook scripts based on conventions documented here
136	/// see <https://git-scm.com/docs/githooks>
137	pub fn run_hook(&self, args: &[&str]) -> Result<HookResult> {
138		self.run_hook_os_str(args)
139	}
140
141	/// this function calls hook scripts based on conventions documented here
142	/// see <https://git-scm.com/docs/githooks>
143	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			// execute hook in shell
161			let command = {
162				// SEE: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02_02
163				// Enclosing characters in single-quotes ( '' ) shall preserve the literal value of each character within the single-quotes.
164				// A single-quote cannot occur within single-quotes.
165				const REPLACEMENT: &str = concat!(
166					"'",   // closing single-quote
167					"\\'", // one escaped single-quote (outside of single-quotes)
168					"'",   // new single-quote
169				);
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							// TODO: escape single quotes instead of failing
185							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			// execute hook directly
201			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		// This call forces Command to handle the Path environment correctly on windows,
232		// the specific env set here does not matter
233		// see https://github.com/rust-lang/rust/issues/37519
234		command.env(
235			"DUMMY_ENV_TO_FIX_WINDOWS_CMD_RUNS",
236			"FixPathHandlingOnWindows",
237		);
238
239		// Use -l to avoid "command not found"
240		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)]
264/// windows does not consider shell scripts to be executable so we consider everything
265/// to be executable (which is not far from the truth for windows platform.)
266const fn is_executable(_: &Path) -> bool {
267	true
268}
269
270trait CommandExt {
271	/// The process is a console application that is being run without a
272	/// console window. Therefore, the console handle for the application is
273	/// not set.
274	///
275	/// This flag is ignored if the application is not a console application,
276	/// or if it used with either `CREATE_NEW_CONSOLE` or `DETACHED_PROCESS`.
277	///
278	/// See: <https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags>
279	const CREATE_NO_WINDOW: u32 = 0x0800_0000;
280
281	fn with_no_window(&mut self) -> &mut Self;
282}
283
284impl CommandExt for Command {
285	/// On Windows, CLI applications that aren't the window's subsystem will
286	/// create and show a console window that pops up next to the main
287	/// application window when run. We disable this behavior by setting the
288	/// `CREATE_NO_WINDOW` flag.
289	#[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}