1#![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 NoHookFound,
55 Ok {
57 hook: PathBuf,
59 },
60 RunNotSuccessful {
62 code: Option<i32>,
64 stdout: String,
66 stderr: String,
68 hook: PathBuf,
70 },
71}
72
73impl HookResult {
74 pub const fn is_ok(&self) -> bool {
76 matches!(self, Self::Ok { .. })
77 }
78
79 pub const fn is_not_successful(&self) -> bool {
81 matches!(self, Self::RunNotSuccessful { .. })
82 }
83}
84
85pub 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 .output()
113 .unwrap();
114 }
115}
116
117pub 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 msg.clear();
141 File::open(temp_file)?.read_to_string(msg)?;
142
143 Ok(res)
144}
145
146pub 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
160pub 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
174pub 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#[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 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 #[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 #[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}