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