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