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