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