Skip to main content

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::{Oid, 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/// Check if a given hook is present considering config/paths and optional extra paths.
52pub fn hook_available(
53	repo: &Repository,
54	other_paths: Option<&[&str]>,
55	hook: &str,
56) -> Result<bool> {
57	let hook = HookPaths::new(repo, other_paths, hook)?;
58	Ok(hook.found())
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct PrePushRef {
63	pub local_ref: String,
64	pub local_oid: Option<Oid>,
65	pub remote_ref: String,
66	pub remote_oid: Option<Oid>,
67}
68
69impl PrePushRef {
70	pub fn new(
71		local_ref: impl Into<String>,
72		local_oid: Option<Oid>,
73		remote_ref: impl Into<String>,
74		remote_oid: Option<Oid>,
75	) -> Self {
76		Self {
77			local_ref: local_ref.into(),
78			local_oid,
79			remote_ref: remote_ref.into(),
80			remote_oid,
81		}
82	}
83
84	fn format_oid(oid: Option<Oid>) -> String {
85		// "If the foreign ref does not yet exist the <remote-object-name> will be the all-zeroes object name"
86		// see https://git-scm.com/docs/githooks#_pre_push
87		oid.map_or_else(|| "0".repeat(40), |id| id.to_string())
88	}
89
90	pub fn to_line(&self) -> String {
91		format!(
92			"{} {} {} {}",
93			self.local_ref,
94			Self::format_oid(self.local_oid),
95			self.remote_ref,
96			Self::format_oid(self.remote_oid)
97		)
98	}
99
100	/// Build stdin content from a slice of updates (for pre-push hook)
101	pub fn to_stdin(updates: &[Self]) -> String {
102		let mut stdin = String::new();
103		for update in updates {
104			stdin.push_str(&update.to_line());
105			stdin.push('\n');
106		}
107		stdin
108	}
109}
110
111/// Response from running a hook
112#[derive(Debug, PartialEq, Eq)]
113pub struct HookRunResponse {
114	/// path of the hook that was run
115	pub hook: PathBuf,
116	/// stdout output emitted by hook
117	pub stdout: String,
118	/// stderr output emitted by hook
119	pub stderr: String,
120	/// exit code as reported back from process calling the hook (0 = success)
121	pub code: i32,
122}
123
124#[derive(Debug, PartialEq, Eq)]
125pub enum HookResult {
126	/// No hook found
127	NoHookFound,
128	/// Hook executed (check `HookRunResponse.code` for success/failure)
129	Run(HookRunResponse),
130}
131
132impl HookResult {
133	/// helper to check if hook ran successfully (found and exit code 0)
134	pub const fn is_successful(&self) -> bool {
135		matches!(self, Self::Run(response) if response.is_successful())
136	}
137}
138
139impl HookRunResponse {
140	/// Check if the hook succeeded (exit code 0)
141	pub const fn is_successful(&self) -> bool {
142		self.code == 0
143	}
144}
145
146/// helper method to create git hooks programmatically (heavy used in unittests)
147///
148/// # Panics
149/// Panics if hook could not be created
150pub fn create_hook(
151	r: &Repository,
152	hook: &str,
153	hook_script: &[u8],
154) -> PathBuf {
155	let hook = HookPaths::new(r, None, hook).unwrap();
156
157	let path = hook.hook.clone();
158
159	create_hook_in_path(&hook.hook, hook_script);
160
161	path
162}
163
164fn create_hook_in_path(path: &Path, hook_script: &[u8]) {
165	File::create(path).unwrap().write_all(hook_script).unwrap();
166
167	#[cfg(unix)]
168	{
169		std::process::Command::new("chmod")
170			.arg("+x")
171			.arg(path)
172			// .current_dir(path)
173			.output()
174			.unwrap();
175	}
176}
177
178/// Git hook: `commit_msg`
179///
180/// This hook is documented here <https://git-scm.com/docs/githooks#_commit_msg>.
181/// We use the same convention as other git clients to create a temp file containing
182/// the commit message at `<.git|hooksPath>/COMMIT_EDITMSG` and pass it's relative path as the only
183/// parameter to the hook script.
184pub fn hooks_commit_msg(
185	repo: &Repository,
186	other_paths: Option<&[&str]>,
187	msg: &mut String,
188) -> Result<HookResult> {
189	let hook = HookPaths::new(repo, other_paths, HOOK_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 res = hook.run_hook_os_str([&temp_file])?;
199
200	// load possibly altered msg
201	msg.clear();
202	File::open(temp_file)?.read_to_string(msg)?;
203
204	Ok(res)
205}
206
207/// this hook is documented here <https://git-scm.com/docs/githooks#_pre_commit>
208pub fn hooks_pre_commit(
209	repo: &Repository,
210	other_paths: Option<&[&str]>,
211) -> Result<HookResult> {
212	let hook = HookPaths::new(repo, other_paths, HOOK_PRE_COMMIT)?;
213
214	if !hook.found() {
215		return Ok(HookResult::NoHookFound);
216	}
217
218	hook.run_hook(&[])
219}
220
221/// this hook is documented here <https://git-scm.com/docs/githooks#_post_commit>
222pub fn hooks_post_commit(
223	repo: &Repository,
224	other_paths: Option<&[&str]>,
225) -> Result<HookResult> {
226	let hook = HookPaths::new(repo, other_paths, HOOK_POST_COMMIT)?;
227
228	if !hook.found() {
229		return Ok(HookResult::NoHookFound);
230	}
231
232	hook.run_hook(&[])
233}
234
235/// this hook is documented here <https://git-scm.com/docs/githooks#_pre_push>
236///
237/// According to git documentation, pre-push hook receives:
238/// - remote name as first argument (or URL if remote is not named)
239/// - remote URL as second argument
240/// - information about refs being pushed via stdin in format:
241///   `<local-ref> SP <local-object-name> SP <remote-ref> SP <remote-object-name> LF`
242///
243/// If `remote` is `None` or empty, the `url` is used for both arguments as per Git spec.
244///
245/// Note: The hook is called even when `updates` is empty (matching Git's behavior).
246/// This can occur when pushing tags that already exist on the remote.
247pub fn hooks_pre_push(
248	repo: &Repository,
249	other_paths: Option<&[&str]>,
250	remote: Option<&str>,
251	url: &str,
252	updates: &[PrePushRef],
253) -> Result<HookResult> {
254	let hook = HookPaths::new(repo, other_paths, HOOK_PRE_PUSH)?;
255
256	if !hook.found() {
257		return Ok(HookResult::NoHookFound);
258	}
259
260	// If a remote is not named (None or empty), the URL is passed for both arguments
261	let remote_name = match remote {
262		Some(r) if !r.is_empty() => r,
263		_ => url,
264	};
265
266	let stdin_data = PrePushRef::to_stdin(updates);
267
268	hook.run_hook_os_str_with_stdin(
269		[remote_name, url],
270		Some(stdin_data.as_bytes()),
271	)
272}
273
274pub enum PrepareCommitMsgSource {
275	Message,
276	Template,
277	Merge,
278	Squash,
279	Commit(git2::Oid),
280}
281
282/// this hook is documented here <https://git-scm.com/docs/githooks#_prepare_commit_msg>
283#[allow(clippy::needless_pass_by_value)]
284pub fn hooks_prepare_commit_msg(
285	repo: &Repository,
286	other_paths: Option<&[&str]>,
287	source: PrepareCommitMsgSource,
288	msg: &mut String,
289) -> Result<HookResult> {
290	let hook =
291		HookPaths::new(repo, other_paths, HOOK_PREPARE_COMMIT_MSG)?;
292
293	if !hook.found() {
294		return Ok(HookResult::NoHookFound);
295	}
296
297	let temp_file = hook.git.join(HOOK_COMMIT_MSG_TEMP_FILE);
298	File::create(&temp_file)?.write_all(msg.as_bytes())?;
299
300	let temp_file_path = temp_file.as_os_str().to_string_lossy();
301
302	let vec = vec![
303		temp_file_path.as_ref(),
304		match source {
305			PrepareCommitMsgSource::Message => "message",
306			PrepareCommitMsgSource::Template => "template",
307			PrepareCommitMsgSource::Merge => "merge",
308			PrepareCommitMsgSource::Squash => "squash",
309			PrepareCommitMsgSource::Commit(_) => "commit",
310		},
311	];
312	let mut args = vec;
313
314	let id = if let PrepareCommitMsgSource::Commit(id) = &source {
315		Some(id.to_string())
316	} else {
317		None
318	};
319
320	if let Some(id) = &id {
321		args.push(id);
322	}
323
324	let res = hook.run_hook(args.as_slice())?;
325
326	// load possibly altered msg
327	msg.clear();
328	File::open(temp_file)?.read_to_string(msg)?;
329
330	Ok(res)
331}
332
333#[cfg(test)]
334mod tests {
335	use super::*;
336	use git2_testing::{repo_init, repo_init_bare};
337	use pretty_assertions::assert_eq;
338	use tempfile::TempDir;
339
340	fn branch_update(
341		repo: &Repository,
342		remote: Option<&str>,
343		branch: &str,
344		remote_branch: Option<&str>,
345		delete: bool,
346	) -> PrePushRef {
347		let local_ref = format!("refs/heads/{branch}");
348		let local_oid = (!delete).then(|| {
349			repo.find_branch(branch, git2::BranchType::Local)
350				.unwrap()
351				.get()
352				.peel_to_commit()
353				.unwrap()
354				.id()
355		});
356
357		let remote_branch = remote_branch.unwrap_or(branch);
358		let remote_ref = format!("refs/heads/{remote_branch}");
359		let remote_oid = remote.and_then(|remote_name| {
360			repo.find_reference(&format!(
361				"refs/remotes/{remote_name}/{remote_branch}"
362			))
363			.ok()
364			.and_then(|r| r.peel_to_commit().ok())
365			.map(|c| c.id())
366		});
367
368		PrePushRef::new(local_ref, local_oid, remote_ref, remote_oid)
369	}
370
371	fn head_branch(repo: &Repository) -> String {
372		repo.head().unwrap().shorthand().unwrap().to_string()
373	}
374
375	#[test]
376	fn test_pre_push_ref_format() {
377		let zero_oid = "0".repeat(40);
378		let oid_a = "a".repeat(40);
379		let oid_b = "b".repeat(40);
380
381		// Both oids present
382		let update = PrePushRef::new(
383			"refs/heads/main",
384			Some(git2::Oid::from_str(&oid_a).unwrap()),
385			"refs/heads/main",
386			Some(git2::Oid::from_str(&oid_b).unwrap()),
387		);
388		assert_eq!(
389			update.to_line(),
390			format!(
391				"refs/heads/main {oid_a} refs/heads/main {oid_b}"
392			)
393		);
394
395		// No remote oid (new branch)
396		let update = PrePushRef::new(
397			"refs/heads/feature",
398			Some(git2::Oid::from_str(&oid_a).unwrap()),
399			"refs/heads/feature",
400			None,
401		);
402		assert_eq!(
403			update.to_line(),
404			format!("refs/heads/feature {oid_a} refs/heads/feature {zero_oid}")
405		);
406
407		// No local oid (delete)
408		let update = PrePushRef::new(
409			"refs/heads/old",
410			None,
411			"refs/heads/old",
412			Some(git2::Oid::from_str(&oid_b).unwrap()),
413		);
414		assert_eq!(
415			update.to_line(),
416			format!(
417				"refs/heads/old {zero_oid} refs/heads/old {oid_b}"
418			)
419		);
420
421		// to_stdin adds newlines
422		let updates = [
423			PrePushRef::new(
424				"refs/heads/a",
425				Some(git2::Oid::from_str(&oid_a).unwrap()),
426				"refs/heads/a",
427				None,
428			),
429			PrePushRef::new(
430				"refs/heads/b",
431				Some(git2::Oid::from_str(&oid_b).unwrap()),
432				"refs/heads/b",
433				None,
434			),
435		];
436		assert_eq!(
437			PrePushRef::to_stdin(&updates),
438			format!(
439				"refs/heads/a {oid_a} refs/heads/a {zero_oid}\nrefs/heads/b {oid_b} refs/heads/b {zero_oid}\n"
440			)
441		);
442	}
443
444	#[test]
445	fn test_smoke() {
446		let (_td, repo) = repo_init();
447
448		let mut msg = String::from("test");
449		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
450
451		assert_eq!(res, HookResult::NoHookFound);
452
453		let hook = b"#!/bin/sh
454exit 0
455        ";
456
457		create_hook(&repo, HOOK_POST_COMMIT, hook);
458
459		let res = hooks_post_commit(&repo, None).unwrap();
460
461		assert!(res.is_successful());
462	}
463
464	#[test]
465	fn test_hooks_commit_msg_ok() {
466		let (_td, repo) = repo_init();
467
468		let hook = b"#!/bin/sh
469exit 0
470        ";
471
472		create_hook(&repo, HOOK_COMMIT_MSG, hook);
473
474		let mut msg = String::from("test");
475		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
476
477		assert!(res.is_successful());
478
479		assert_eq!(msg, String::from("test"));
480	}
481
482	#[test]
483	fn test_hooks_commit_msg_with_shell_command_ok() {
484		let (_td, repo) = repo_init();
485
486		let hook = br#"#!/bin/sh
487COMMIT_MSG="$(cat "$1")"
488printf "$COMMIT_MSG" | sed 's/sth/shell_command/g' > "$1"
489exit 0
490        "#;
491
492		create_hook(&repo, HOOK_COMMIT_MSG, hook);
493
494		let mut msg = String::from("test_sth");
495		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
496
497		assert!(res.is_successful());
498
499		assert_eq!(msg, String::from("test_shell_command"));
500	}
501
502	#[test]
503	fn test_pre_commit_sh() {
504		let (_td, repo) = repo_init();
505
506		let hook = b"#!/bin/sh
507exit 0
508        ";
509
510		create_hook(&repo, HOOK_PRE_COMMIT, hook);
511		let res = hooks_pre_commit(&repo, None).unwrap();
512		assert!(res.is_successful());
513	}
514
515	#[test]
516	fn test_hook_with_missing_shebang() {
517		const TEXT: &str = "Hello, world!";
518
519		let (_td, repo) = repo_init();
520
521		let hook = b"echo \"$@\"\nexit 42";
522
523		create_hook(&repo, HOOK_PRE_COMMIT, hook);
524
525		let hook =
526			HookPaths::new(&repo, None, HOOK_PRE_COMMIT).unwrap();
527
528		assert!(hook.found());
529
530		let result = hook.run_hook(&[TEXT]).unwrap();
531
532		let HookResult::Run(response) = result else {
533			unreachable!("run_hook should've run");
534		};
535
536		let stdout = response.stdout.as_str().trim_ascii_end();
537
538		assert_eq!(response.code, 42);
539		assert_eq!(response.hook, hook.hook);
540		assert_eq!(stdout, TEXT, "{:?} != {TEXT:?}", stdout);
541		assert!(response.stderr.is_empty());
542	}
543
544	#[test]
545	fn test_no_hook_found() {
546		let (_td, repo) = repo_init();
547
548		let res = hooks_pre_commit(&repo, None).unwrap();
549		assert_eq!(res, HookResult::NoHookFound);
550	}
551
552	#[test]
553	fn test_other_path() {
554		let (td, repo) = repo_init();
555
556		let hook = b"#!/bin/sh
557exit 0
558        ";
559
560		let custom_hooks_path = td.path().join(".myhooks");
561
562		std::fs::create_dir(dbg!(&custom_hooks_path)).unwrap();
563		create_hook_in_path(
564			dbg!(custom_hooks_path.join(HOOK_PRE_COMMIT).as_path()),
565			hook,
566		);
567
568		let res =
569			hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
570
571		assert!(res.is_successful());
572	}
573
574	#[test]
575	fn test_other_path_precedence() {
576		let (td, repo) = repo_init();
577
578		{
579			let hook = b"#!/bin/sh
580exit 0
581        ";
582
583			create_hook(&repo, HOOK_PRE_COMMIT, hook);
584		}
585
586		{
587			let reject_hook = b"#!/bin/sh
588exit 1
589        ";
590
591			let custom_hooks_path = td.path().join(".myhooks");
592			std::fs::create_dir(dbg!(&custom_hooks_path)).unwrap();
593			create_hook_in_path(
594				dbg!(custom_hooks_path
595					.join(HOOK_PRE_COMMIT)
596					.as_path()),
597				reject_hook,
598			);
599		}
600
601		let res =
602			hooks_pre_commit(&repo, Some(&["../.myhooks"])).unwrap();
603
604		assert!(res.is_successful());
605	}
606
607	#[test]
608	fn test_pre_commit_fail_sh() {
609		let (_td, repo) = repo_init();
610
611		let hook = b"#!/bin/sh
612echo 'rejected'
613exit 1
614        ";
615
616		create_hook(&repo, HOOK_PRE_COMMIT, hook);
617		let res = hooks_pre_commit(&repo, None).unwrap();
618		assert!(!res.is_successful());
619	}
620
621	#[test]
622	fn test_env_containing_path() {
623		const PATH_EXPORT: &str = "export PATH";
624
625		let (_td, repo) = repo_init();
626
627		let hook = b"#!/bin/sh
628export
629exit 1
630        ";
631
632		create_hook(&repo, HOOK_PRE_COMMIT, hook);
633		let res = hooks_pre_commit(&repo, None).unwrap();
634
635		let HookResult::Run(response) = res else {
636			unreachable!()
637		};
638
639		assert!(
640			response
641				.stdout
642				.lines()
643				.any(|line| line.starts_with(PATH_EXPORT)),
644			"Could not find line starting with {PATH_EXPORT:?} in: {:?}",
645			response.stdout
646		);
647	}
648
649	#[test]
650	fn test_pre_commit_fail_hookspath() {
651		let (_td, repo) = repo_init();
652		let hooks = TempDir::new().unwrap();
653
654		let hook = b"#!/bin/sh
655echo 'rejected'
656exit 1
657        ";
658
659		create_hook_in_path(&hooks.path().join("pre-commit"), hook);
660
661		repo.config()
662			.unwrap()
663			.set_str(
664				"core.hooksPath",
665				hooks.path().as_os_str().to_str().unwrap(),
666			)
667			.unwrap();
668
669		let res = hooks_pre_commit(&repo, None).unwrap();
670
671		let HookResult::Run(response) = res else {
672			unreachable!()
673		};
674
675		assert_eq!(response.code, 1);
676		assert_eq!(&response.stdout, "rejected\n");
677	}
678
679	#[test]
680	fn test_pre_commit_fail_bare() {
681		let (_td, repo) = repo_init_bare();
682
683		let hook = b"#!/bin/sh
684echo 'rejected'
685exit 1
686        ";
687
688		create_hook(&repo, HOOK_PRE_COMMIT, hook);
689		let res = hooks_pre_commit(&repo, None).unwrap();
690		assert!(!res.is_successful());
691	}
692
693	#[test]
694	fn test_pre_commit_py() {
695		let (_td, repo) = repo_init();
696
697		// mirror how python pre-commit sets itself up
698		#[cfg(not(windows))]
699		let hook = b"#!/usr/bin/env python
700import sys
701sys.exit(0)
702        ";
703		#[cfg(windows)]
704		let hook = b"#!/bin/env python.exe
705import sys
706sys.exit(0)
707        ";
708
709		create_hook(&repo, HOOK_PRE_COMMIT, hook);
710		let res = hooks_pre_commit(&repo, None).unwrap();
711		assert!(res.is_successful(), "{res:?}");
712	}
713
714	#[test]
715	fn test_pre_commit_fail_py() {
716		let (_td, repo) = repo_init();
717
718		// mirror how python pre-commit sets itself up
719		#[cfg(not(windows))]
720		let hook = b"#!/usr/bin/env python
721import sys
722sys.exit(1)
723        ";
724		#[cfg(windows)]
725		let hook = b"#!/bin/env python.exe
726import sys
727sys.exit(1)
728        ";
729
730		create_hook(&repo, HOOK_PRE_COMMIT, hook);
731		let res = hooks_pre_commit(&repo, None).unwrap();
732		assert!(!res.is_successful());
733	}
734
735	#[test]
736	fn test_hooks_commit_msg_reject() {
737		let (_td, repo) = repo_init();
738
739		let hook = b"#!/bin/sh
740	echo 'msg' > \"$1\"
741	echo 'rejected'
742	exit 1
743        ";
744
745		create_hook(&repo, HOOK_COMMIT_MSG, hook);
746
747		let mut msg = String::from("test");
748		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
749
750		let HookResult::Run(response) = res else {
751			unreachable!()
752		};
753
754		assert_eq!(response.code, 1);
755		assert_eq!(&response.stdout, "rejected\n");
756
757		assert_eq!(msg, String::from("msg\n"));
758	}
759
760	#[test]
761	fn test_commit_msg_no_block_but_alter() {
762		let (_td, repo) = repo_init();
763
764		let hook = b"#!/bin/sh
765echo 'msg' > \"$1\"
766exit 0
767        ";
768
769		create_hook(&repo, HOOK_COMMIT_MSG, hook);
770
771		let mut msg = String::from("test");
772		let res = hooks_commit_msg(&repo, None, &mut msg).unwrap();
773
774		assert!(res.is_successful());
775		assert_eq!(msg, String::from("msg\n"));
776	}
777
778	#[test]
779	fn test_hook_pwd_in_bare_without_workdir() {
780		let (_td, repo) = repo_init_bare();
781		let git_root = repo.path().to_path_buf();
782
783		let hook =
784			HookPaths::new(&repo, None, HOOK_POST_COMMIT).unwrap();
785
786		assert_eq!(hook.pwd, git_root);
787	}
788
789	#[test]
790	fn test_hook_pwd() {
791		let (_td, repo) = repo_init();
792		let git_root = repo.path().to_path_buf();
793
794		let hook =
795			HookPaths::new(&repo, None, HOOK_POST_COMMIT).unwrap();
796
797		assert_eq!(hook.pwd, git_root.parent().unwrap());
798	}
799
800	#[test]
801	fn test_hooks_prep_commit_msg_success() {
802		let (_td, repo) = repo_init();
803
804		let hook = b"#!/bin/sh
805echo \"msg:$2\" > \"$1\"
806exit 0
807        ";
808
809		create_hook(&repo, HOOK_PREPARE_COMMIT_MSG, hook);
810
811		let mut msg = String::from("test");
812		let res = hooks_prepare_commit_msg(
813			&repo,
814			None,
815			PrepareCommitMsgSource::Message,
816			&mut msg,
817		)
818		.unwrap();
819
820		assert!(res.is_successful());
821		assert_eq!(msg, String::from("msg:message\n"));
822	}
823
824	#[test]
825	fn test_hooks_prep_commit_msg_reject() {
826		let (_td, repo) = repo_init();
827
828		let hook = b"#!/bin/sh
829echo \"$2,$3\" > \"$1\"
830echo 'rejected'
831exit 2
832        ";
833
834		create_hook(&repo, HOOK_PREPARE_COMMIT_MSG, hook);
835
836		let mut msg = String::from("test");
837		let res = hooks_prepare_commit_msg(
838			&repo,
839			None,
840			PrepareCommitMsgSource::Commit(git2::Oid::zero()),
841			&mut msg,
842		)
843		.unwrap();
844
845		let HookResult::Run(response) = res else {
846			unreachable!()
847		};
848
849		assert_eq!(response.code, 2);
850		assert_eq!(&response.stdout, "rejected\n");
851
852		assert_eq!(
853			msg,
854			String::from(
855				"commit,0000000000000000000000000000000000000000\n"
856			)
857		);
858	}
859
860	#[test]
861	fn test_pre_push_sh() {
862		let (_td, repo) = repo_init();
863
864		let hook = b"#!/bin/sh
865exit 0
866	";
867
868		create_hook(&repo, HOOK_PRE_PUSH, hook);
869
870		let branch = head_branch(&repo);
871		let updates = [branch_update(
872			&repo,
873			Some("origin"),
874			&branch,
875			None,
876			false,
877		)];
878
879		let res = hooks_pre_push(
880			&repo,
881			None,
882			Some("origin"),
883			"https://example.com/repo.git",
884			&updates,
885		)
886		.unwrap();
887
888		assert!(res.is_successful());
889	}
890
891	#[test]
892	fn test_pre_push_fail_sh() {
893		let (_td, repo) = repo_init();
894
895		let hook = b"#!/bin/sh
896echo 'failed'
897exit 3
898	";
899		create_hook(&repo, HOOK_PRE_PUSH, hook);
900
901		let branch = head_branch(&repo);
902		let updates = [branch_update(
903			&repo,
904			Some("origin"),
905			&branch,
906			None,
907			false,
908		)];
909
910		let res = hooks_pre_push(
911			&repo,
912			None,
913			Some("origin"),
914			"https://example.com/repo.git",
915			&updates,
916		)
917		.unwrap();
918		let HookResult::Run(response) = res else {
919			unreachable!()
920		};
921		assert_eq!(response.code, 3);
922		assert_eq!(&response.stdout, "failed\n");
923	}
924
925	#[test]
926	fn test_pre_push_no_remote_name() {
927		let (_td, repo) = repo_init();
928
929		let hook = b"#!/bin/sh
930# Verify that when remote is None, URL is passed for both arguments
931echo \"arg1=$1 arg2=$2\"
932exit 0
933	";
934
935		create_hook(&repo, HOOK_PRE_PUSH, hook);
936
937		let branch = head_branch(&repo);
938		let updates =
939			[branch_update(&repo, None, &branch, None, false)];
940
941		let res = hooks_pre_push(
942			&repo,
943			None,
944			None,
945			"https://example.com/repo.git",
946			&updates,
947		)
948		.unwrap();
949
950		let HookResult::Run(response) = res else {
951			panic!("Expected Run result, got: {res:?}");
952		};
953
954		assert!(response.is_successful());
955		// When remote is None, URL should be passed for both arguments
956		assert_eq!(
957			response.stdout,
958			"arg1=https://example.com/repo.git arg2=https://example.com/repo.git\n"
959		);
960	}
961
962	#[test]
963	fn test_pre_push_with_arguments() {
964		let (_td, repo) = repo_init();
965
966		let hook = b"#!/bin/sh
967echo \"remote_name=$1\"
968echo \"remote_url=$2\"
969exit 0
970	";
971
972		create_hook(&repo, HOOK_PRE_PUSH, hook);
973
974		let branch = head_branch(&repo);
975		let updates = [branch_update(
976			&repo,
977			Some("origin"),
978			&branch,
979			None,
980			false,
981		)];
982
983		let res = hooks_pre_push(
984			&repo,
985			None,
986			Some("origin"),
987			"https://example.com/repo.git",
988			&updates,
989		)
990		.unwrap();
991
992		let HookResult::Run(response) = res else {
993			unreachable!("Expected Run result, got: {res:?}")
994		};
995
996		assert!(response.is_successful());
997		assert_eq!(
998			response.stdout,
999			"remote_name=origin\nremote_url=https://example.com/repo.git\n"
1000		);
1001	}
1002
1003	#[test]
1004	fn test_pre_push_multiple_updates() {
1005		let (_td, repo) = repo_init();
1006
1007		let hook = b"#!/bin/sh
1008cat
1009exit 0
1010	";
1011
1012		create_hook(&repo, HOOK_PRE_PUSH, hook);
1013
1014		let branch = head_branch(&repo);
1015		let branch_update = branch_update(
1016			&repo,
1017			Some("origin"),
1018			&branch,
1019			None,
1020			false,
1021		);
1022
1023		// create a tag to add a second refspec
1024		let head_commit =
1025			repo.head().unwrap().peel_to_commit().unwrap();
1026		repo.tag_lightweight("v1", head_commit.as_object(), false)
1027			.unwrap();
1028		let tag_ref = repo.find_reference("refs/tags/v1").unwrap();
1029		let tag_oid = tag_ref.target().unwrap();
1030		let tag_update = PrePushRef::new(
1031			"refs/tags/v1",
1032			Some(tag_oid),
1033			"refs/tags/v1",
1034			None,
1035		);
1036
1037		let updates = [branch_update, tag_update];
1038		let expected_stdin = PrePushRef::to_stdin(&updates);
1039
1040		let res = hooks_pre_push(
1041			&repo,
1042			None,
1043			Some("origin"),
1044			"https://example.com/repo.git",
1045			&updates,
1046		)
1047		.unwrap();
1048
1049		let HookResult::Run(response) = res else {
1050			unreachable!("Expected Run result, got: {res:?}")
1051		};
1052
1053		assert!(
1054			response.is_successful(),
1055			"Hook should succeed: stdout {} stderr {}",
1056			response.stdout,
1057			response.stderr
1058		);
1059		assert_eq!(
1060			response.stdout, expected_stdin,
1061			"stdin should include all refspec lines"
1062		);
1063	}
1064
1065	#[test]
1066	fn test_pre_push_delete_ref_uses_zero_oid() {
1067		let (_td, repo) = repo_init();
1068
1069		let hook = b"#!/bin/sh
1070cat
1071exit 0
1072	";
1073
1074		create_hook(&repo, HOOK_PRE_PUSH, hook);
1075
1076		let branch = head_branch(&repo);
1077		let updates = [branch_update(
1078			&repo,
1079			Some("origin"),
1080			&branch,
1081			None,
1082			true,
1083		)];
1084		let expected_stdin = PrePushRef::to_stdin(&updates);
1085
1086		let res = hooks_pre_push(
1087			&repo,
1088			None,
1089			Some("origin"),
1090			"https://example.com/repo.git",
1091			&updates,
1092		)
1093		.unwrap();
1094
1095		let HookResult::Run(response) = res else {
1096			unreachable!("Expected Run result, got: {res:?}")
1097		};
1098
1099		assert!(response.is_successful());
1100		assert_eq!(response.stdout, expected_stdin);
1101	}
1102
1103	#[test]
1104	fn test_pre_push_stdin() {
1105		let (_td, repo) = repo_init();
1106
1107		let hook = b"#!/bin/sh
1108cat
1109exit 0
1110		";
1111
1112		create_hook(&repo, HOOK_PRE_PUSH, hook);
1113
1114		let branch = head_branch(&repo);
1115		let updates = [branch_update(
1116			&repo,
1117			Some("origin"),
1118			&branch,
1119			None,
1120			false,
1121		)];
1122		let expected_stdin = PrePushRef::to_stdin(&updates);
1123
1124		let res = hooks_pre_push(
1125			&repo,
1126			None,
1127			Some("origin"),
1128			"https://github.com/user/repo.git",
1129			&updates,
1130		)
1131		.unwrap();
1132
1133		let HookResult::Run(response) = res else {
1134			unreachable!("Expected Run result, got: {res:?}")
1135		};
1136
1137		assert!(response.is_successful());
1138		assert_eq!(response.stdout, expected_stdin);
1139	}
1140
1141	#[test]
1142	fn test_pre_push_uses_push_target_remote_not_upstream() {
1143		let (_td, repo) = repo_init();
1144
1145		// repo_init() already creates an initial commit on master
1146		let head = repo.head().unwrap();
1147		let local_commit = head.target().unwrap();
1148
1149		// Set up scenario:
1150		// - Local master is at local_commit (latest)
1151		// - origin/master exists at local_commit (fully synced - upstream)
1152		// - backup/master exists at old_commit (behind/different)
1153		// - Branch tracks origin/master as upstream
1154		// - We push to "backup" remote
1155		// - Expected: remote SHA should be old_commit (not origin/master)
1156
1157		// Create origin/master tracking branch (at same commit as local)
1158		repo.reference(
1159			"refs/remotes/origin/master",
1160			local_commit,
1161			true,
1162			"create origin/master",
1163		)
1164		.unwrap();
1165
1166		// Create backup/master at a different commit
1167		let sig = repo.signature().unwrap();
1168		let tree_id = {
1169			let mut index = repo.index().unwrap();
1170			index.write_tree().unwrap()
1171		};
1172		let tree = repo.find_tree(tree_id).unwrap();
1173		let old_commit = repo
1174			.commit(None, &sig, &sig, "old backup commit", &tree, &[])
1175			.unwrap();
1176
1177		repo.reference(
1178			"refs/remotes/backup/master",
1179			old_commit,
1180			true,
1181			"create backup/master at old commit",
1182		)
1183		.unwrap();
1184
1185		// Configure upstream to origin
1186		{
1187			let mut config = repo.config().unwrap();
1188			config.set_str("branch.master.remote", "origin").unwrap();
1189			config
1190				.set_str("branch.master.merge", "refs/heads/master")
1191				.unwrap();
1192		}
1193
1194		let hook = b"#!/bin/sh
1195cat
1196exit 0
1197";
1198
1199		create_hook(&repo, HOOK_PRE_PUSH, hook);
1200
1201		let branch = head_branch(&repo);
1202		let updates = [branch_update(
1203			&repo,
1204			Some("backup"),
1205			&branch,
1206			None,
1207			false,
1208		)];
1209		let expected_stdin = PrePushRef::to_stdin(&updates);
1210
1211		let res = hooks_pre_push(
1212			&repo,
1213			None,
1214			Some("backup"),
1215			"https://github.com/user/backup-repo.git",
1216			&updates,
1217		)
1218		.unwrap();
1219
1220		let HookResult::Run(response) = res else {
1221			panic!("Expected Run result, got: {res:?}")
1222		};
1223
1224		assert!(response.is_successful());
1225		assert_eq!(response.stdout, expected_stdin);
1226	}
1227}