git2_hooks/
lib.rs

1//! git2-rs addon supporting git hooks
2//!
3//! we look for hooks in the following locations:
4//!  * whatever `config.hooksPath` points to
5//!  * `.git/hooks/`
6//!  * whatever list of paths provided as `other_paths` (in order)
7//!
8//! most basic hook is: [`hooks_pre_commit`]. see also other `hooks_*` functions.
9//!
10//! [`create_hook`] is useful to create git hooks from code (unittest make heavy usage of it)
11
12#![forbid(unsafe_code)]
13#![deny(
14	unused_imports,
15	unused_must_use,
16	dead_code,
17	unstable_name_collisions,
18	unused_assignments
19)]
20#![deny(clippy::all, clippy::perf, clippy::pedantic, clippy::nursery)]
21#![allow(
22	clippy::missing_errors_doc,
23	clippy::must_use_candidate,
24	clippy::module_name_repetitions
25)]
26
27mod error;
28mod hookspath;
29
30use std::{
31	fs::File,
32	io::{Read, Write},
33	path::{Path, PathBuf},
34};
35
36pub use error::HooksError;
37use error::Result;
38use hookspath::HookPaths;
39
40use git2::Repository;
41
42pub const HOOK_POST_COMMIT: &str = "post-commit";
43pub const HOOK_PRE_COMMIT: &str = "pre-commit";
44pub const HOOK_COMMIT_MSG: &str = "commit-msg";
45pub const HOOK_PREPARE_COMMIT_MSG: &str = "prepare-commit-msg";
46
47const HOOK_COMMIT_MSG_TEMP_FILE: &str = "COMMIT_EDITMSG";
48
49#[derive(Debug, PartialEq, Eq)]
50pub enum HookResult {
51	/// No hook found
52	NoHookFound,
53	/// Hook executed with non error return code
54	Ok {
55		/// path of the hook that was run
56		hook: PathBuf,
57	},
58	/// Hook executed and returned an error code
59	RunNotSuccessful {
60		/// exit code as reported back from process calling the hook
61		code: Option<i32>,
62		/// stderr output emitted by hook
63		stdout: String,
64		/// stderr output emitted by hook
65		stderr: String,
66		/// path of the hook that was run
67		hook: PathBuf,
68	},
69}
70
71impl HookResult {
72	/// helper to check if result is ok
73	pub const fn is_ok(&self) -> bool {
74		matches!(self, Self::Ok { .. })
75	}
76
77	/// helper to check if result was run and not rejected
78	pub const fn is_not_successful(&self) -> bool {
79		matches!(self, Self::RunNotSuccessful { .. })
80	}
81}
82
83/// helper method to create git hooks programmatically (heavy used in unittests)
84///
85/// # Panics
86/// Panics if hook could not be created
87pub fn create_hook(
88	r: &Repository,
89	hook: &str,
90	hook_script: &[u8],
91) -> PathBuf {
92	let hook = HookPaths::new(r, None, hook).unwrap();
93
94	let path = hook.hook.clone();
95
96	create_hook_in_path(&hook.hook, hook_script);
97
98	path
99}
100
101fn create_hook_in_path(path: &Path, hook_script: &[u8]) {
102	File::create(path).unwrap().write_all(hook_script).unwrap();
103
104	#[cfg(unix)]
105	{
106		std::process::Command::new("chmod")
107			.arg("+x")
108			.arg(path)
109			// .current_dir(path)
110			.output()
111			.unwrap();
112	}
113}
114
115/// Git hook: `commit_msg`
116///
117/// This hook is documented here <https://git-scm.com/docs/githooks#_commit_msg>.
118/// We use the same convention as other git clients to create a temp file containing
119/// the commit message at `<.git|hooksPath>/COMMIT_EDITMSG` and pass it's relative path as the only
120/// parameter to the hook script.
121pub fn hooks_commit_msg(
122	repo: &Repository,
123	other_paths: Option<&[&str]>,
124	msg: &mut String,
125) -> Result<HookResult> {
126	let hook = HookPaths::new(repo, other_paths, HOOK_COMMIT_MSG)?;
127
128	if !hook.found() {
129		return Ok(HookResult::NoHookFound);
130	}
131
132	let temp_file = hook.git.join(HOOK_COMMIT_MSG_TEMP_FILE);
133	File::create(&temp_file)?.write_all(msg.as_bytes())?;
134
135	let res = hook.run_hook_os_str([&temp_file])?;
136
137	// load possibly altered msg
138	msg.clear();
139	File::open(temp_file)?.read_to_string(msg)?;
140
141	Ok(res)
142}
143
144/// this hook is documented here <https://git-scm.com/docs/githooks#_pre_commit>
145pub fn hooks_pre_commit(
146	repo: &Repository,
147	other_paths: Option<&[&str]>,
148) -> Result<HookResult> {
149	let hook = HookPaths::new(repo, other_paths, HOOK_PRE_COMMIT)?;
150
151	if !hook.found() {
152		return Ok(HookResult::NoHookFound);
153	}
154
155	hook.run_hook(&[])
156}
157
158/// this hook is documented here <https://git-scm.com/docs/githooks#_post_commit>
159pub fn hooks_post_commit(
160	repo: &Repository,
161	other_paths: Option<&[&str]>,
162) -> Result<HookResult> {
163	let hook = HookPaths::new(repo, other_paths, HOOK_POST_COMMIT)?;
164
165	if !hook.found() {
166		return Ok(HookResult::NoHookFound);
167	}
168
169	hook.run_hook(&[])
170}
171
172pub enum PrepareCommitMsgSource {
173	Message,
174	Template,
175	Merge,
176	Squash,
177	Commit(git2::Oid),
178}
179
180/// this hook is documented here <https://git-scm.com/docs/githooks#_prepare_commit_msg>
181#[allow(clippy::needless_pass_by_value)]
182pub fn hooks_prepare_commit_msg(
183	repo: &Repository,
184	other_paths: Option<&[&str]>,
185	source: PrepareCommitMsgSource,
186	msg: &mut String,
187) -> Result<HookResult> {
188	let hook =
189		HookPaths::new(repo, other_paths, HOOK_PREPARE_COMMIT_MSG)?;
190
191	if !hook.found() {
192		return Ok(HookResult::NoHookFound);
193	}
194
195	let temp_file = hook.git.join(HOOK_COMMIT_MSG_TEMP_FILE);
196	File::create(&temp_file)?.write_all(msg.as_bytes())?;
197
198	let temp_file_path = temp_file.as_os_str().to_string_lossy();
199
200	let vec = vec![
201		temp_file_path.as_ref(),
202		match source {
203			PrepareCommitMsgSource::Message => "message",
204			PrepareCommitMsgSource::Template => "template",
205			PrepareCommitMsgSource::Merge => "merge",
206			PrepareCommitMsgSource::Squash => "squash",
207			PrepareCommitMsgSource::Commit(_) => "commit",
208		},
209	];
210	let mut args = vec;
211
212	let id = if let PrepareCommitMsgSource::Commit(id) = &source {
213		Some(id.to_string())
214	} else {
215		None
216	};
217
218	if let Some(id) = &id {
219		args.push(id);
220	}
221
222	let res = hook.run_hook(args.as_slice())?;
223
224	// load possibly altered msg
225	msg.clear();
226	File::open(temp_file)?.read_to_string(msg)?;
227
228	Ok(res)
229}
230
231#[cfg(test)]
232mod tests {
233	use super::*;
234	use git2_testing::{repo_init, repo_init_bare};
235	use pretty_assertions::assert_eq;
236	use tempfile::TempDir;
237
238	#[test]
239	fn test_smoke() {
240		let (_td, repo) = repo_init();
241
242		let mut msg = String::from("test");
243		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
244
245		assert_eq!(res, HookResult::NoHookFound);
246
247		let hook = b"#!/bin/sh
248exit 0
249        ";
250
251		create_hook(&repo, HOOK_POST_COMMIT, hook);
252
253		let res = hooks_post_commit(&repo, None).unwrap();
254
255		assert!(res.is_ok());
256	}
257
258	#[test]
259	fn test_hooks_commit_msg_ok() {
260		let (_td, repo) = repo_init();
261
262		let hook = b"#!/bin/sh
263exit 0
264        ";
265
266		create_hook(&repo, HOOK_COMMIT_MSG, hook);
267
268		let mut msg = String::from("test");
269		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
270
271		assert!(res.is_ok());
272
273		assert_eq!(msg, String::from("test"));
274	}
275
276	#[test]
277	fn test_hooks_commit_msg_with_shell_command_ok() {
278		let (_td, repo) = repo_init();
279
280		let hook = br#"#!/bin/sh
281COMMIT_MSG="$(cat "$1")"
282printf "$COMMIT_MSG" | sed 's/sth/shell_command/g' > "$1"
283exit 0
284        "#;
285
286		create_hook(&repo, HOOK_COMMIT_MSG, hook);
287
288		let mut msg = String::from("test_sth");
289		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
290
291		assert!(res.is_ok());
292
293		assert_eq!(msg, String::from("test_shell_command"));
294	}
295
296	#[test]
297	fn test_pre_commit_sh() {
298		let (_td, repo) = repo_init();
299
300		let hook = b"#!/bin/sh
301exit 0
302        ";
303
304		create_hook(&repo, HOOK_PRE_COMMIT, hook);
305		let res = hooks_pre_commit(&repo, None).unwrap();
306		assert!(res.is_ok());
307	}
308
309	#[test]
310	fn test_hook_with_missing_shebang() {
311		const TEXT: &str = "Hello, world!";
312
313		let (_td, repo) = repo_init();
314
315		let hook = b"echo \"$@\"\nexit 42";
316
317		create_hook(&repo, HOOK_PRE_COMMIT, hook);
318
319		let hook =
320			HookPaths::new(&repo, None, HOOK_PRE_COMMIT).unwrap();
321
322		assert!(hook.found());
323
324		let result = hook.run_hook(&[TEXT]).unwrap();
325
326		let HookResult::RunNotSuccessful {
327			code,
328			stdout,
329			stderr,
330			hook: h,
331		} = result
332		else {
333			unreachable!("run_hook should've failed");
334		};
335
336		let stdout = stdout.as_str().trim_ascii_end();
337
338		assert_eq!(code, Some(42));
339		assert_eq!(h, hook.hook);
340		assert_eq!(stdout, TEXT, "{:?} != {TEXT:?}", stdout);
341		assert!(stderr.is_empty());
342	}
343
344	#[test]
345	fn test_no_hook_found() {
346		let (_td, repo) = repo_init();
347
348		let res = hooks_pre_commit(&repo, None).unwrap();
349		assert_eq!(res, HookResult::NoHookFound);
350	}
351
352	#[test]
353	fn test_other_path() {
354		let (td, repo) = repo_init();
355
356		let hook = b"#!/bin/sh
357exit 0
358        ";
359
360		let custom_hooks_path = td.path().join(".myhooks");
361
362		std::fs::create_dir(dbg!(&custom_hooks_path)).unwrap();
363		create_hook_in_path(
364			dbg!(custom_hooks_path.join(HOOK_PRE_COMMIT).as_path()),
365			hook,
366		);
367
368		let res =
369			hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
370
371		assert!(res.is_ok());
372	}
373
374	#[test]
375	fn test_other_path_precendence() {
376		let (td, repo) = repo_init();
377
378		{
379			let hook = b"#!/bin/sh
380exit 0
381        ";
382
383			create_hook(&repo, HOOK_PRE_COMMIT, hook);
384		}
385
386		{
387			let reject_hook = b"#!/bin/sh
388exit 1
389        ";
390
391			let custom_hooks_path = td.path().join(".myhooks");
392			std::fs::create_dir(dbg!(&custom_hooks_path)).unwrap();
393			create_hook_in_path(
394				dbg!(custom_hooks_path
395					.join(HOOK_PRE_COMMIT)
396					.as_path()),
397				reject_hook,
398			);
399		}
400
401		let res =
402			hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
403
404		assert!(res.is_ok());
405	}
406
407	#[test]
408	fn test_pre_commit_fail_sh() {
409		let (_td, repo) = repo_init();
410
411		let hook = b"#!/bin/sh
412echo 'rejected'
413exit 1
414        ";
415
416		create_hook(&repo, HOOK_PRE_COMMIT, hook);
417		let res = hooks_pre_commit(&repo, None).unwrap();
418		assert!(res.is_not_successful());
419	}
420
421	#[test]
422	fn test_env_containing_path() {
423		const PATH_EXPORT: &str = "export PATH";
424
425		let (_td, repo) = repo_init();
426
427		let hook = b"#!/bin/sh
428export
429exit 1
430        ";
431
432		create_hook(&repo, HOOK_PRE_COMMIT, hook);
433		let res = hooks_pre_commit(&repo, None).unwrap();
434
435		let HookResult::RunNotSuccessful { stdout, .. } = res else {
436			unreachable!()
437		};
438
439		assert!(
440			stdout
441				.lines()
442				.any(|line| line.starts_with(PATH_EXPORT)),
443			"Could not find line starting with {PATH_EXPORT:?} in: {stdout:?}"
444		);
445	}
446
447	#[test]
448	fn test_pre_commit_fail_hookspath() {
449		let (_td, repo) = repo_init();
450		let hooks = TempDir::new().unwrap();
451
452		let hook = b"#!/bin/sh
453echo 'rejected'
454exit 1
455        ";
456
457		create_hook_in_path(&hooks.path().join("pre-commit"), hook);
458
459		repo.config()
460			.unwrap()
461			.set_str(
462				"core.hooksPath",
463				hooks.path().as_os_str().to_str().unwrap(),
464			)
465			.unwrap();
466
467		let res = hooks_pre_commit(&repo, None).unwrap();
468
469		let HookResult::RunNotSuccessful { code, stdout, .. } = res
470		else {
471			unreachable!()
472		};
473
474		assert_eq!(code.unwrap(), 1);
475		assert_eq!(&stdout, "rejected\n");
476	}
477
478	#[test]
479	fn test_pre_commit_fail_bare() {
480		let (_td, repo) = repo_init_bare();
481
482		let hook = b"#!/bin/sh
483echo 'rejected'
484exit 1
485        ";
486
487		create_hook(&repo, HOOK_PRE_COMMIT, hook);
488		let res = hooks_pre_commit(&repo, None).unwrap();
489		assert!(res.is_not_successful());
490	}
491
492	#[test]
493	fn test_pre_commit_py() {
494		let (_td, repo) = repo_init();
495
496		// mirror how python pre-commmit sets itself up
497		#[cfg(not(windows))]
498		let hook = b"#!/usr/bin/env python
499import sys
500sys.exit(0)
501        ";
502		#[cfg(windows)]
503		let hook = b"#!/bin/env python.exe
504import sys
505sys.exit(0)
506        ";
507
508		create_hook(&repo, HOOK_PRE_COMMIT, hook);
509		let res = hooks_pre_commit(&repo, None).unwrap();
510		assert!(res.is_ok(), "{res:?}");
511	}
512
513	#[test]
514	fn test_pre_commit_fail_py() {
515		let (_td, repo) = repo_init();
516
517		// mirror how python pre-commmit sets itself up
518		#[cfg(not(windows))]
519		let hook = b"#!/usr/bin/env python
520import sys
521sys.exit(1)
522        ";
523		#[cfg(windows)]
524		let hook = b"#!/bin/env python.exe
525import sys
526sys.exit(1)
527        ";
528
529		create_hook(&repo, HOOK_PRE_COMMIT, hook);
530		let res = hooks_pre_commit(&repo, None).unwrap();
531		assert!(res.is_not_successful());
532	}
533
534	#[test]
535	fn test_hooks_commit_msg_reject() {
536		let (_td, repo) = repo_init();
537
538		let hook = b"#!/bin/sh
539	echo 'msg' > \"$1\"
540	echo 'rejected'
541	exit 1
542        ";
543
544		create_hook(&repo, HOOK_COMMIT_MSG, hook);
545
546		let mut msg = String::from("test");
547		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
548
549		let HookResult::RunNotSuccessful { code, stdout, .. } = res
550		else {
551			unreachable!()
552		};
553
554		assert_eq!(code.unwrap(), 1);
555		assert_eq!(&stdout, "rejected\n");
556
557		assert_eq!(msg, String::from("msg\n"));
558	}
559
560	#[test]
561	fn test_commit_msg_no_block_but_alter() {
562		let (_td, repo) = repo_init();
563
564		let hook = b"#!/bin/sh
565echo 'msg' > \"$1\"
566exit 0
567        ";
568
569		create_hook(&repo, HOOK_COMMIT_MSG, hook);
570
571		let mut msg = String::from("test");
572		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
573
574		assert!(res.is_ok());
575		assert_eq!(msg, String::from("msg\n"));
576	}
577
578	#[test]
579	fn test_hook_pwd_in_bare_without_workdir() {
580		let (_td, repo) = repo_init_bare();
581		let git_root = repo.path().to_path_buf();
582
583		let hook =
584			HookPaths::new(&repo, None, HOOK_POST_COMMIT).unwrap();
585
586		assert_eq!(hook.pwd, git_root);
587	}
588
589	#[test]
590	fn test_hook_pwd() {
591		let (_td, repo) = repo_init();
592		let git_root = repo.path().to_path_buf();
593
594		let hook =
595			HookPaths::new(&repo, None, HOOK_POST_COMMIT).unwrap();
596
597		assert_eq!(hook.pwd, git_root.parent().unwrap());
598	}
599
600	#[test]
601	fn test_hooks_prep_commit_msg_success() {
602		let (_td, repo) = repo_init();
603
604		let hook = b"#!/bin/sh
605echo \"msg:$2\" > \"$1\"
606exit 0
607        ";
608
609		create_hook(&repo, HOOK_PREPARE_COMMIT_MSG, hook);
610
611		let mut msg = String::from("test");
612		let res = hooks_prepare_commit_msg(
613			&repo,
614			None,
615			PrepareCommitMsgSource::Message,
616			&mut msg,
617		)
618		.unwrap();
619
620		assert!(matches!(res, HookResult::Ok { .. }));
621		assert_eq!(msg, String::from("msg:message\n"));
622	}
623
624	#[test]
625	fn test_hooks_prep_commit_msg_reject() {
626		let (_td, repo) = repo_init();
627
628		let hook = b"#!/bin/sh
629echo \"$2,$3\" > \"$1\"
630echo 'rejected'
631exit 2
632        ";
633
634		create_hook(&repo, HOOK_PREPARE_COMMIT_MSG, hook);
635
636		let mut msg = String::from("test");
637		let res = hooks_prepare_commit_msg(
638			&repo,
639			None,
640			PrepareCommitMsgSource::Commit(git2::Oid::zero()),
641			&mut msg,
642		)
643		.unwrap();
644
645		let HookResult::RunNotSuccessful { code, stdout, .. } = res
646		else {
647			unreachable!()
648		};
649
650		assert_eq!(code.unwrap(), 2);
651		assert_eq!(&stdout, "rejected\n");
652
653		assert_eq!(
654			msg,
655			String::from(
656				"commit,0000000000000000000000000000000000000000\n"
657			)
658		);
659	}
660}