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(
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 msg.clear();
140 File::open(temp_file)?.read_to_string(msg)?;
141
142 Ok(res)
143}
144
145pub 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
159pub 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#[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 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 #[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 #[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}