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| 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 {
255 return PathBuf::from(".git").join("hooks").join(hook_name);
256 }
257 }
258 }
259 hooks_dir.join(hook_name)
260}
261
262fn traditional_hook_candidate(
263 repo: &Repository,
264 hooks_dir: &Path,
265 hook_name: &str,
266) -> Option<PathBuf> {
267 let path = hooks_dir.join(hook_name);
268 if !path.exists() {
269 return None;
270 }
271 let meta = fs::metadata(&path).ok()?;
272 #[cfg(unix)]
273 if meta.permissions().mode() & 0o111 == 0 {
274 let config = ConfigSet::load(Some(&repo.git_dir), true).ok();
275 let show_warning = config
276 .as_ref()
277 .and_then(|c| c.get("advice.ignoredHook"))
278 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
279 .unwrap_or(true);
280 if show_warning {
281 eprintln!(
282 "hint: The '{hook_name}' hook was ignored because it's not set as executable."
283 );
284 eprintln!(
285 "hint: You can disable this warning with `git config set advice.ignoredHook false`."
286 );
287 }
288 return None;
289 }
290 Some(path)
291}
292
293fn resolve_configured_hooks_only(
295 hook_name: &str,
296 config: &ConfigSet,
297) -> Result<Vec<ResolvedHook>, String> {
298 let tables = HookConfigTables::from_config(config);
299 let mut seq = Vec::new();
300 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
301 seq.push(ResolvedHook::Configured { command });
302 }
303 Ok(seq)
304}
305
306fn resolve_hook_sequence(
308 repo: &Repository,
309 hook_name: &str,
310 config: &ConfigSet,
311) -> Result<Vec<ResolvedHook>, String> {
312 let tables = HookConfigTables::from_config(config);
313 let mut seq = Vec::new();
314 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
315 seq.push(ResolvedHook::Configured { command });
316 }
317 let hooks_dir = resolve_hooks_dir_for_config(Some(&repo.git_dir), Some(config));
318 if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
319 let work_dir = repo.work_tree.as_deref().unwrap_or(&repo.git_dir);
320 let argv0 = hook_argv0(repo, &hooks_dir, hook_name, work_dir);
321 seq.push(ResolvedHook::Traditional { path, argv0 });
322 }
323 Ok(seq)
324}
325
326pub fn list_hooks_display_lines(
328 repo: Option<&Repository>,
329 hook_name: &str,
330 config: &ConfigSet,
331) -> Result<Vec<String>, String> {
332 let git_dir = repo.map(|r| r.git_dir.as_path());
333 let tables = HookConfigTables::from_config(config);
334 let mut lines = Vec::new();
335 for (friendly, _) in tables.hooks_for_event(hook_name)? {
336 lines.push(friendly);
337 }
338 if let Some(r) = repo {
339 let hooks_dir = resolve_hooks_dir_for_config(git_dir, Some(config));
340 if traditional_hook_candidate(r, &hooks_dir, hook_name).is_some() {
341 lines.push("hook from hookdir".to_owned());
342 }
343 }
344 Ok(lines)
345}
346
347fn spawn_traditional_hook(
349 argv0: &Path,
350 hook_args: &[&str],
351 cwd: &Path,
352 git_dir: &Path,
353 extra_env: &[(String, String)],
354 stdin_piped: bool,
355 stdout_piped: bool,
356 stderr_piped: bool,
357 use_shell: bool,
358) -> std::io::Result<std::process::Child> {
359 let mut cmd = if use_shell {
360 let mut sh = Command::new("/bin/sh");
361 sh.arg(argv0);
362 sh
363 } else {
364 Command::new(argv0)
365 };
366 cmd.args(hook_args)
367 .current_dir(cwd)
368 .env("GIT_DIR", git_dir)
369 .stdin(stdio_piped(stdin_piped))
370 .stdout(stdio_piped(stdout_piped))
371 .stderr(stdio_piped(stderr_piped));
372 for (k, v) in extra_env {
373 cmd.env(k, v);
374 }
375 match cmd.spawn() {
376 Ok(c) => Ok(c),
377 Err(e) => {
378 #[cfg(unix)]
379 {
380 if !use_shell && is_enoexec(&e) {
381 return spawn_traditional_hook(
382 argv0,
383 hook_args,
384 cwd,
385 git_dir,
386 extra_env,
387 stdin_piped,
388 stdout_piped,
389 stderr_piped,
390 true,
391 );
392 }
393 }
394 Err(e)
395 }
396 }
397}
398
399fn spawn_configured_hook(
401 command: &str,
402 hook_args: &[&str],
403 cwd: &Path,
404 git_dir: Option<&Path>,
405 extra_env: &[(String, String)],
406 stdin_piped: bool,
407 stdout_piped: bool,
408 stderr_piped: bool,
409) -> std::io::Result<std::process::Child> {
410 let mut cmd = Command::new("/bin/sh");
411 cmd.arg("-c")
412 .arg(command)
413 .arg("hook")
414 .args(hook_args)
415 .current_dir(cwd)
416 .stdin(stdio_piped(stdin_piped))
417 .stdout(stdio_piped(stdout_piped))
418 .stderr(stdio_piped(stderr_piped));
419 if let Some(gd) = git_dir {
420 cmd.env("GIT_DIR", gd);
421 }
422 for (k, v) in extra_env {
423 cmd.env(k, v);
424 }
425 cmd.spawn()
426}
427
428fn report_spawn_error(path: &Path, err: &std::io::Error) {
429 let msg = format!("{err}");
430 let p = path.display();
431 if msg.contains("No such file") || msg.contains("not found") {
432 eprintln!("error: cannot exec '{p}': {msg}");
433 } else {
434 eprintln!("error: cannot exec '{p}': {msg}");
435 }
436}
437
438#[derive(Debug)]
440pub enum HookResult {
441 Success,
443 NotFound,
445 Failed(i32),
447}
448
449impl HookResult {
450 #[must_use]
452 pub fn is_ok(&self) -> bool {
453 matches!(self, HookResult::Success | HookResult::NotFound)
454 }
455
456 #[must_use]
458 pub fn was_executed(&self) -> bool {
459 matches!(self, HookResult::Success | HookResult::Failed(_))
460 }
461}
462
463#[derive(Debug, Clone, Default)]
465pub struct RunHookOptions<'a> {
466 pub stdout_to_stderr: bool,
468 pub path_to_stdin: Option<&'a Path>,
470 pub stdin_data: Option<&'a [u8]>,
472 pub env_vars: &'a [(&'a str, &'a str)],
474 pub cwd: Option<&'a Path>,
476 pub commit_env: Option<&'a CommitHookEnv<'a>>,
478}
479
480pub fn run_hook_opts(
488 repo: Option<&Repository>,
489 hook_name: &str,
490 args: &[&str],
491 config: &ConfigSet,
492 opts: RunHookOptions<'_>,
493 mut capture_output: Option<&mut Vec<u8>>,
494) -> Result<HookResult, String> {
495 let seq = match repo {
496 Some(r) => resolve_hook_sequence(r, hook_name, config)?,
497 None => resolve_configured_hooks_only(hook_name, config)?,
498 };
499 if seq.is_empty() {
500 return Ok(HookResult::NotFound);
501 }
502
503 let work_dir: PathBuf = opts.cwd.map_or_else(
504 || match repo {
505 Some(r) => r.work_tree.clone().unwrap_or_else(|| r.git_dir.clone()),
506 None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
507 },
508 Path::to_path_buf,
509 );
510 let work_dir = work_dir.as_path();
511 let git_dir_for_configured = repo.map(|r| r.git_dir.as_path());
512
513 let mut merged_env: Vec<(String, String)> = opts
514 .env_vars
515 .iter()
516 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
517 .collect();
518 if let Some(r) = repo {
519 if let Some(ce) = opts.commit_env {
520 merged_env.extend(build_commit_hook_env(r, work_dir, ce));
521 }
522 }
523
524 for h in &seq {
525 let (stdin_piped, stdin_file) = match opts.path_to_stdin {
526 Some(p) => (true, Some(p.to_path_buf())),
527 None => (opts.stdin_data.is_some(), None),
528 };
529
530 let capture_mode = capture_output.is_some();
531 let (stdout_piped, stderr_piped) = if capture_mode {
532 (true, true)
533 } else if opts.stdout_to_stderr {
534 (true, true)
535 } else {
536 (false, false)
537 };
538
539 let mut child = match h {
540 ResolvedHook::Traditional { path, argv0 } => {
541 let Some(r) = repo else {
542 continue;
543 };
544 let gd = r.git_dir.as_path();
545 let effective_argv0 = path
546 .parent()
547 .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
548 .unwrap_or_else(|| argv0.clone());
549 match spawn_traditional_hook(
550 &effective_argv0,
551 args,
552 work_dir,
553 gd,
554 &merged_env,
555 stdin_piped,
556 stdout_piped,
557 stderr_piped,
558 false,
559 ) {
560 Ok(c) => c,
561 Err(e) => {
562 report_spawn_error(path, &e);
563 return Ok(HookResult::Failed(1));
564 }
565 }
566 }
567 ResolvedHook::Configured { command } => {
568 match spawn_configured_hook(
569 command,
570 args,
571 work_dir,
572 git_dir_for_configured,
573 &merged_env,
574 stdin_piped,
575 stdout_piped,
576 stderr_piped,
577 ) {
578 Ok(c) => c,
579 Err(e) => {
580 eprintln!("error: failed to run configured hook: {e}");
581 return Ok(HookResult::Failed(1));
582 }
583 }
584 }
585 };
586
587 if let Some(ref path) = stdin_file {
588 let file = match fs::File::open(path) {
589 Ok(f) => f,
590 Err(e) => {
591 eprintln!("error: failed to open stdin file {}: {e}", path.display());
592 return Ok(HookResult::Failed(1));
593 }
594 };
595 if let Some(ref mut stdin) = child.stdin {
596 let mut file = file;
597 let _ = std::io::copy(&mut file, stdin);
598 }
599 drop(child.stdin.take());
600 } else if let Some(data) = opts.stdin_data {
601 if let Some(ref mut stdin) = child.stdin {
602 let _ = stdin.write_all(data);
603 }
604 drop(child.stdin.take());
605 }
606
607 let status = if capture_mode {
608 let output = match child.wait_with_output() {
609 Ok(o) => o,
610 Err(_) => return Ok(HookResult::Failed(1)),
611 };
612 if let Some(buf) = capture_output.as_mut() {
613 buf.extend_from_slice(&output.stdout);
614 buf.extend_from_slice(&output.stderr);
615 }
616 output.status
617 } else if opts.stdout_to_stderr {
618 let output = match child.wait_with_output() {
619 Ok(o) => o,
620 Err(_) => return Ok(HookResult::Failed(1)),
621 };
622 let mut stderr = std::io::stderr().lock();
623 let _ = stderr.write_all(&output.stdout);
624 let _ = stderr.write_all(&output.stderr);
625 output.status
626 } else {
627 match child.wait() {
628 Ok(s) => s,
629 Err(_) => return Ok(HookResult::Failed(1)),
630 }
631 };
632
633 if !status.success() {
634 return Ok(HookResult::Failed(status.code().unwrap_or(1)));
635 }
636 }
637
638 Ok(HookResult::Success)
639}
640
641pub fn run_commit_hook(
643 repo: &Repository,
644 hook_name: &str,
645 args: &[&str],
646 stdin_data: Option<&[u8]>,
647 commit_env: &CommitHookEnv<'_>,
648) -> Result<HookResult, String> {
649 let config = ConfigSet::load(Some(&repo.git_dir), true).map_err(|e| format!("{e}"))?;
650 let stdout_to_stderr = hook_name != "pre-push";
651 run_hook_opts(
652 Some(repo),
653 hook_name,
654 args,
655 &config,
656 RunHookOptions {
657 stdout_to_stderr,
658 path_to_stdin: None,
659 stdin_data,
660 env_vars: &[],
661 cwd: None,
662 commit_env: Some(commit_env),
663 },
664 None,
665 )
666}
667
668pub fn run_hook(
672 repo: &Repository,
673 hook_name: &str,
674 args: &[&str],
675 stdin_data: Option<&[u8]>,
676) -> HookResult {
677 let config = match ConfigSet::load(Some(&repo.git_dir), true) {
678 Ok(c) => c,
679 Err(_) => return HookResult::Failed(1),
680 };
681 let stdout_to_stderr = hook_name != "pre-push";
682 match run_hook_opts(
683 Some(repo),
684 hook_name,
685 args,
686 &config,
687 RunHookOptions {
688 stdout_to_stderr,
689 path_to_stdin: None,
690 stdin_data,
691 env_vars: &[],
692 cwd: None,
693 commit_env: None,
694 },
695 None,
696 ) {
697 Ok(r) => r,
698 Err(msg) => {
699 eprintln!("fatal: {msg}");
700 HookResult::Failed(1)
701 }
702 }
703}
704
705pub fn run_hook_in_git_dir(
707 repo: &Repository,
708 hook_name: &str,
709 args: &[&str],
710 stdin_data: Option<&[u8]>,
711 env_vars: &[(&str, &str)],
712) -> (HookResult, Vec<u8>) {
713 let config = match ConfigSet::load(Some(&repo.git_dir), true) {
714 Ok(c) => c,
715 Err(_) => return (HookResult::Failed(1), Vec::new()),
716 };
717 let mut captured = Vec::new();
718 match run_hook_opts(
719 Some(repo),
720 hook_name,
721 args,
722 &config,
723 RunHookOptions {
724 stdout_to_stderr: true,
725 path_to_stdin: None,
726 stdin_data,
727 env_vars,
728 cwd: Some(repo.git_dir.as_path()),
729 commit_env: None,
730 },
731 Some(&mut captured),
732 ) {
733 Ok(r) => (r, captured),
734 Err(_) => (HookResult::Failed(1), captured),
735 }
736}
737
738pub fn run_hook_with_env(
740 repo: &Repository,
741 hook_name: &str,
742 args: &[&str],
743 stdin_data: Option<&[u8]>,
744 env_vars: &[(&str, &str)],
745) -> (HookResult, Vec<u8>) {
746 let config = match ConfigSet::load(Some(&repo.git_dir), true) {
747 Ok(c) => c,
748 Err(_) => return (HookResult::Failed(1), Vec::new()),
749 };
750 let mut captured = Vec::new();
751 match run_hook_opts(
752 Some(repo),
753 hook_name,
754 args,
755 &config,
756 RunHookOptions {
757 stdout_to_stderr: true,
758 path_to_stdin: None,
759 stdin_data,
760 env_vars,
761 cwd: None,
762 commit_env: None,
763 },
764 Some(&mut captured),
765 ) {
766 Ok(r) => (r, captured),
767 Err(_) => (HookResult::Failed(1), captured),
768 }
769}
770
771pub fn run_hook_capture(
772 repo: &Repository,
773 hook_name: &str,
774 args: &[&str],
775 stdin_data: Option<&[u8]>,
776) -> (HookResult, Vec<u8>) {
777 run_hook_with_env(repo, hook_name, args, stdin_data, &[])
778}
779
780#[must_use]
786pub fn run_reference_transaction_committed_for_head_update(
787 repo: &Repository,
788 head: &HeadState,
789 old_head_commit: Option<ObjectId>,
790 new_oid: ObjectId,
791) -> HookResult {
792 let zero = ObjectId::from_bytes(&[0u8; 20]).unwrap();
793 let old_oid = old_head_commit.unwrap_or(zero);
794 let old_hex = if old_oid == zero {
795 "0000000000000000000000000000000000000000".to_owned()
796 } else {
797 old_oid.to_hex()
798 };
799 let new_hex = new_oid.to_hex();
800 let mut stdin = String::new();
801 match head {
802 HeadState::Branch { refname, .. } => {
803 stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
805 stdin.push_str(&format!("{old_hex} {new_hex} {refname}\n"));
806 }
807 _ => {
808 stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
809 }
810 }
811 run_hook(
812 repo,
813 "reference-transaction",
814 &["committed"],
815 Some(stdin.as_bytes()),
816 )
817}