1#![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 NoHookFound,
54 Ok {
56 hook: PathBuf,
58 },
59 RunNotSuccessful {
61 code: Option<i32>,
63 stdout: String,
65 stderr: String,
67 hook: PathBuf,
69 },
70}
71
72impl HookResult {
73 pub const fn is_ok(&self) -> bool {
75 matches!(self, Self::Ok { .. })
76 }
77
78 pub const fn is_not_successful(&self) -> bool {
80 matches!(self, Self::RunNotSuccessful { .. })
81 }
82}
83
84pub 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(&[temp_file
138 .as_os_str()
139 .to_string_lossy()
140 .as_ref()])?;
141
142 msg.clear();
144 File::open(temp_file)?.read_to_string(msg)?;
145
146 Ok(res)
147}
148
149pub 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
163pub 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#[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 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 #[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 #[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}