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/// this hook is documented here <https://git-scm.com/docs/githooks#_commit_msg>
116/// we use the same convention as other git clients to create a temp file containing
117/// the commit message at `<.git|hooksPath>/COMMIT_EDITMSG` and pass it's relative path as the only
118/// parameter to the hook script.
119pub fn hooks_commit_msg(
120	repo: &Repository,
121	other_paths: Option<&[&str]>,
122	msg: &mut String,
123) -> Result<HookResult> {
124	let hook = HookPaths::new(repo, other_paths, HOOK_COMMIT_MSG)?;
125
126	if !hook.found() {
127		return Ok(HookResult::NoHookFound);
128	}
129
130	let temp_file = hook.git.join(HOOK_COMMIT_MSG_TEMP_FILE);
131	File::create(&temp_file)?.write_all(msg.as_bytes())?;
132
133	let res = hook.run_hook(&[temp_file
134		.as_os_str()
135		.to_string_lossy()
136		.as_ref()])?;
137
138	// load possibly altered msg
139	msg.clear();
140	File::open(temp_file)?.read_to_string(msg)?;
141
142	Ok(res)
143}
144
145/// this hook is documented here <https://git-scm.com/docs/githooks#_pre_commit>
146pub fn hooks_pre_commit(
147	repo: &Repository,
148	other_paths: Option<&[&str]>,
149) -> Result<HookResult> {
150	let hook = HookPaths::new(repo, other_paths, HOOK_PRE_COMMIT)?;
151
152	if !hook.found() {
153		return Ok(HookResult::NoHookFound);
154	}
155
156	hook.run_hook(&[])
157}
158
159/// this hook is documented here <https://git-scm.com/docs/githooks#_post_commit>
160pub fn hooks_post_commit(
161	repo: &Repository,
162	other_paths: Option<&[&str]>,
163) -> Result<HookResult> {
164	let hook = HookPaths::new(repo, other_paths, HOOK_POST_COMMIT)?;
165
166	if !hook.found() {
167		return Ok(HookResult::NoHookFound);
168	}
169
170	hook.run_hook(&[])
171}
172
173pub enum PrepareCommitMsgSource {
174	Message,
175	Template,
176	Merge,
177	Squash,
178	Commit(git2::Oid),
179}
180
181/// this hook is documented here <https://git-scm.com/docs/githooks#_prepare_commit_msg>
182#[allow(clippy::needless_pass_by_value)]
183pub fn hooks_prepare_commit_msg(
184	repo: &Repository,
185	other_paths: Option<&[&str]>,
186	source: PrepareCommitMsgSource,
187	msg: &mut String,
188) -> Result<HookResult> {
189	let hook =
190		HookPaths::new(repo, other_paths, HOOK_PREPARE_COMMIT_MSG)?;
191
192	if !hook.found() {
193		return Ok(HookResult::NoHookFound);
194	}
195
196	let temp_file = hook.git.join(HOOK_COMMIT_MSG_TEMP_FILE);
197	File::create(&temp_file)?.write_all(msg.as_bytes())?;
198
199	let temp_file_path = temp_file.as_os_str().to_string_lossy();
200
201	let vec = vec![
202		temp_file_path.as_ref(),
203		match source {
204			PrepareCommitMsgSource::Message => "message",
205			PrepareCommitMsgSource::Template => "template",
206			PrepareCommitMsgSource::Merge => "merge",
207			PrepareCommitMsgSource::Squash => "squash",
208			PrepareCommitMsgSource::Commit(_) => "commit",
209		},
210	];
211	let mut args = vec;
212
213	let id = if let PrepareCommitMsgSource::Commit(id) = &source {
214		Some(id.to_string())
215	} else {
216		None
217	};
218
219	if let Some(id) = &id {
220		args.push(id);
221	}
222
223	let res = hook.run_hook(args.as_slice())?;
224
225	// load possibly altered msg
226	msg.clear();
227	File::open(temp_file)?.read_to_string(msg)?;
228
229	Ok(res)
230}
231
232#[cfg(test)]
233mod tests {
234	use super::*;
235	use git2_testing::{repo_init, repo_init_bare};
236	use pretty_assertions::assert_eq;
237	use tempfile::TempDir;
238
239	#[test]
240	fn test_smoke() {
241		let (_td, repo) = repo_init();
242
243		let mut msg = String::from("test");
244		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
245
246		assert_eq!(res, HookResult::NoHookFound);
247
248		let hook = b"#!/bin/sh
249exit 0
250        ";
251
252		create_hook(&repo, HOOK_POST_COMMIT, hook);
253
254		let res = hooks_post_commit(&repo, None).unwrap();
255
256		assert!(res.is_ok());
257	}
258
259	#[test]
260	fn test_hooks_commit_msg_ok() {
261		let (_td, repo) = repo_init();
262
263		let hook = b"#!/bin/sh
264exit 0
265        ";
266
267		create_hook(&repo, HOOK_COMMIT_MSG, hook);
268
269		let mut msg = String::from("test");
270		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
271
272		assert!(res.is_ok());
273
274		assert_eq!(msg, String::from("test"));
275	}
276
277	#[test]
278	fn test_hooks_commit_msg_with_shell_command_ok() {
279		let (_td, repo) = repo_init();
280
281		let hook = br#"#!/bin/sh
282COMMIT_MSG="$(cat "$1")"
283printf "$COMMIT_MSG" | sed 's/sth/shell_command/g' >"$1"
284exit 0
285        "#;
286
287		create_hook(&repo, HOOK_COMMIT_MSG, hook);
288
289		let mut msg = String::from("test_sth");
290		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
291
292		assert!(res.is_ok());
293
294		assert_eq!(msg, String::from("test_shell_command"));
295	}
296
297	#[test]
298	fn test_pre_commit_sh() {
299		let (_td, repo) = repo_init();
300
301		let hook = b"#!/bin/sh
302exit 0
303        ";
304
305		create_hook(&repo, HOOK_PRE_COMMIT, hook);
306		let res = hooks_pre_commit(&repo, None).unwrap();
307		assert!(res.is_ok());
308	}
309
310	#[test]
311	fn test_no_hook_found() {
312		let (_td, repo) = repo_init();
313
314		let res = hooks_pre_commit(&repo, None).unwrap();
315		assert_eq!(res, HookResult::NoHookFound);
316	}
317
318	#[test]
319	fn test_other_path() {
320		let (td, repo) = repo_init();
321
322		let hook = b"#!/bin/sh
323exit 0
324        ";
325
326		let custom_hooks_path = td.path().join(".myhooks");
327
328		std::fs::create_dir(dbg!(&custom_hooks_path)).unwrap();
329		create_hook_in_path(
330			dbg!(custom_hooks_path.join(HOOK_PRE_COMMIT).as_path()),
331			hook,
332		);
333
334		let res =
335			hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
336
337		assert!(res.is_ok());
338	}
339
340	#[test]
341	fn test_other_path_precendence() {
342		let (td, repo) = repo_init();
343
344		{
345			let hook = b"#!/bin/sh
346exit 0
347        ";
348
349			create_hook(&repo, HOOK_PRE_COMMIT, hook);
350		}
351
352		{
353			let reject_hook = b"#!/bin/sh
354exit 1
355        ";
356
357			let custom_hooks_path = td.path().join(".myhooks");
358			std::fs::create_dir(dbg!(&custom_hooks_path)).unwrap();
359			create_hook_in_path(
360				dbg!(custom_hooks_path
361					.join(HOOK_PRE_COMMIT)
362					.as_path()),
363				reject_hook,
364			);
365		}
366
367		let res =
368			hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
369
370		assert!(res.is_ok());
371	}
372
373	#[test]
374	fn test_pre_commit_fail_sh() {
375		let (_td, repo) = repo_init();
376
377		let hook = b"#!/bin/sh
378echo 'rejected'
379exit 1
380        ";
381
382		create_hook(&repo, HOOK_PRE_COMMIT, hook);
383		let res = hooks_pre_commit(&repo, None).unwrap();
384		assert!(res.is_not_successful());
385	}
386
387	#[test]
388	fn test_env_containing_path() {
389		let (_td, repo) = repo_init();
390
391		let hook = b"#!/bin/sh
392export
393exit 1
394        ";
395
396		create_hook(&repo, HOOK_PRE_COMMIT, hook);
397		let res = hooks_pre_commit(&repo, None).unwrap();
398
399		let HookResult::RunNotSuccessful { stdout, .. } = res else {
400			unreachable!()
401		};
402
403		assert!(stdout
404			.lines()
405			.any(|line| line.starts_with("export PATH")));
406	}
407
408	#[test]
409	fn test_pre_commit_fail_hookspath() {
410		let (_td, repo) = repo_init();
411		let hooks = TempDir::new().unwrap();
412
413		let hook = b"#!/bin/sh
414echo 'rejected'
415exit 1
416        ";
417
418		create_hook_in_path(&hooks.path().join("pre-commit"), hook);
419
420		repo.config()
421			.unwrap()
422			.set_str(
423				"core.hooksPath",
424				hooks.path().as_os_str().to_str().unwrap(),
425			)
426			.unwrap();
427
428		let res = hooks_pre_commit(&repo, None).unwrap();
429
430		let HookResult::RunNotSuccessful { code, stdout, .. } = res
431		else {
432			unreachable!()
433		};
434
435		assert_eq!(code.unwrap(), 1);
436		assert_eq!(&stdout, "rejected\n");
437	}
438
439	#[test]
440	fn test_pre_commit_fail_bare() {
441		let (_td, repo) = repo_init_bare();
442
443		let hook = b"#!/bin/sh
444echo 'rejected'
445exit 1
446        ";
447
448		create_hook(&repo, HOOK_PRE_COMMIT, hook);
449		let res = hooks_pre_commit(&repo, None).unwrap();
450		assert!(res.is_not_successful());
451	}
452
453	#[test]
454	fn test_pre_commit_py() {
455		let (_td, repo) = repo_init();
456
457		// mirror how python pre-commmit sets itself up
458		#[cfg(not(windows))]
459		let hook = b"#!/usr/bin/env python
460import sys
461sys.exit(0)
462        ";
463		#[cfg(windows)]
464		let hook = b"#!/bin/env python.exe
465import sys
466sys.exit(0)
467        ";
468
469		create_hook(&repo, HOOK_PRE_COMMIT, hook);
470		let res = hooks_pre_commit(&repo, None).unwrap();
471		assert!(res.is_ok());
472	}
473
474	#[test]
475	fn test_pre_commit_fail_py() {
476		let (_td, repo) = repo_init();
477
478		// mirror how python pre-commmit sets itself up
479		#[cfg(not(windows))]
480		let hook = b"#!/usr/bin/env python
481import sys
482sys.exit(1)
483        ";
484		#[cfg(windows)]
485		let hook = b"#!/bin/env python.exe
486import sys
487sys.exit(1)
488        ";
489
490		create_hook(&repo, HOOK_PRE_COMMIT, hook);
491		let res = hooks_pre_commit(&repo, None).unwrap();
492		assert!(res.is_not_successful());
493	}
494
495	#[test]
496	fn test_hooks_commit_msg_reject() {
497		let (_td, repo) = repo_init();
498
499		let hook = b"#!/bin/sh
500echo 'msg' > $1
501echo 'rejected'
502exit 1
503        ";
504
505		create_hook(&repo, HOOK_COMMIT_MSG, hook);
506
507		let mut msg = String::from("test");
508		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
509
510		let HookResult::RunNotSuccessful { code, stdout, .. } = res
511		else {
512			unreachable!()
513		};
514
515		assert_eq!(code.unwrap(), 1);
516		assert_eq!(&stdout, "rejected\n");
517
518		assert_eq!(msg, String::from("msg\n"));
519	}
520
521	#[test]
522	fn test_commit_msg_no_block_but_alter() {
523		let (_td, repo) = repo_init();
524
525		let hook = b"#!/bin/sh
526echo 'msg' > $1
527exit 0
528        ";
529
530		create_hook(&repo, HOOK_COMMIT_MSG, hook);
531
532		let mut msg = String::from("test");
533		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
534
535		assert!(res.is_ok());
536		assert_eq!(msg, String::from("msg\n"));
537	}
538
539	#[test]
540	fn test_hook_pwd_in_bare_without_workdir() {
541		let (_td, repo) = repo_init_bare();
542		let git_root = repo.path().to_path_buf();
543
544		let hook =
545			HookPaths::new(&repo, None, HOOK_POST_COMMIT).unwrap();
546
547		assert_eq!(hook.pwd, git_root);
548	}
549
550	#[test]
551	fn test_hook_pwd() {
552		let (_td, repo) = repo_init();
553		let git_root = repo.path().to_path_buf();
554
555		let hook =
556			HookPaths::new(&repo, None, HOOK_POST_COMMIT).unwrap();
557
558		assert_eq!(hook.pwd, git_root.parent().unwrap());
559	}
560
561	#[test]
562	fn test_hooks_prep_commit_msg_success() {
563		let (_td, repo) = repo_init();
564
565		let hook = b"#!/bin/sh
566echo msg:$2 > $1
567exit 0
568        ";
569
570		create_hook(&repo, HOOK_PREPARE_COMMIT_MSG, hook);
571
572		let mut msg = String::from("test");
573		let res = hooks_prepare_commit_msg(
574			&repo,
575			None,
576			PrepareCommitMsgSource::Message,
577			&mut msg,
578		)
579		.unwrap();
580
581		assert!(matches!(res, HookResult::Ok { .. }));
582		assert_eq!(msg, String::from("msg:message\n"));
583	}
584
585	#[test]
586	fn test_hooks_prep_commit_msg_reject() {
587		let (_td, repo) = repo_init();
588
589		let hook = b"#!/bin/sh
590echo $2,$3 > $1
591echo 'rejected'
592exit 2
593        ";
594
595		create_hook(&repo, HOOK_PREPARE_COMMIT_MSG, hook);
596
597		let mut msg = String::from("test");
598		let res = hooks_prepare_commit_msg(
599			&repo,
600			None,
601			PrepareCommitMsgSource::Commit(git2::Oid::zero()),
602			&mut msg,
603		)
604		.unwrap();
605
606		let HookResult::RunNotSuccessful { code, stdout, .. } = res
607		else {
608			unreachable!()
609		};
610
611		assert_eq!(code.unwrap(), 2);
612		assert_eq!(&stdout, "rejected\n");
613
614		assert_eq!(
615			msg,
616			String::from(
617				"commit,0000000000000000000000000000000000000000\n"
618			)
619		);
620	}
621}