1use crate::command_runner::{
7 git_for_windows_tool_dirs, prepend_to_child_path, system_command_runner, CommandEnvironment,
8 CommandRunner, CommandSpec, CommandStdin, CommandStdio, RunningCommand, ScriptInterpreter,
9 ShellInvocation,
10};
11use crate::config::{parse_path, ConfigSet};
12use crate::git_path::path_without_verbatim_prefix;
13use crate::objects::ObjectId;
14use crate::repo::Repository;
15use crate::state::HeadState;
16use std::collections::{HashMap, HashSet, VecDeque};
17use std::ffi::OsString;
18use std::fs;
19#[cfg(unix)]
20use std::os::unix::fs::PermissionsExt;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use thiserror::Error;
24
25fn is_not_an_executable(err: &std::io::Error) -> bool {
28 #[cfg(unix)]
29 const NOT_EXECUTABLE: i32 = 8;
30 #[cfg(windows)]
31 const NOT_EXECUTABLE: i32 = 193;
32 #[cfg(not(any(unix, windows)))]
33 const NOT_EXECUTABLE: i32 = -1;
34 err.raw_os_error() == Some(NOT_EXECUTABLE)
35}
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42pub enum HookStdio {
43 #[default]
47 Capture,
48 Inherit,
52}
53
54#[derive(Clone, Default, PartialEq, Eq)]
59pub struct HookOutput {
60 pub stdout: Vec<u8>,
62 pub stderr: Vec<u8>,
64}
65
66impl std::fmt::Debug for HookOutput {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("HookOutput")
69 .field("stdout", &String::from_utf8_lossy(&self.stdout))
70 .field("stderr", &String::from_utf8_lossy(&self.stderr))
71 .finish()
72 }
73}
74
75impl HookOutput {
76 #[must_use]
78 pub fn is_empty(&self) -> bool {
79 self.stdout.is_empty() && self.stderr.is_empty()
80 }
81}
82
83#[derive(Debug, Error)]
85pub enum HookError {
86 #[error("hook {hook_name} failed with exit status {status}")]
90 Failed {
91 hook_name: String,
93 status: i32,
95 output: HookOutput,
97 },
98 #[error("failed to run hook {hook_name} ({})", path.display())]
100 Spawn {
101 hook_name: String,
103 path: PathBuf,
105 #[source]
107 source: std::io::Error,
108 output: HookOutput,
112 },
113 #[error("{0}")]
115 Internal(String),
116}
117
118impl HookError {
119 #[must_use]
121 pub fn output(&self) -> Option<&HookOutput> {
122 match self {
123 HookError::Failed { output, .. } | HookError::Spawn { output, .. } => Some(output),
124 HookError::Internal(_) => None,
125 }
126 }
127}
128
129impl From<String> for HookError {
130 fn from(value: String) -> Self {
131 Self::Internal(value)
132 }
133}
134
135fn hook_command_env(git_dir: Option<&Path>, extra_env: &[(String, String)]) -> CommandEnvironment {
136 let mut env = CommandEnvironment::inherit_process_only();
137 if let Some(gd) = git_dir {
138 env.set
139 .push((OsString::from("GIT_DIR"), gd.as_os_str().to_os_string()));
140 }
141 for (k, v) in extra_env {
142 env.set
143 .push((OsString::from(k.as_str()), OsString::from(v.as_str())));
144 }
145 env
146}
147
148#[derive(Debug, Clone, Default)]
150pub struct CommitHookEnv<'a> {
151 pub index_file: Option<&'a Path>,
153 pub git_editor: Option<&'a str>,
155 pub git_prefix: Option<&'a str>,
157 pub extra_env: &'a [(&'a str, &'a str)],
159}
160
161fn absolute_index_path(index_file: &Path, cwd: &Path) -> PathBuf {
162 if index_file.is_absolute() {
163 index_file.to_path_buf()
164 } else {
165 cwd.join(index_file)
166 }
167}
168
169fn git_prefix_for_invocation(repo: &Repository, invocation_cwd: &Path) -> String {
172 let Some(wt) = repo.work_tree() else {
173 return String::new();
174 };
175 if invocation_cwd == repo.git_dir() {
176 return String::new();
177 }
178 let wt_canon = wt.canonicalize().unwrap_or_else(|_| wt.to_path_buf());
179 let wd_canon = invocation_cwd
180 .canonicalize()
181 .unwrap_or_else(|_| invocation_cwd.to_path_buf());
182 let rel = wd_canon.strip_prefix(&wt_canon).ok();
183 let Some(rel) = rel else {
184 return String::new();
185 };
186 let Some(s) = rel.to_str() else {
187 return String::new();
188 };
189 if s.is_empty() {
190 return String::new();
191 }
192 let mut out = s.replace('\\', "/");
193 if !out.ends_with('/') {
194 out.push('/');
195 }
196 out
197}
198
199fn build_commit_hook_env(
200 repo: &Repository,
201 _work_dir: &Path,
202 opts: &CommitHookEnv<'_>,
203) -> Vec<(String, String)> {
204 let mut env: Vec<(String, String)> = Vec::new();
205 if let Some(p) = opts.index_file {
206 env.push((
207 "GIT_INDEX_FILE".to_string(),
208 absolute_index_path(p, &repo.environment().cwd)
209 .to_string_lossy()
210 .into_owned(),
211 ));
212 }
213 let invocation_cwd = repo.environment().discovery_cwd();
214 let prefix = opts
215 .git_prefix
216 .map(|s| s.to_string())
217 .unwrap_or_else(|| git_prefix_for_invocation(repo, &invocation_cwd));
218 env.push(("GIT_PREFIX".to_string(), prefix));
219 if let Some(ed) = opts.git_editor {
220 env.push(("GIT_EDITOR".to_string(), ed.to_string()));
221 }
222 for (k, v) in opts.extra_env {
223 env.push(((*k).to_string(), (*v).to_string()));
224 }
225 env
226}
227
228fn parse_maybe_bool(value: &str) -> Option<bool> {
230 let v = value.trim().to_ascii_lowercase();
231 match v.as_str() {
232 "true" | "yes" | "on" | "1" => Some(true),
233 "false" | "no" | "off" | "0" => Some(false),
234 _ => None,
235 }
236}
237
238fn parse_hook_config_key(key: &str) -> Option<(&str, &str)> {
240 let rest = key.strip_prefix("hook.")?;
241 let (subsection, var) = rest.rsplit_once('.')?;
242 if subsection.is_empty() || var.is_empty() {
243 return None;
244 }
245 Some((subsection, var))
246}
247
248#[derive(Debug, Default)]
250struct HookConfigTables {
251 commands: HashMap<String, String>,
253 event_hooks: HashMap<String, VecDeque<String>>,
255 disabled: HashSet<String>,
256}
257
258impl HookConfigTables {
259 fn apply_entry(&mut self, key: &str, value: Option<&str>) {
260 let Some((hook_name, subkey)) = parse_hook_config_key(key) else {
261 return;
262 };
263 let Some(value) = value else {
264 return;
265 };
266 let hook_name = hook_name.to_string();
267
268 match subkey {
269 "event" => {
270 if value.is_empty() {
271 for hooks in self.event_hooks.values_mut() {
272 hooks.retain(|n| n != &hook_name);
273 }
274 } else {
275 let event = value.to_string();
276 let hooks = self.event_hooks.entry(event).or_default();
277 hooks.retain(|n| n != &hook_name);
278 hooks.push_back(hook_name);
279 }
280 }
281 "command" => {
282 self.commands.insert(hook_name, value.to_string());
283 }
284 "enabled" => match parse_maybe_bool(value) {
285 Some(false) => {
286 self.disabled.insert(hook_name);
287 }
288 Some(true) => {
289 self.disabled.remove(&hook_name);
290 }
291 None => {}
292 },
293 _ => {}
294 }
295 }
296
297 fn from_config(config: &ConfigSet) -> Self {
298 let mut t = Self::default();
299 for e in config.entries() {
300 t.apply_entry(&e.key, e.value.as_deref());
301 }
302 t
303 }
304
305 fn hooks_for_event(&self, event: &str) -> Result<Vec<(String, String)>, String> {
309 let Some(names) = self.event_hooks.get(event) else {
310 return Ok(Vec::new());
311 };
312 let mut out = Vec::new();
313 for name in names {
314 if self.disabled.contains(name) {
315 continue;
316 }
317 let Some(cmd) = self.commands.get(name) else {
318 return Err(format!(
319 "'hook.{name}.command' must be configured or 'hook.{name}.event' must be removed; aborting."
320 ));
321 };
322 out.push((name.clone(), cmd.clone()));
323 }
324 Ok(out)
325 }
326}
327
328#[derive(Debug)]
330enum ResolvedHook {
331 Configured { command: String },
332 Traditional { path: PathBuf, argv0: PathBuf },
333}
334
335pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
340 resolve_hooks_dir_for_config(
341 repo.environment(),
342 Some(repo.git_dir()),
343 repo.config().ok().as_deref(),
344 )
345}
346
347fn resolve_hooks_dir_for_config(
348 env: &crate::environment::Environment,
349 git_dir: Option<&Path>,
350 config: Option<&ConfigSet>,
351) -> PathBuf {
352 let dir = match config.and_then(|cfg| cfg.get("core.hooksPath")) {
353 Some(hooks_path) => {
354 let p = PathBuf::from(parse_path(env, &hooks_path));
355 if p.is_absolute() {
356 p
357 } else {
358 env.cwd.join(p)
359 }
360 }
361 None => git_dir
362 .map(|gd| crate::repo::common_git_dir_for_config(gd).join("hooks"))
363 .unwrap_or_else(|| PathBuf::from("hooks")),
364 };
365 path_without_verbatim_prefix(&dir).into_owned()
366}
367
368fn hook_argv0(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
369 let default_hooks_dir = repo.git_dir().join("hooks");
370 if same_dir(hooks_dir, &default_hooks_dir) {
371 if cwd == repo.git_dir() {
372 return PathBuf::from("hooks").join(hook_name);
373 }
374 if let Some(work_tree) = repo.work_tree() {
375 if cwd == work_tree && same_dir(repo.git_dir(), &work_tree.join(".git")) {
383 return PathBuf::from(".git").join("hooks").join(hook_name);
384 }
385 }
386 }
387 hooks_dir.join(hook_name)
388}
389
390fn same_dir(a: &Path, b: &Path) -> bool {
393 if a == b {
394 return true;
395 }
396 match (a.canonicalize(), b.canonicalize()) {
397 (Ok(ca), Ok(cb)) => ca == cb,
398 _ => false,
399 }
400}
401
402#[cfg_attr(not(unix), allow(unused_variables))]
405fn traditional_hook_candidate(
406 repo: &Repository,
407 hooks_dir: &Path,
408 hook_name: &str,
409) -> Option<PathBuf> {
410 let path = hooks_dir.join(hook_name);
411 if !path.exists() {
412 return None;
413 }
414 let meta = fs::metadata(&path).ok()?;
415 #[cfg(unix)]
416 if meta.permissions().mode() & 0o111 == 0 {
417 let config = repo.config().ok();
418 let show_warning = config
419 .as_deref()
420 .and_then(|c| c.get("advice.ignoredHook"))
421 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
422 .unwrap_or(true);
423 if show_warning {
424 repo.warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
425 hook_name: hook_name.to_string(),
426 hook_path: Some(path.clone()),
427 });
428 }
429 return None;
430 }
431 Some(path)
432}
433
434fn resolve_configured_hooks_only(
436 hook_name: &str,
437 config: &ConfigSet,
438) -> Result<Vec<ResolvedHook>, String> {
439 let tables = HookConfigTables::from_config(config);
440 let mut seq = Vec::new();
441 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
442 seq.push(ResolvedHook::Configured { command });
443 }
444 Ok(seq)
445}
446
447fn resolve_hook_sequence(
449 repo: &Repository,
450 hook_name: &str,
451 config: &ConfigSet,
452) -> Result<Vec<ResolvedHook>, String> {
453 let tables = HookConfigTables::from_config(config);
454 let mut seq = Vec::new();
455 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
456 seq.push(ResolvedHook::Configured { command });
457 }
458 let hooks_dir =
459 resolve_hooks_dir_for_config(repo.environment(), Some(repo.git_dir()), Some(config));
460 if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
461 let work_dir = repo.work_tree().unwrap_or(repo.git_dir());
462 let argv0 = hook_argv0(repo, &hooks_dir, hook_name, work_dir);
463 seq.push(ResolvedHook::Traditional { path, argv0 });
464 }
465 Ok(seq)
466}
467
468#[cfg_attr(not(windows), allow(dead_code))]
473fn shebang_interpreter(head: &[u8]) -> Option<String> {
474 let rest = head.strip_prefix(b"#!")?;
475 let line_len = rest
476 .iter()
477 .position(|&b| b == b'\n' || b == b'\r')
478 .unwrap_or(rest.len());
479 let line = std::str::from_utf8(&rest[..line_len]).ok()?;
480 let basename = |word: &str| word.rsplit(['/', '\\']).next().map(str::to_owned);
481 let mut words = line.split_ascii_whitespace();
482 let mut name = basename(words.next()?)?;
483 if name == "env" {
484 name = basename(words.find(|w| !w.starts_with('-'))?)?;
485 }
486 (!name.is_empty()).then_some(name)
487}
488
489fn strip_verbatim_prefix_from_path_vars(set: &mut [(OsString, OsString)]) {
495 crate::git_path::strip_verbatim_prefix_from_subprocess_path_vars(set);
496}
497
498fn hook_shell(env: &CommandEnvironment) -> ScriptInterpreter {
503 crate::command_runner::shell_interpreter(env)
504}
505
506#[cfg(windows)]
508fn script_shebang_interpreter(
509 script: &Path,
510 env: &CommandEnvironment,
511) -> Option<ScriptInterpreter> {
512 use std::io::Read;
513 let mut head = [0u8; 256];
514 let mut file = fs::File::open(script).ok()?;
515 let n = file.read(&mut head).ok()?;
516 let name = shebang_interpreter(&head[..n])?;
517 Some(crate::command_runner::resolve_script_interpreter(
518 &name,
519 crate::command_runner::child_path_var(env).as_deref(),
520 ))
521}
522
523struct HookSpawnContext<'a> {
525 runner: &'a dyn CommandRunner,
526 cwd: &'a Path,
527 env: &'a CommandEnvironment,
528 stdin_piped: bool,
529 stdout: CommandStdio,
530 stderr: CommandStdio,
531}
532
533impl HookSpawnContext<'_> {
534 fn spec(&self, program: PathBuf, shell: Option<ShellInvocation>, args: &[&str]) -> CommandSpec {
535 CommandSpec {
536 program,
537 args: if shell.is_some() {
538 Vec::new()
539 } else {
540 args.iter().map(OsString::from).collect()
541 },
542 shell,
543 cwd: Some(path_without_verbatim_prefix(self.cwd).into_owned()),
544 env: self.env.clone(),
545 stdin: if self.stdin_piped {
546 CommandStdin::Pipe(Vec::new())
547 } else {
548 CommandStdin::Inherit
549 },
550 stdout: self.stdout,
551 stderr: self.stderr,
552 }
553 }
554
555 fn interpreter_spec(
558 &self,
559 interpreter: ScriptInterpreter,
560 invocation: ShellInvocation,
561 args: &[&str],
562 ) -> CommandSpec {
563 let mut spec = self.spec(interpreter.program, Some(invocation), args);
564 if let Some(root) = &interpreter.git_root {
565 prepend_to_child_path(&mut spec.env, &git_for_windows_tool_dirs(root));
566 }
567 spec
568 }
569
570 fn spawn(&self, spec: &CommandSpec, hook_name: &str) -> Result<RunningCommand, HookError> {
571 self.runner.spawn(spec).map_err(|source| HookError::Spawn {
572 hook_name: hook_name.to_owned(),
573 path: spec.program.clone(),
574 source,
575 output: HookOutput::default(),
576 })
577 }
578
579 #[cfg_attr(not(windows), allow(unused_variables))]
583 fn spawn_traditional(
584 &self,
585 hook_name: &str,
586 script: &Path,
587 argv0: &Path,
588 args: &[&str],
589 ) -> Result<RunningCommand, HookError> {
590 let argv0 = path_without_verbatim_prefix(argv0);
591 let via_shell = |shell: ScriptInterpreter| {
592 let invocation = ShellInvocation::ScriptPath {
593 script_path: argv0.to_path_buf(),
594 args: args.iter().map(OsString::from).collect(),
595 };
596 self.interpreter_spec(shell, invocation, args)
597 };
598 #[cfg(windows)]
599 if let Some(interpreter) = script_shebang_interpreter(script, self.env) {
600 return self.spawn(&via_shell(interpreter), hook_name);
601 }
602 let direct = self.spec(argv0.to_path_buf(), None, args);
603 match self.runner.spawn(&direct) {
604 Err(e) if is_not_an_executable(&e) => {
605 self.spawn(&via_shell(hook_shell(self.env)), hook_name)
606 }
607 Err(source) => Err(HookError::Spawn {
608 hook_name: hook_name.to_owned(),
609 path: direct.program,
610 source,
611 output: HookOutput::default(),
612 }),
613 Ok(child) => Ok(child),
614 }
615 }
616
617 fn spawn_configured(
619 &self,
620 hook_name: &str,
621 command: &str,
622 args: &[&str],
623 ) -> Result<RunningCommand, HookError> {
624 let invocation = ShellInvocation::DashC {
625 script: command.to_owned(),
626 argv0: Some("hook".to_owned()),
627 args: args.iter().map(OsString::from).collect(),
628 };
629 self.spawn(
630 &self.interpreter_spec(hook_shell(self.env), invocation, args),
631 hook_name,
632 )
633 }
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639pub enum HookName {
640 PreCommit,
642 CommitMsg,
644 PostCommit,
646 PostCheckout,
648 PostMerge,
650 PostRewrite,
652}
653
654impl HookName {
655 #[must_use]
657 pub const fn as_str(self) -> &'static str {
658 match self {
659 Self::PreCommit => "pre-commit",
660 Self::CommitMsg => "commit-msg",
661 Self::PostCommit => "post-commit",
662 Self::PostCheckout => "post-checkout",
663 Self::PostMerge => "post-merge",
664 Self::PostRewrite => "post-rewrite",
665 }
666 }
667}
668
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
676pub enum PostOperationHooks {
677 #[default]
679 Run,
680 Skip,
682}
683
684impl PostOperationHooks {
685 #[must_use]
687 pub const fn enabled(self) -> bool {
688 matches!(self, Self::Run)
689 }
690}
691
692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
694pub enum RewriteKind {
695 Amend,
697 Rebase,
699}
700
701impl RewriteKind {
702 #[must_use]
704 pub const fn as_str(self) -> &'static str {
705 match self {
706 Self::Amend => "amend",
707 Self::Rebase => "rebase",
708 }
709 }
710}
711
712pub(crate) fn run_advisory_hook(
720 repo: &Repository,
721 hook: HookName,
722 args: &[&str],
723 stdin_data: Option<&[u8]>,
724 commit_env: Option<&CommitHookEnv<'_>>,
725) -> bool {
726 let result = match commit_env {
727 Some(env) => run_commit_hook(repo, hook.as_str(), args, stdin_data, env),
728 None => run_hook(repo, hook.as_str(), args, stdin_data),
729 };
730 match result {
731 Ok(result) => result.was_executed(),
732 Err(err) => {
733 let mut detail = err.to_string();
734 let mut cause = std::error::Error::source(&err);
735 while let Some(c) = cause {
736 detail.push_str(": ");
737 detail.push_str(&c.to_string());
738 cause = c.source();
739 }
740 repo.warn(crate::diagnostics::Warning::HookNotRun {
741 hook_name: hook.as_str().to_owned(),
742 detail,
743 });
744 false
745 }
746 }
747}
748
749pub fn run_post_checkout(
759 repo: &Repository,
760 old: Option<ObjectId>,
761 new: ObjectId,
762 branch_checkout: bool,
763) -> bool {
764 let old_hex = old
765 .unwrap_or_else(|| ObjectId::null(repo.odb().hash_algo()))
766 .to_hex();
767 let new_hex = new.to_hex();
768 let flag = if branch_checkout { "1" } else { "0" };
769 run_advisory_hook(
770 repo,
771 HookName::PostCheckout,
772 &[old_hex.as_str(), new_hex.as_str(), flag],
773 None,
774 None,
775 )
776}
777
778pub fn run_post_merge(repo: &Repository, squash: bool) -> bool {
783 run_advisory_hook(
784 repo,
785 HookName::PostMerge,
786 &[if squash { "1" } else { "0" }],
787 None,
788 None,
789 )
790}
791
792#[must_use]
794pub fn post_rewrite_stdin(pairs: &[(ObjectId, ObjectId)]) -> Vec<u8> {
795 let mut out = Vec::with_capacity(pairs.len() * 82);
796 for (old, new) in pairs {
797 out.extend_from_slice(old.to_hex().as_bytes());
798 out.push(b' ');
799 out.extend_from_slice(new.to_hex().as_bytes());
800 out.push(b'\n');
801 }
802 out
803}
804
805pub fn run_post_rewrite(
810 repo: &Repository,
811 kind: RewriteKind,
812 pairs: &[(ObjectId, ObjectId)],
813) -> bool {
814 if pairs.is_empty() {
815 return false;
816 }
817 let stdin = post_rewrite_stdin(pairs);
818 run_advisory_hook(
819 repo,
820 HookName::PostRewrite,
821 &[kind.as_str()],
822 Some(&stdin),
823 None,
824 )
825}
826
827#[derive(Debug, Clone, PartialEq, Eq)]
829pub enum HookResult {
830 Success {
832 output: HookOutput,
834 },
835 NotFound,
837 Failed {
839 status: i32,
841 output: HookOutput,
844 },
845}
846
847impl HookResult {
848 #[must_use]
850 pub fn is_ok(&self) -> bool {
851 matches!(self, HookResult::Success { .. } | HookResult::NotFound)
852 }
853
854 #[must_use]
856 pub fn was_executed(&self) -> bool {
857 !matches!(self, HookResult::NotFound)
858 }
859
860 #[must_use]
862 pub fn status(&self) -> Option<i32> {
863 match self {
864 HookResult::Success { .. } => Some(0),
865 HookResult::NotFound => None,
866 HookResult::Failed { status, .. } => Some(*status),
867 }
868 }
869
870 #[must_use]
872 pub fn output(&self) -> Option<&HookOutput> {
873 match self {
874 HookResult::Success { output } | HookResult::Failed { output, .. } => Some(output),
875 HookResult::NotFound => None,
876 }
877 }
878
879 pub fn check(self, hook_name: &str) -> Result<bool, HookError> {
885 match self {
886 HookResult::Success { .. } => Ok(true),
887 HookResult::NotFound => Ok(false),
888 HookResult::Failed { status, output } => Err(HookError::Failed {
889 hook_name: hook_name.to_owned(),
890 status,
891 output,
892 }),
893 }
894 }
895}
896
897#[derive(Debug, Clone, Default)]
899pub struct RunHookOptions<'a> {
900 pub stdio: HookStdio,
904 pub stdout_to_stderr: bool,
907 pub path_to_stdin: Option<&'a Path>,
909 pub stdin_data: Option<&'a [u8]>,
911 pub env_vars: &'a [(&'a str, &'a str)],
913 pub cwd: Option<&'a Path>,
915 pub commit_env: Option<&'a CommitHookEnv<'a>>,
917}
918
919pub fn run_hook_opts(
934 repo: Option<&Repository>,
935 hook_name: &str,
936 args: &[&str],
937 config: &ConfigSet,
938 opts: RunHookOptions<'_>,
939) -> Result<HookResult, HookError> {
940 let seq = match repo {
941 Some(r) => resolve_hook_sequence(r, hook_name, config)?,
942 None => resolve_configured_hooks_only(hook_name, config)?,
943 };
944 if seq.is_empty() {
945 return Ok(HookResult::NotFound);
946 }
947
948 let work_dir: PathBuf = opts.cwd.map_or_else(
949 || match repo {
950 Some(r) => r
951 .work_tree()
952 .map(|p| p.to_path_buf())
953 .unwrap_or_else(|| r.git_dir().to_path_buf()),
954 None => PathBuf::from("."),
955 },
956 Path::to_path_buf,
957 );
958 let work_dir = work_dir.as_path();
959 let git_dir_for_configured = repo.map(|r| r.git_dir());
960 let runner: Arc<dyn CommandRunner> = match repo {
961 Some(r) => r.command_runner(),
962 None => system_command_runner(),
963 };
964
965 let mut merged_env: Vec<(String, String)> = opts
966 .env_vars
967 .iter()
968 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
969 .collect();
970 if let Some(r) = repo {
971 if let Some(ce) = opts.commit_env {
972 merged_env.extend(build_commit_hook_env(r, work_dir, ce));
973 }
974 }
975
976 let mut hook_env = match repo {
977 Some(r) => CommandEnvironment::from_repository_environment(r.environment(), &merged_env),
978 None => hook_command_env(git_dir_for_configured, &merged_env),
979 };
980 if cfg!(windows) {
981 strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
982 }
983
984 let capture = opts.stdio == HookStdio::Capture;
985 let (stdout, stderr) = match opts.stdio {
986 HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
987 HookStdio::Inherit if opts.stdout_to_stderr => {
988 (CommandStdio::ParentStderr, CommandStdio::Inherit)
989 }
990 HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
991 };
992 let mut output = HookOutput::default();
993
994 for h in &seq {
995 let (stdin_piped, stdin_file) = match opts.path_to_stdin {
996 Some(p) => (true, Some(p.to_path_buf())),
997 None => (opts.stdin_data.is_some(), None),
998 };
999
1000 let ctx = HookSpawnContext {
1001 runner: runner.as_ref(),
1002 cwd: work_dir,
1003 env: &hook_env,
1004 stdin_piped,
1005 stdout,
1006 stderr,
1007 };
1008 let spawned = match h {
1009 ResolvedHook::Traditional { path, argv0 } => {
1010 let Some(r) = repo else {
1011 continue;
1012 };
1013 let effective_argv0 = path
1014 .parent()
1015 .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
1016 .unwrap_or_else(|| argv0.clone());
1017 ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
1018 }
1019 ResolvedHook::Configured { command } => ctx.spawn_configured(hook_name, command, args),
1020 };
1021 let mut child = match spawned {
1022 Ok(child) => child,
1023 Err(HookError::Spawn {
1024 hook_name,
1025 path,
1026 source,
1027 ..
1028 }) => {
1029 return Err(HookError::Spawn {
1030 hook_name,
1031 path,
1032 source,
1033 output,
1034 })
1035 }
1036 Err(other) => return Err(other),
1037 };
1038
1039 if let Some(ref path) = stdin_file {
1040 let file = match fs::File::open(path) {
1041 Ok(f) => f,
1042 Err(_e) => {
1043 return Ok(HookResult::Failed { status: 1, output });
1044 }
1045 };
1046 if let Some(stdin) = child.stdin_mut() {
1047 let mut file = file;
1048 let _ = std::io::copy(&mut file, stdin);
1049 }
1050 } else if let Some(data) = opts.stdin_data {
1051 if let Some(stdin) = child.stdin_mut() {
1052 let _ = stdin.write_all(data);
1053 }
1054 }
1055 if stdin_piped {
1056 child.close_stdin();
1057 }
1058
1059 let waited = if capture {
1060 child.wait_with_output().map(|o| {
1061 output.stdout.extend_from_slice(&o.stdout);
1062 output.stderr.extend_from_slice(&o.stderr);
1063 o.status
1064 })
1065 } else {
1066 child.wait()
1067 };
1068 let exit = match waited {
1069 Ok(exit) => exit,
1070 Err(_) => return Ok(HookResult::Failed { status: 1, output }),
1071 };
1072
1073 if !exit.success {
1074 if let Some(r) = repo {
1075 let _ = r.reload_config();
1076 }
1077 return Ok(HookResult::Failed {
1078 status: exit.code.unwrap_or(1),
1079 output,
1080 });
1081 }
1082 }
1083
1084 if let Some(r) = repo {
1085 let _ = r.reload_config();
1086 }
1087
1088 Ok(HookResult::Success { output })
1089}
1090
1091pub fn run_commit_hook_checked(
1098 repo: &Repository,
1099 hook_name: &str,
1100 args: &[&str],
1101 stdin_data: Option<&[u8]>,
1102 commit_env: &CommitHookEnv<'_>,
1103) -> Result<(), HookError> {
1104 run_commit_hook(repo, hook_name, args, stdin_data, commit_env)?
1105 .check(hook_name)
1106 .map(drop)
1107}
1108
1109pub fn run_commit_hook(
1118 repo: &Repository,
1119 hook_name: &str,
1120 args: &[&str],
1121 stdin_data: Option<&[u8]>,
1122 commit_env: &CommitHookEnv<'_>,
1123) -> Result<HookResult, HookError> {
1124 let config = repo
1125 .config()
1126 .map_err(|e| HookError::Internal(e.to_string()))?
1127 .as_ref()
1128 .clone();
1129 run_hook_opts(
1130 Some(repo),
1131 hook_name,
1132 args,
1133 &config,
1134 RunHookOptions {
1135 stdin_data,
1136 commit_env: Some(commit_env),
1137 ..repo_hook_options(repo, hook_name)
1138 },
1139 )
1140}
1141
1142fn repo_hook_options<'a>(repo: &Repository, hook_name: &str) -> RunHookOptions<'a> {
1145 RunHookOptions {
1146 stdio: repo.hook_stdio(),
1147 stdout_to_stderr: hook_name != "pre-push",
1148 ..RunHookOptions::default()
1149 }
1150}
1151
1152pub fn run_hook(
1162 repo: &Repository,
1163 hook_name: &str,
1164 args: &[&str],
1165 stdin_data: Option<&[u8]>,
1166) -> Result<HookResult, HookError> {
1167 let config = repo
1168 .config()
1169 .map_err(|e| HookError::Internal(e.to_string()))?;
1170 run_hook_opts(
1171 Some(repo),
1172 hook_name,
1173 args,
1174 config.as_ref(),
1175 RunHookOptions {
1176 stdin_data,
1177 ..repo_hook_options(repo, hook_name)
1178 },
1179 )
1180}
1181
1182pub fn run_hook_in_git_dir(
1191 repo: &Repository,
1192 hook_name: &str,
1193 args: &[&str],
1194 stdin_data: Option<&[u8]>,
1195 env_vars: &[(&str, &str)],
1196) -> HookResult {
1197 let declined = || HookResult::Failed {
1198 status: 1,
1199 output: HookOutput::default(),
1200 };
1201 let env_vars: Vec<(&str, &str)> = std::iter::once(("GIT_DIR", "."))
1202 .chain(env_vars.iter().copied())
1203 .collect();
1204 let Some(config) = repo.config().ok() else {
1205 return declined();
1206 };
1207 run_hook_opts(
1208 Some(repo),
1209 hook_name,
1210 args,
1211 config.as_ref(),
1212 RunHookOptions {
1213 stdio: HookStdio::Capture,
1214 stdout_to_stderr: true,
1215 stdin_data,
1216 env_vars: &env_vars,
1217 cwd: Some(repo.git_dir()),
1218 ..RunHookOptions::default()
1219 },
1220 )
1221 .unwrap_or_else(|_| declined())
1222}
1223
1224pub fn run_reference_transaction_committed_for_head_update(
1234 repo: &Repository,
1235 head: &HeadState,
1236 old_head_commit: Option<ObjectId>,
1237 new_oid: ObjectId,
1238) -> Result<HookResult, HookError> {
1239 let zero = ObjectId::zero();
1240 let old_oid = old_head_commit.unwrap_or(zero);
1241 let old_hex = if old_oid == zero {
1242 "0000000000000000000000000000000000000000".to_owned()
1243 } else {
1244 old_oid.to_hex()
1245 };
1246 let new_hex = new_oid.to_hex();
1247 let mut stdin = String::new();
1248 match head {
1249 HeadState::Branch { refname, .. } => {
1250 stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
1252 stdin.push_str(&format!("{old_hex} {new_hex} {refname}\n"));
1253 }
1254 _ => {
1255 stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
1256 }
1257 }
1258 run_hook(
1259 repo,
1260 "reference-transaction",
1261 &["committed"],
1262 Some(stdin.as_bytes()),
1263 )
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268 use super::*;
1269
1270 fn touch(path: &Path) {
1271 fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
1272 fs::write(path, b"").expect("write");
1273 }
1274
1275 fn exe(name: &str) -> String {
1276 format!("{name}{}", std::env::consts::EXE_SUFFIX)
1277 }
1278
1279 #[test]
1280 fn hook_result_reports_status_output_and_checks() {
1281 let output = HookOutput {
1282 stdout: b"out".to_vec(),
1283 stderr: b"err".to_vec(),
1284 };
1285 assert!(!output.is_empty());
1286 assert!(HookOutput::default().is_empty());
1287 assert_eq!(HookError::Internal("bad".into()).output(), None);
1288 assert_eq!(
1289 format!("{output:?}"),
1290 r#"HookOutput { stdout: "out", stderr: "err" }"#
1291 );
1292
1293 let success = HookResult::Success {
1294 output: output.clone(),
1295 };
1296 assert!(success.is_ok() && success.was_executed());
1297 assert_eq!(success.status(), Some(0));
1298 assert_eq!(success.output(), Some(&output));
1299 assert!(success.check("pre-commit").expect("success"));
1300
1301 let missing = HookResult::NotFound;
1302 assert!(missing.is_ok() && !missing.was_executed());
1303 assert_eq!((missing.status(), missing.output()), (None, None));
1304 assert!(!missing.check("pre-commit").expect("not found"));
1305
1306 let failed = HookResult::Failed {
1307 status: 3,
1308 output: output.clone(),
1309 };
1310 assert!(!failed.is_ok() && failed.was_executed());
1311 assert_eq!(failed.status(), Some(3));
1312 match failed.check("commit-msg") {
1313 Err(err @ HookError::Failed { .. }) => {
1314 assert_eq!(err.to_string(), "hook commit-msg failed with exit status 3");
1315 let HookError::Failed {
1316 hook_name,
1317 status,
1318 output: got,
1319 } = err
1320 else {
1321 unreachable!()
1322 };
1323 assert_eq!((hook_name.as_str(), status), ("commit-msg", 3));
1324 assert_eq!(got, output);
1325 }
1326 other => panic!("expected HookError::Failed, got {other:?}"),
1327 }
1328 }
1329
1330 #[test]
1331 fn post_rewrite_stdin_is_one_old_new_line_per_pair() {
1332 let a = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("a");
1333 let b = ObjectId::from_hex("2222222222222222222222222222222222222222").expect("b");
1334 assert_eq!(post_rewrite_stdin(&[]), b"");
1335 assert_eq!(
1336 post_rewrite_stdin(&[(a, b), (b, a)]),
1337 format!(
1338 "{} {}\n{} {}\n",
1339 a.to_hex(),
1340 b.to_hex(),
1341 b.to_hex(),
1342 a.to_hex()
1343 )
1344 .into_bytes()
1345 );
1346 }
1347
1348 #[test]
1349 fn post_operation_names_and_flags() {
1350 assert_eq!(HookName::PostCheckout.as_str(), "post-checkout");
1351 assert_eq!(HookName::PostMerge.as_str(), "post-merge");
1352 assert_eq!(HookName::PostRewrite.as_str(), "post-rewrite");
1353 assert_eq!(RewriteKind::Amend.as_str(), "amend");
1354 assert_eq!(RewriteKind::Rebase.as_str(), "rebase");
1355 assert!(PostOperationHooks::default().enabled());
1356 assert!(!PostOperationHooks::Skip.enabled());
1357 }
1358
1359 #[test]
1360 fn shebang_interpreter_takes_the_interpreter_basename() {
1361 assert_eq!(
1362 shebang_interpreter(b"#!/bin/sh\nexit 0\n").as_deref(),
1363 Some("sh")
1364 );
1365 assert_eq!(
1366 shebang_interpreter(b"#!/bin/bash -e\n").as_deref(),
1367 Some("bash")
1368 );
1369 assert_eq!(shebang_interpreter(b"#! /bin/sh\n").as_deref(), Some("sh"));
1370 assert_eq!(
1371 shebang_interpreter(b"#!/bin/sh\r\nexit 0\r\n").as_deref(),
1372 Some("sh")
1373 );
1374 assert_eq!(shebang_interpreter(b"#!/bin/sh").as_deref(), Some("sh"));
1375 assert_eq!(
1376 shebang_interpreter(br"#!C:\tools\perl.exe").as_deref(),
1377 Some("perl.exe")
1378 );
1379 }
1380
1381 #[test]
1382 fn shebang_interpreter_looks_through_env() {
1383 assert_eq!(
1384 shebang_interpreter(b"#!/usr/bin/env bash\n").as_deref(),
1385 Some("bash")
1386 );
1387 assert_eq!(
1388 shebang_interpreter(b"#!/usr/bin/env -S python3 -u\n").as_deref(),
1389 Some("python3")
1390 );
1391 assert_eq!(shebang_interpreter(b"#!/usr/bin/env\n"), None);
1392 }
1393
1394 #[test]
1395 fn shebang_interpreter_rejects_non_scripts() {
1396 assert_eq!(shebang_interpreter(b"exit 0\n"), None);
1397 assert_eq!(shebang_interpreter(b""), None);
1398 assert_eq!(shebang_interpreter(b"#!\n"), None);
1399 assert_eq!(shebang_interpreter(b"#!/\n"), None);
1400 assert_eq!(shebang_interpreter(b"\x7fELF\x02\x01"), None);
1401 }
1402
1403 #[cfg(unix)]
1404 #[test]
1405 fn hook_shell_is_bin_sh_on_unix() {
1406 let mut env = CommandEnvironment::default();
1407 env.set.push(("PATH".into(), "/nonexistent".into()));
1408 assert_eq!(hook_shell(&env), ScriptInterpreter::bare("/bin/sh"));
1409 }
1410
1411 #[test]
1412 fn interpreter_from_git_tree_gets_its_tool_dirs_on_path() {
1413 let tmp = tempfile::tempdir().expect("tempdir");
1414 let root = tmp.path().join("Git");
1415 touch(&root.join("cmd").join(exe("git")));
1416 touch(&root.join("mingw64").join("bin").join(exe("git")));
1417 touch(&root.join("usr").join("bin").join(exe("sh")));
1418 let mut env = CommandEnvironment::default();
1419 env.set
1420 .push(("PATH".into(), root.join("cmd").into_os_string()));
1421 let runner = crate::command_runner::RecordingRunner::always_success();
1422 let ctx = HookSpawnContext {
1423 runner: runner.as_ref(),
1424 cwd: tmp.path(),
1425 env: &env,
1426 stdin_piped: false,
1427 stdout: CommandStdio::Inherit,
1428 stderr: CommandStdio::Inherit,
1429 };
1430 let dash_c = || ShellInvocation::DashC {
1431 script: "true".into(),
1432 argv0: Some("hook".into()),
1433 args: Vec::new(),
1434 };
1435 let interpreter = crate::command_runner::resolve_script_interpreter(
1436 "sh",
1437 Some(root.join("cmd").as_os_str()),
1438 );
1439 let spec = ctx.interpreter_spec(interpreter, dash_c(), &[]);
1440 assert_eq!(spec.program, root.join("usr").join("bin").join(exe("sh")));
1441 let expected = std::env::join_paths([
1442 root.join("mingw64").join("bin"),
1443 root.join("usr").join("bin"),
1444 root.join("cmd"),
1445 ])
1446 .expect("join");
1447 assert_eq!(
1448 crate::command_runner::child_path_var(&spec.env),
1449 Some(expected)
1450 );
1451
1452 let bare = ctx.interpreter_spec(ScriptInterpreter::bare("/bin/sh"), dash_c(), &[]);
1453 assert_eq!(
1454 crate::command_runner::child_path_var(&bare.env),
1455 Some(root.join("cmd").into_os_string()),
1456 "interpreters outside a Git tree keep PATH as is"
1457 );
1458 }
1459
1460 #[test]
1461 fn verbatim_prefixes_are_stripped_from_path_vars() {
1462 let mut set: Vec<(OsString, OsString)> = vec![
1463 ("GIT_INDEX_FILE".into(), r"\\?\C:\repo\.git\index".into()),
1464 ("GIT_DIR".into(), r"\\?\UNC\server\share\repo\.git".into()),
1465 ("GIT_WORK_TREE".into(), r"C:\repo".into()),
1466 (
1467 "GIT_OBJECT_DIRECTORY".into(),
1468 r"\\?\C:\repo\.git\objects".into(),
1469 ),
1470 ("GIT_PREFIX".into(), r"\\?\kept".into()),
1471 ];
1472 strip_verbatim_prefix_from_path_vars(&mut set);
1473 let value = |key: &str| {
1474 set.iter()
1475 .find(|(k, _)| k == key)
1476 .map(|(_, v)| v.clone())
1477 .expect("present")
1478 };
1479 assert_eq!(
1480 value("GIT_INDEX_FILE"),
1481 OsString::from(r"C:\repo\.git\index")
1482 );
1483 assert_eq!(
1484 value("GIT_DIR"),
1485 OsString::from(r"\\server\share\repo\.git")
1486 );
1487 assert_eq!(value("GIT_WORK_TREE"), OsString::from(r"C:\repo"));
1488 assert_eq!(
1489 value("GIT_OBJECT_DIRECTORY"),
1490 OsString::from(r"C:\repo\.git\objects")
1491 );
1492 assert_eq!(value("GIT_PREFIX"), OsString::from(r"\\?\kept"));
1493 }
1494
1495 #[test]
1496 fn not_an_executable_matches_only_the_platform_code() {
1497 #[cfg(unix)]
1498 assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(8)));
1499 #[cfg(windows)]
1500 assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(
1501 193
1502 )));
1503 assert!(!is_not_an_executable(&std::io::Error::from(
1504 std::io::ErrorKind::NotFound
1505 )));
1506 }
1507}