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