1use crate::config::{parse_path, ConfigSet};
7use crate::objects::ObjectId;
8use crate::repo::Repository;
9use crate::state::HeadState;
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::fs;
12use std::io::Write;
13#[cfg(unix)]
14use std::os::unix::fs::PermissionsExt;
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17
18#[cfg(unix)]
19const ENOEXEC: i32 = 8;
20
21#[cfg(unix)]
22fn is_enoexec(err: &std::io::Error) -> bool {
23 err.raw_os_error() == Some(ENOEXEC)
24}
25
26fn stdio_piped(piped: bool) -> Stdio {
27 if piped {
28 Stdio::piped()
29 } else {
30 Stdio::inherit()
31 }
32}
33
34#[derive(Debug, Clone, Default)]
36pub struct CommitHookEnv<'a> {
37 pub index_file: Option<&'a Path>,
39 pub git_editor: Option<&'a str>,
41 pub git_prefix: Option<&'a str>,
43 pub extra_env: &'a [(&'a str, &'a str)],
45}
46
47fn absolute_index_path(index_file: &Path) -> PathBuf {
48 if index_file.is_absolute() {
49 index_file.to_path_buf()
50 } else if let Ok(cwd) = std::env::current_dir() {
51 cwd.join(index_file)
52 } else {
53 index_file.to_path_buf()
54 }
55}
56
57fn git_prefix_for_invocation(repo: &Repository, invocation_cwd: &Path) -> String {
60 let Some(wt) = repo.work_tree.as_deref() else {
61 return String::new();
62 };
63 if invocation_cwd == repo.git_dir.as_path() {
64 return String::new();
65 }
66 let wt_canon = wt.canonicalize().unwrap_or_else(|_| wt.to_path_buf());
67 let wd_canon = invocation_cwd
68 .canonicalize()
69 .unwrap_or_else(|_| invocation_cwd.to_path_buf());
70 let rel = wd_canon.strip_prefix(&wt_canon).ok();
71 let Some(rel) = rel else {
72 return String::new();
73 };
74 let Some(s) = rel.to_str() else {
75 return String::new();
76 };
77 if s.is_empty() {
78 return String::new();
79 }
80 let mut out = s.replace('\\', "/");
81 if !out.ends_with('/') {
82 out.push('/');
83 }
84 out
85}
86
87fn build_commit_hook_env(
88 repo: &Repository,
89 work_dir: &Path,
90 opts: &CommitHookEnv<'_>,
91) -> Vec<(String, String)> {
92 let mut env: Vec<(String, String)> = Vec::new();
93 if let Some(p) = opts.index_file {
94 env.push((
95 "GIT_INDEX_FILE".to_string(),
96 absolute_index_path(p).to_string_lossy().into_owned(),
97 ));
98 }
99 let invocation_cwd = std::env::current_dir().unwrap_or_else(|_| work_dir.to_path_buf());
100 let prefix = opts
101 .git_prefix
102 .map(|s| s.to_string())
103 .unwrap_or_else(|| git_prefix_for_invocation(repo, &invocation_cwd));
104 env.push(("GIT_PREFIX".to_string(), prefix));
105 if let Some(ed) = opts.git_editor {
106 env.push(("GIT_EDITOR".to_string(), ed.to_string()));
107 }
108 for (k, v) in opts.extra_env {
109 env.push(((*k).to_string(), (*v).to_string()));
110 }
111 env
112}
113
114fn parse_maybe_bool(value: &str) -> Option<bool> {
116 let v = value.trim().to_ascii_lowercase();
117 match v.as_str() {
118 "true" | "yes" | "on" | "1" => Some(true),
119 "false" | "no" | "off" | "0" => Some(false),
120 _ => None,
121 }
122}
123
124fn parse_hook_config_key(key: &str) -> Option<(&str, &str)> {
126 let rest = key.strip_prefix("hook.")?;
127 let (subsection, var) = rest.rsplit_once('.')?;
128 if subsection.is_empty() || var.is_empty() {
129 return None;
130 }
131 Some((subsection, var))
132}
133
134#[derive(Debug, Default)]
136struct HookConfigTables {
137 commands: HashMap<String, String>,
139 event_hooks: HashMap<String, VecDeque<String>>,
141 disabled: HashSet<String>,
142}
143
144impl HookConfigTables {
145 fn apply_entry(&mut self, key: &str, value: Option<&str>) {
146 let Some((hook_name, subkey)) = parse_hook_config_key(key) else {
147 return;
148 };
149 let Some(value) = value else {
150 return;
151 };
152 let hook_name = hook_name.to_string();
153
154 match subkey {
155 "event" => {
156 if value.is_empty() {
157 for hooks in self.event_hooks.values_mut() {
158 hooks.retain(|n| n != &hook_name);
159 }
160 } else {
161 let event = value.to_string();
162 let hooks = self.event_hooks.entry(event).or_default();
163 hooks.retain(|n| n != &hook_name);
164 hooks.push_back(hook_name);
165 }
166 }
167 "command" => {
168 self.commands.insert(hook_name, value.to_string());
169 }
170 "enabled" => match parse_maybe_bool(value) {
171 Some(false) => {
172 self.disabled.insert(hook_name);
173 }
174 Some(true) => {
175 self.disabled.remove(&hook_name);
176 }
177 None => {}
178 },
179 _ => {}
180 }
181 }
182
183 fn from_config(config: &ConfigSet) -> Self {
184 let mut t = Self::default();
185 for e in config.entries() {
186 t.apply_entry(&e.key, e.value.as_deref());
187 }
188 t
189 }
190
191 fn hooks_for_event(&self, event: &str) -> Result<Vec<(String, String)>, String> {
195 let Some(names) = self.event_hooks.get(event) else {
196 return Ok(Vec::new());
197 };
198 let mut out = Vec::new();
199 for name in names {
200 if self.disabled.contains(name) {
201 continue;
202 }
203 let Some(cmd) = self.commands.get(name) else {
204 return Err(format!(
205 "'hook.{name}.command' must be configured or 'hook.{name}.event' must be removed; aborting."
206 ));
207 };
208 out.push((name.clone(), cmd.clone()));
209 }
210 Ok(out)
211 }
212}
213
214#[derive(Debug)]
216enum ResolvedHook {
217 Configured { command: String },
218 Traditional { path: PathBuf, argv0: PathBuf },
219}
220
221pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
223 resolve_hooks_dir_for_config(
224 Some(&repo.git_dir),
225 ConfigSet::load(Some(&repo.git_dir), true).ok().as_ref(),
226 )
227}
228
229fn resolve_hooks_dir_for_config(git_dir: Option<&Path>, config: Option<&ConfigSet>) -> PathBuf {
230 if let Some(cfg) = config {
231 if let Some(hooks_path) = cfg.get("core.hooksPath") {
232 let expanded = parse_path(&hooks_path);
233 let p = PathBuf::from(expanded);
234 if p.is_absolute() {
235 return p;
236 }
237 if let Ok(cwd) = std::env::current_dir() {
238 return cwd.join(p);
239 }
240 }
241 }
242 git_dir
243 .map(|gd| crate::repo::common_git_dir_for_config(gd).join("hooks"))
244 .unwrap_or_else(|| PathBuf::from("hooks"))
245}
246
247fn hook_argv0(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
248 let default_hooks_dir = repo.git_dir.join("hooks");
249 if hooks_dir == default_hooks_dir.as_path() {
250 if cwd == repo.git_dir.as_path() {
251 return PathBuf::from("hooks").join(hook_name);
252 }
253 if let Some(work_tree) = repo.work_tree.as_deref() {
254 if cwd == work_tree && same_dir(repo.git_dir.as_path(), &work_tree.join(".git")) {
262 return PathBuf::from(".git").join("hooks").join(hook_name);
263 }
264 }
265 }
266 hooks_dir.join(hook_name)
267}
268
269fn same_dir(a: &Path, b: &Path) -> bool {
272 if a == b {
273 return true;
274 }
275 match (a.canonicalize(), b.canonicalize()) {
276 (Ok(ca), Ok(cb)) => ca == cb,
277 _ => false,
278 }
279}
280
281#[cfg_attr(not(unix), allow(unused_variables))]
284fn traditional_hook_candidate(
285 repo: &Repository,
286 hooks_dir: &Path,
287 hook_name: &str,
288) -> Option<PathBuf> {
289 let path = hooks_dir.join(hook_name);
290 if !path.exists() {
291 return None;
292 }
293 let meta = fs::metadata(&path).ok()?;
294 #[cfg(unix)]
295 if meta.permissions().mode() & 0o111 == 0 {
296 let config = ConfigSet::load(Some(&repo.git_dir), true).ok();
297 let show_warning = config
298 .as_ref()
299 .and_then(|c| c.get("advice.ignoredHook"))
300 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
301 .unwrap_or(true);
302 if show_warning {
303 eprintln!(
304 "hint: The '{hook_name}' hook was ignored because it's not set as executable."
305 );
306 eprintln!(
307 "hint: You can disable this warning with `git config set advice.ignoredHook false`."
308 );
309 }
310 return None;
311 }
312 Some(path)
313}
314
315fn resolve_configured_hooks_only(
317 hook_name: &str,
318 config: &ConfigSet,
319) -> Result<Vec<ResolvedHook>, String> {
320 let tables = HookConfigTables::from_config(config);
321 let mut seq = Vec::new();
322 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
323 seq.push(ResolvedHook::Configured { command });
324 }
325 Ok(seq)
326}
327
328fn resolve_hook_sequence(
330 repo: &Repository,
331 hook_name: &str,
332 config: &ConfigSet,
333) -> Result<Vec<ResolvedHook>, String> {
334 let tables = HookConfigTables::from_config(config);
335 let mut seq = Vec::new();
336 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
337 seq.push(ResolvedHook::Configured { command });
338 }
339 let hooks_dir = resolve_hooks_dir_for_config(Some(&repo.git_dir), Some(config));
340 if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
341 let work_dir = repo.work_tree.as_deref().unwrap_or(&repo.git_dir);
342 let argv0 = hook_argv0(repo, &hooks_dir, hook_name, work_dir);
343 seq.push(ResolvedHook::Traditional { path, argv0 });
344 }
345 Ok(seq)
346}
347
348pub fn list_hooks_display_lines(
350 repo: Option<&Repository>,
351 hook_name: &str,
352 config: &ConfigSet,
353) -> Result<Vec<String>, String> {
354 let git_dir = repo.map(|r| r.git_dir.as_path());
355 let tables = HookConfigTables::from_config(config);
356 let mut lines = Vec::new();
357 for (friendly, _) in tables.hooks_for_event(hook_name)? {
358 lines.push(friendly);
359 }
360 if let Some(r) = repo {
361 let hooks_dir = resolve_hooks_dir_for_config(git_dir, Some(config));
362 if traditional_hook_candidate(r, &hooks_dir, hook_name).is_some() {
363 lines.push("hook from hookdir".to_owned());
364 }
365 }
366 Ok(lines)
367}
368
369fn spawn_traditional_hook(
371 argv0: &Path,
372 hook_args: &[&str],
373 cwd: &Path,
374 git_dir: &Path,
375 extra_env: &[(String, String)],
376 stdin_piped: bool,
377 stdout_piped: bool,
378 stderr_piped: bool,
379 use_shell: bool,
380) -> std::io::Result<std::process::Child> {
381 let mut cmd = if use_shell {
382 let mut sh = Command::new("/bin/sh");
383 sh.arg(argv0);
384 sh
385 } else {
386 Command::new(argv0)
387 };
388 cmd.args(hook_args)
389 .current_dir(cwd)
390 .env("GIT_DIR", git_dir)
391 .stdin(stdio_piped(stdin_piped))
392 .stdout(stdio_piped(stdout_piped))
393 .stderr(stdio_piped(stderr_piped));
394 for (k, v) in extra_env {
395 cmd.env(k, v);
396 }
397 match cmd.spawn() {
398 Ok(c) => Ok(c),
399 Err(e) => {
400 #[cfg(unix)]
401 {
402 if !use_shell && is_enoexec(&e) {
403 return spawn_traditional_hook(
404 argv0,
405 hook_args,
406 cwd,
407 git_dir,
408 extra_env,
409 stdin_piped,
410 stdout_piped,
411 stderr_piped,
412 true,
413 );
414 }
415 }
416 Err(e)
417 }
418 }
419}
420
421fn spawn_configured_hook(
423 command: &str,
424 hook_args: &[&str],
425 cwd: &Path,
426 git_dir: Option<&Path>,
427 extra_env: &[(String, String)],
428 stdin_piped: bool,
429 stdout_piped: bool,
430 stderr_piped: bool,
431) -> std::io::Result<std::process::Child> {
432 let mut cmd = Command::new("/bin/sh");
433 cmd.arg("-c")
434 .arg(command)
435 .arg("hook")
436 .args(hook_args)
437 .current_dir(cwd)
438 .stdin(stdio_piped(stdin_piped))
439 .stdout(stdio_piped(stdout_piped))
440 .stderr(stdio_piped(stderr_piped));
441 if let Some(gd) = git_dir {
442 cmd.env("GIT_DIR", gd);
443 }
444 for (k, v) in extra_env {
445 cmd.env(k, v);
446 }
447 cmd.spawn()
448}
449
450fn report_spawn_error(path: &Path, err: &std::io::Error) {
451 let msg = format!("{err}");
452 let p = path.display();
453 if msg.contains("No such file") || msg.contains("not found") {
454 eprintln!("error: cannot exec '{p}': {msg}");
455 } else {
456 eprintln!("error: cannot exec '{p}': {msg}");
457 }
458}
459
460#[derive(Debug)]
462pub enum HookResult {
463 Success,
465 NotFound,
467 Failed(i32),
469}
470
471impl HookResult {
472 #[must_use]
474 pub fn is_ok(&self) -> bool {
475 matches!(self, HookResult::Success | HookResult::NotFound)
476 }
477
478 #[must_use]
480 pub fn was_executed(&self) -> bool {
481 matches!(self, HookResult::Success | HookResult::Failed(_))
482 }
483}
484
485#[derive(Debug, Clone, Default)]
487pub struct RunHookOptions<'a> {
488 pub stdout_to_stderr: bool,
490 pub path_to_stdin: Option<&'a Path>,
492 pub stdin_data: Option<&'a [u8]>,
494 pub env_vars: &'a [(&'a str, &'a str)],
496 pub cwd: Option<&'a Path>,
498 pub commit_env: Option<&'a CommitHookEnv<'a>>,
500}
501
502pub fn run_hook_opts(
510 repo: Option<&Repository>,
511 hook_name: &str,
512 args: &[&str],
513 config: &ConfigSet,
514 opts: RunHookOptions<'_>,
515 mut capture_output: Option<&mut Vec<u8>>,
516) -> Result<HookResult, String> {
517 let seq = match repo {
518 Some(r) => resolve_hook_sequence(r, hook_name, config)?,
519 None => resolve_configured_hooks_only(hook_name, config)?,
520 };
521 if seq.is_empty() {
522 return Ok(HookResult::NotFound);
523 }
524
525 let work_dir: PathBuf = opts.cwd.map_or_else(
526 || match repo {
527 Some(r) => r.work_tree.clone().unwrap_or_else(|| r.git_dir.clone()),
528 None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
529 },
530 Path::to_path_buf,
531 );
532 let work_dir = work_dir.as_path();
533 let git_dir_for_configured = repo.map(|r| r.git_dir.as_path());
534
535 let mut merged_env: Vec<(String, String)> = opts
536 .env_vars
537 .iter()
538 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
539 .collect();
540 if let Some(r) = repo {
541 if let Some(ce) = opts.commit_env {
542 merged_env.extend(build_commit_hook_env(r, work_dir, ce));
543 }
544 }
545
546 for h in &seq {
547 let (stdin_piped, stdin_file) = match opts.path_to_stdin {
548 Some(p) => (true, Some(p.to_path_buf())),
549 None => (opts.stdin_data.is_some(), None),
550 };
551
552 let capture_mode = capture_output.is_some();
553 let (stdout_piped, stderr_piped) = if capture_mode {
554 (true, true)
555 } else if opts.stdout_to_stderr {
556 (true, true)
557 } else {
558 (false, false)
559 };
560
561 let mut child = match h {
562 ResolvedHook::Traditional { path, argv0 } => {
563 let Some(r) = repo else {
564 continue;
565 };
566 let gd = r.git_dir.as_path();
567 let effective_argv0 = path
568 .parent()
569 .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
570 .unwrap_or_else(|| argv0.clone());
571 match spawn_traditional_hook(
572 &effective_argv0,
573 args,
574 work_dir,
575 gd,
576 &merged_env,
577 stdin_piped,
578 stdout_piped,
579 stderr_piped,
580 false,
581 ) {
582 Ok(c) => c,
583 Err(e) => {
584 report_spawn_error(path, &e);
585 return Ok(HookResult::Failed(1));
586 }
587 }
588 }
589 ResolvedHook::Configured { command } => {
590 match spawn_configured_hook(
591 command,
592 args,
593 work_dir,
594 git_dir_for_configured,
595 &merged_env,
596 stdin_piped,
597 stdout_piped,
598 stderr_piped,
599 ) {
600 Ok(c) => c,
601 Err(e) => {
602 eprintln!("error: failed to run configured hook: {e}");
603 return Ok(HookResult::Failed(1));
604 }
605 }
606 }
607 };
608
609 if let Some(ref path) = stdin_file {
610 let file = match fs::File::open(path) {
611 Ok(f) => f,
612 Err(e) => {
613 eprintln!("error: failed to open stdin file {}: {e}", path.display());
614 return Ok(HookResult::Failed(1));
615 }
616 };
617 if let Some(ref mut stdin) = child.stdin {
618 let mut file = file;
619 let _ = std::io::copy(&mut file, stdin);
620 }
621 drop(child.stdin.take());
622 } else if let Some(data) = opts.stdin_data {
623 if let Some(ref mut stdin) = child.stdin {
624 let _ = stdin.write_all(data);
625 }
626 drop(child.stdin.take());
627 }
628
629 let status = if capture_mode {
630 let output = match child.wait_with_output() {
631 Ok(o) => o,
632 Err(_) => return Ok(HookResult::Failed(1)),
633 };
634 if let Some(buf) = capture_output.as_mut() {
635 buf.extend_from_slice(&output.stdout);
636 buf.extend_from_slice(&output.stderr);
637 }
638 output.status
639 } else if opts.stdout_to_stderr {
640 let output = match child.wait_with_output() {
641 Ok(o) => o,
642 Err(_) => return Ok(HookResult::Failed(1)),
643 };
644 let mut stderr = std::io::stderr().lock();
645 let _ = stderr.write_all(&output.stdout);
646 let _ = stderr.write_all(&output.stderr);
647 output.status
648 } else {
649 match child.wait() {
650 Ok(s) => s,
651 Err(_) => return Ok(HookResult::Failed(1)),
652 }
653 };
654
655 if !status.success() {
656 return Ok(HookResult::Failed(status.code().unwrap_or(1)));
657 }
658 }
659
660 Ok(HookResult::Success)
661}
662
663pub fn run_commit_hook(
665 repo: &Repository,
666 hook_name: &str,
667 args: &[&str],
668 stdin_data: Option<&[u8]>,
669 commit_env: &CommitHookEnv<'_>,
670) -> Result<HookResult, String> {
671 let config = ConfigSet::load(Some(&repo.git_dir), true).map_err(|e| format!("{e}"))?;
672 let stdout_to_stderr = hook_name != "pre-push";
673 run_hook_opts(
674 Some(repo),
675 hook_name,
676 args,
677 &config,
678 RunHookOptions {
679 stdout_to_stderr,
680 path_to_stdin: None,
681 stdin_data,
682 env_vars: &[],
683 cwd: None,
684 commit_env: Some(commit_env),
685 },
686 None,
687 )
688}
689
690pub fn run_hook(
694 repo: &Repository,
695 hook_name: &str,
696 args: &[&str],
697 stdin_data: Option<&[u8]>,
698) -> HookResult {
699 let config = match ConfigSet::load(Some(&repo.git_dir), true) {
700 Ok(c) => c,
701 Err(_) => return HookResult::Failed(1),
702 };
703 let stdout_to_stderr = hook_name != "pre-push";
704 match run_hook_opts(
705 Some(repo),
706 hook_name,
707 args,
708 &config,
709 RunHookOptions {
710 stdout_to_stderr,
711 path_to_stdin: None,
712 stdin_data,
713 env_vars: &[],
714 cwd: None,
715 commit_env: None,
716 },
717 None,
718 ) {
719 Ok(r) => r,
720 Err(msg) => {
721 eprintln!("fatal: {msg}");
722 HookResult::Failed(1)
723 }
724 }
725}
726
727pub fn run_hook_in_git_dir(
729 repo: &Repository,
730 hook_name: &str,
731 args: &[&str],
732 stdin_data: Option<&[u8]>,
733 env_vars: &[(&str, &str)],
734) -> (HookResult, Vec<u8>) {
735 let config = match ConfigSet::load(Some(&repo.git_dir), true) {
736 Ok(c) => c,
737 Err(_) => return (HookResult::Failed(1), Vec::new()),
738 };
739 let mut captured = Vec::new();
740 match run_hook_opts(
741 Some(repo),
742 hook_name,
743 args,
744 &config,
745 RunHookOptions {
746 stdout_to_stderr: true,
747 path_to_stdin: None,
748 stdin_data,
749 env_vars,
750 cwd: Some(repo.git_dir.as_path()),
751 commit_env: None,
752 },
753 Some(&mut captured),
754 ) {
755 Ok(r) => (r, captured),
756 Err(_) => (HookResult::Failed(1), captured),
757 }
758}
759
760pub fn run_hook_with_env(
762 repo: &Repository,
763 hook_name: &str,
764 args: &[&str],
765 stdin_data: Option<&[u8]>,
766 env_vars: &[(&str, &str)],
767) -> (HookResult, Vec<u8>) {
768 let config = match ConfigSet::load(Some(&repo.git_dir), true) {
769 Ok(c) => c,
770 Err(_) => return (HookResult::Failed(1), Vec::new()),
771 };
772 let mut captured = Vec::new();
773 match run_hook_opts(
774 Some(repo),
775 hook_name,
776 args,
777 &config,
778 RunHookOptions {
779 stdout_to_stderr: true,
780 path_to_stdin: None,
781 stdin_data,
782 env_vars,
783 cwd: None,
784 commit_env: None,
785 },
786 Some(&mut captured),
787 ) {
788 Ok(r) => (r, captured),
789 Err(_) => (HookResult::Failed(1), captured),
790 }
791}
792
793pub fn run_hook_capture(
794 repo: &Repository,
795 hook_name: &str,
796 args: &[&str],
797 stdin_data: Option<&[u8]>,
798) -> (HookResult, Vec<u8>) {
799 run_hook_with_env(repo, hook_name, args, stdin_data, &[])
800}
801
802#[must_use]
808pub fn run_reference_transaction_committed_for_head_update(
809 repo: &Repository,
810 head: &HeadState,
811 old_head_commit: Option<ObjectId>,
812 new_oid: ObjectId,
813) -> HookResult {
814 let zero = ObjectId::zero();
815 let old_oid = old_head_commit.unwrap_or(zero);
816 let old_hex = if old_oid == zero {
817 "0000000000000000000000000000000000000000".to_owned()
818 } else {
819 old_oid.to_hex()
820 };
821 let new_hex = new_oid.to_hex();
822 let mut stdin = String::new();
823 match head {
824 HeadState::Branch { refname, .. } => {
825 stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
827 stdin.push_str(&format!("{old_hex} {new_hex} {refname}\n"));
828 }
829 _ => {
830 stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
831 }
832 }
833 run_hook(
834 repo,
835 "reference-transaction",
836 &["committed"],
837 Some(stdin.as_bytes()),
838 )
839}