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::git_path::CanonicalizeExt;
14use crate::objects::ObjectId;
15use crate::repo::Repository;
16use crate::state::HeadState;
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::ffi::OsString;
19use std::fs;
20#[cfg(unix)]
21use std::os::unix::fs::PermissionsExt;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use thiserror::Error;
25
26fn is_not_an_executable(err: &std::io::Error) -> bool {
29 #[cfg(unix)]
30 const NOT_EXECUTABLE: i32 = 8;
31 #[cfg(windows)]
32 const NOT_EXECUTABLE: i32 = 193;
33 #[cfg(not(any(unix, windows)))]
34 const NOT_EXECUTABLE: i32 = -1;
35 err.raw_os_error() == Some(NOT_EXECUTABLE)
36}
37
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub enum HookStdio {
44 #[default]
48 Capture,
49 Inherit,
53}
54
55#[derive(Clone, Default, PartialEq, Eq)]
60pub struct HookOutput {
61 pub stdout: Vec<u8>,
63 pub stderr: Vec<u8>,
65}
66
67impl std::fmt::Debug for HookOutput {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("HookOutput")
70 .field("stdout", &String::from_utf8_lossy(&self.stdout))
71 .field("stderr", &String::from_utf8_lossy(&self.stderr))
72 .finish()
73 }
74}
75
76impl HookOutput {
77 #[must_use]
79 pub fn is_empty(&self) -> bool {
80 self.stdout.is_empty() && self.stderr.is_empty()
81 }
82}
83
84#[derive(Debug, Error)]
86pub enum HookError {
87 #[error("hook {hook_name} failed with exit status {status}")]
91 Failed {
92 hook_name: String,
94 status: i32,
96 output: HookOutput,
98 },
99 #[error("failed to run hook {hook_name} ({})", path.display())]
101 Spawn {
102 hook_name: String,
104 path: PathBuf,
106 #[source]
108 source: std::io::Error,
109 output: HookOutput,
113 },
114 #[error("{0}")]
116 Internal(String),
117}
118
119impl HookError {
120 #[must_use]
122 pub fn output(&self) -> Option<&HookOutput> {
123 match self {
124 HookError::Failed { output, .. } | HookError::Spawn { output, .. } => Some(output),
125 HookError::Internal(_) => None,
126 }
127 }
128}
129
130impl From<String> for HookError {
131 fn from(value: String) -> Self {
132 Self::Internal(value)
133 }
134}
135
136fn hook_command_env(git_dir: Option<&Path>, extra_env: &[(String, String)]) -> CommandEnvironment {
137 let mut env = CommandEnvironment::inherit_process_only();
138 if let Some(gd) = git_dir {
139 env.set
140 .push((OsString::from("GIT_DIR"), gd.as_os_str().to_os_string()));
141 }
142 for (k, v) in extra_env {
143 env.set
144 .push((OsString::from(k.as_str()), OsString::from(v.as_str())));
145 }
146 env
147}
148
149#[derive(Debug, Clone, Default)]
151pub struct CommitHookEnv<'a> {
152 pub index_file: Option<&'a Path>,
154 pub git_editor: Option<&'a str>,
156 pub git_prefix: Option<&'a str>,
158 pub extra_env: &'a [(&'a str, &'a str)],
160}
161
162fn absolute_index_path(index_file: &Path, cwd: &Path) -> PathBuf {
163 if index_file.is_absolute() {
164 index_file.to_path_buf()
165 } else {
166 cwd.join(index_file)
167 }
168}
169
170fn git_prefix_for_invocation(repo: &Repository, invocation_cwd: &Path) -> String {
173 let Some(wt) = repo.work_tree() else {
174 return String::new();
175 };
176 if invocation_cwd == repo.git_dir() {
177 return String::new();
178 }
179 let wt_canon = wt
180 .canonicalize_simplified()
181 .unwrap_or_else(|_| wt.to_path_buf());
182 let wd_canon = invocation_cwd
183 .canonicalize_simplified()
184 .unwrap_or_else(|_| invocation_cwd.to_path_buf());
185 let rel = wd_canon.strip_prefix(&wt_canon).ok();
186 let Some(rel) = rel else {
187 return String::new();
188 };
189 let Some(s) = rel.to_str() else {
190 return String::new();
191 };
192 if s.is_empty() {
193 return String::new();
194 }
195 let mut out = s.replace('\\', "/");
196 if !out.ends_with('/') {
197 out.push('/');
198 }
199 out
200}
201
202fn build_commit_hook_env(
203 repo: &Repository,
204 _work_dir: &Path,
205 opts: &CommitHookEnv<'_>,
206) -> Vec<(String, String)> {
207 let mut env: Vec<(String, String)> = Vec::new();
208 if let Some(p) = opts.index_file {
209 env.push((
210 "GIT_INDEX_FILE".to_string(),
211 absolute_index_path(p, &repo.environment().cwd)
212 .to_string_lossy()
213 .into_owned(),
214 ));
215 }
216 let invocation_cwd = repo.environment().discovery_cwd();
217 let prefix = opts
218 .git_prefix
219 .map(|s| s.to_string())
220 .unwrap_or_else(|| git_prefix_for_invocation(repo, &invocation_cwd));
221 env.push(("GIT_PREFIX".to_string(), prefix));
222 if let Some(ed) = opts.git_editor {
223 env.push(("GIT_EDITOR".to_string(), ed.to_string()));
224 }
225 for (k, v) in opts.extra_env {
226 env.push(((*k).to_string(), (*v).to_string()));
227 }
228 env
229}
230
231fn parse_maybe_bool(value: &str) -> Option<bool> {
233 let v = value.trim().to_ascii_lowercase();
234 match v.as_str() {
235 "true" | "yes" | "on" | "1" => Some(true),
236 "false" | "no" | "off" | "0" => Some(false),
237 _ => None,
238 }
239}
240
241fn parse_hook_config_key(key: &str) -> Option<(&str, &str)> {
243 let rest = key.strip_prefix("hook.")?;
244 let (subsection, var) = rest.rsplit_once('.')?;
245 if subsection.is_empty() || var.is_empty() {
246 return None;
247 }
248 Some((subsection, var))
249}
250
251#[derive(Debug, Default)]
253struct HookConfigTables {
254 commands: HashMap<String, String>,
256 event_hooks: HashMap<String, VecDeque<String>>,
258 disabled: HashSet<String>,
259}
260
261impl HookConfigTables {
262 fn apply_entry(&mut self, key: &str, value: Option<&str>) {
263 let Some((hook_name, subkey)) = parse_hook_config_key(key) else {
264 return;
265 };
266 let Some(value) = value else {
267 return;
268 };
269 let hook_name = hook_name.to_string();
270
271 match subkey {
272 "event" => {
273 if value.is_empty() {
274 for hooks in self.event_hooks.values_mut() {
275 hooks.retain(|n| n != &hook_name);
276 }
277 } else {
278 let event = value.to_string();
279 let hooks = self.event_hooks.entry(event).or_default();
280 hooks.retain(|n| n != &hook_name);
281 hooks.push_back(hook_name);
282 }
283 }
284 "command" => {
285 self.commands.insert(hook_name, value.to_string());
286 }
287 "enabled" => match parse_maybe_bool(value) {
288 Some(false) => {
289 self.disabled.insert(hook_name);
290 }
291 Some(true) => {
292 self.disabled.remove(&hook_name);
293 }
294 None => {}
295 },
296 _ => {}
297 }
298 }
299
300 fn from_config(config: &ConfigSet) -> Self {
301 let mut t = Self::default();
302 for e in config.entries() {
303 t.apply_entry(&e.key, e.value.as_deref());
304 }
305 t
306 }
307
308 fn hooks_for_event(&self, event: &str) -> Result<Vec<(String, String)>, String> {
312 let Some(names) = self.event_hooks.get(event) else {
313 return Ok(Vec::new());
314 };
315 let mut out = Vec::new();
316 for name in names {
317 if self.disabled.contains(name) {
318 continue;
319 }
320 let Some(cmd) = self.commands.get(name) else {
321 return Err(format!(
322 "'hook.{name}.command' must be configured or 'hook.{name}.event' must be removed; aborting."
323 ));
324 };
325 out.push((name.clone(), cmd.clone()));
326 }
327 Ok(out)
328 }
329}
330
331#[derive(Debug)]
333enum ResolvedHook {
334 Configured { command: String },
335 Traditional { path: PathBuf, argv0: PathBuf },
336}
337
338pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
347 resolve_hooks_dir_for_config(repo, repo.config().ok().as_deref())
348}
349
350fn resolve_hooks_dir_for_config(repo: &Repository, config: Option<&ConfigSet>) -> PathBuf {
351 let dir = match config.and_then(|cfg| cfg.get("core.hooksPath")) {
352 Some(hooks_path) => {
353 let p = PathBuf::from(parse_path(repo.environment(), &hooks_path));
354 if p.is_absolute() {
355 p
356 } else {
357 hook_run_dir(repo).join(p)
358 }
359 }
360 None => crate::repo::common_git_dir_for_config(repo.git_dir()).join("hooks"),
361 };
362 path_without_verbatim_prefix(&dir).into_owned()
363}
364
365fn hook_run_dir(repo: &Repository) -> &Path {
367 repo.work_tree().unwrap_or(repo.git_dir())
368}
369
370fn hook_argv0(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
371 let default_hooks_dir = repo.git_dir().join("hooks");
372 if same_dir(hooks_dir, &default_hooks_dir) {
373 if cwd == repo.git_dir() {
374 return PathBuf::from("hooks").join(hook_name);
375 }
376 if let Some(work_tree) = repo.work_tree() {
377 if cwd == work_tree && same_dir(repo.git_dir(), &work_tree.join(".git")) {
385 return PathBuf::from(".git").join("hooks").join(hook_name);
386 }
387 }
388 }
389 hooks_dir.join(hook_name)
390}
391
392fn same_dir(a: &Path, b: &Path) -> bool {
395 if a == b {
396 return true;
397 }
398 match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
399 (Ok(ca), Ok(cb)) => ca == cb,
400 _ => false,
401 }
402}
403
404#[cfg_attr(not(unix), allow(unused_variables))]
407fn traditional_hook_candidate(
408 repo: &Repository,
409 hooks_dir: &Path,
410 hook_name: &str,
411) -> Option<PathBuf> {
412 let path = hooks_dir.join(hook_name);
413 if !path.exists() {
414 return None;
415 }
416 let meta = fs::metadata(&path).ok()?;
417 #[cfg(unix)]
418 if meta.permissions().mode() & 0o111 == 0 {
419 let config = repo.config().ok();
420 let show_warning = config
421 .as_deref()
422 .and_then(|c| c.get("advice.ignoredHook"))
423 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
424 .unwrap_or(true);
425 if show_warning {
426 repo.warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
427 hook_name: hook_name.to_string(),
428 hook_path: Some(path.clone()),
429 });
430 }
431 return None;
432 }
433 Some(path)
434}
435
436fn resolve_configured_hooks_only(
438 hook_name: &str,
439 config: &ConfigSet,
440) -> Result<Vec<ResolvedHook>, String> {
441 let tables = HookConfigTables::from_config(config);
442 let mut seq = Vec::new();
443 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
444 seq.push(ResolvedHook::Configured { command });
445 }
446 Ok(seq)
447}
448
449fn resolve_hook_sequence(
451 repo: &Repository,
452 hook_name: &str,
453 config: &ConfigSet,
454) -> Result<Vec<ResolvedHook>, String> {
455 let tables = HookConfigTables::from_config(config);
456 let mut seq = Vec::new();
457 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
458 seq.push(ResolvedHook::Configured { command });
459 }
460 let hooks_dir = resolve_hooks_dir_for_config(repo, Some(config));
461 if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
462 let argv0 = hook_argv0(repo, &hooks_dir, hook_name, hook_run_dir(repo));
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)]
639#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
640#[cfg_attr(feature = "serde-models", serde(rename_all = "kebab-case"))]
641pub enum HookName {
642 PreCommit,
644 CommitMsg,
646 PostCommit,
648 PostCheckout,
650 PostMerge,
652 PostRewrite,
654}
655
656impl HookName {
657 #[must_use]
659 pub const fn as_str(self) -> &'static str {
660 match self {
661 Self::PreCommit => "pre-commit",
662 Self::CommitMsg => "commit-msg",
663 Self::PostCommit => "post-commit",
664 Self::PostCheckout => "post-checkout",
665 Self::PostMerge => "post-merge",
666 Self::PostRewrite => "post-rewrite",
667 }
668 }
669}
670
671#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
678pub enum PostOperationHooks {
679 #[default]
681 Run,
682 Skip,
684}
685
686impl PostOperationHooks {
687 #[must_use]
689 pub const fn enabled(self) -> bool {
690 matches!(self, Self::Run)
691 }
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq)]
696pub enum RewriteKind {
697 Amend,
699 Rebase,
701}
702
703impl RewriteKind {
704 #[must_use]
706 pub const fn as_str(self) -> &'static str {
707 match self {
708 Self::Amend => "amend",
709 Self::Rebase => "rebase",
710 }
711 }
712}
713
714pub(crate) fn run_advisory_hook(
722 repo: &Repository,
723 hook: HookName,
724 args: &[&str],
725 stdin_data: Option<&[u8]>,
726 commit_env: Option<&CommitHookEnv<'_>>,
727) -> bool {
728 let result = match commit_env {
729 Some(env) => run_commit_hook(repo, hook.as_str(), args, stdin_data, env),
730 None => run_hook(repo, hook.as_str(), args, stdin_data),
731 };
732 match result {
733 Ok(result) => result.was_executed(),
734 Err(err) => {
735 let mut detail = err.to_string();
736 let mut cause = std::error::Error::source(&err);
737 while let Some(c) = cause {
738 detail.push_str(": ");
739 detail.push_str(&c.to_string());
740 cause = c.source();
741 }
742 repo.warn(crate::diagnostics::Warning::HookNotRun {
743 hook_name: hook.as_str().to_owned(),
744 detail,
745 });
746 false
747 }
748 }
749}
750
751pub fn run_post_checkout(
761 repo: &Repository,
762 old: Option<ObjectId>,
763 new: ObjectId,
764 branch_checkout: bool,
765) -> bool {
766 let old_hex = old
767 .unwrap_or_else(|| ObjectId::null(repo.odb().hash_algo()))
768 .to_hex();
769 let new_hex = new.to_hex();
770 let flag = if branch_checkout { "1" } else { "0" };
771 run_advisory_hook(
772 repo,
773 HookName::PostCheckout,
774 &[old_hex.as_str(), new_hex.as_str(), flag],
775 None,
776 None,
777 )
778}
779
780pub fn run_post_merge(repo: &Repository, squash: bool) -> bool {
785 run_advisory_hook(
786 repo,
787 HookName::PostMerge,
788 &[if squash { "1" } else { "0" }],
789 None,
790 None,
791 )
792}
793
794#[must_use]
796pub fn post_rewrite_stdin(pairs: &[(ObjectId, ObjectId)]) -> Vec<u8> {
797 let mut out = Vec::with_capacity(pairs.len() * 82);
798 for (old, new) in pairs {
799 out.extend_from_slice(old.to_hex().as_bytes());
800 out.push(b' ');
801 out.extend_from_slice(new.to_hex().as_bytes());
802 out.push(b'\n');
803 }
804 out
805}
806
807pub fn run_post_rewrite(
812 repo: &Repository,
813 kind: RewriteKind,
814 pairs: &[(ObjectId, ObjectId)],
815) -> bool {
816 if pairs.is_empty() {
817 return false;
818 }
819 let stdin = post_rewrite_stdin(pairs);
820 run_advisory_hook(
821 repo,
822 HookName::PostRewrite,
823 &[kind.as_str()],
824 Some(&stdin),
825 None,
826 )
827}
828
829#[derive(Debug, Clone, PartialEq, Eq)]
831pub enum HookResult {
832 Success {
834 output: HookOutput,
836 },
837 NotFound,
839 Failed {
841 status: i32,
843 output: HookOutput,
846 },
847}
848
849impl HookResult {
850 #[must_use]
852 pub fn is_ok(&self) -> bool {
853 matches!(self, HookResult::Success { .. } | HookResult::NotFound)
854 }
855
856 #[must_use]
858 pub fn was_executed(&self) -> bool {
859 !matches!(self, HookResult::NotFound)
860 }
861
862 #[must_use]
864 pub fn status(&self) -> Option<i32> {
865 match self {
866 HookResult::Success { .. } => Some(0),
867 HookResult::NotFound => None,
868 HookResult::Failed { status, .. } => Some(*status),
869 }
870 }
871
872 #[must_use]
874 pub fn output(&self) -> Option<&HookOutput> {
875 match self {
876 HookResult::Success { output } | HookResult::Failed { output, .. } => Some(output),
877 HookResult::NotFound => None,
878 }
879 }
880
881 pub fn check(self, hook_name: &str) -> Result<bool, HookError> {
887 match self {
888 HookResult::Success { .. } => Ok(true),
889 HookResult::NotFound => Ok(false),
890 HookResult::Failed { status, output } => Err(HookError::Failed {
891 hook_name: hook_name.to_owned(),
892 status,
893 output,
894 }),
895 }
896 }
897}
898
899#[derive(Debug, Clone, Default)]
901pub struct RunHookOptions<'a> {
902 pub stdio: HookStdio,
906 pub stdout_to_stderr: bool,
909 pub path_to_stdin: Option<&'a Path>,
911 pub stdin_data: Option<&'a [u8]>,
913 pub env_vars: &'a [(&'a str, &'a str)],
915 pub cwd: Option<&'a Path>,
917 pub commit_env: Option<&'a CommitHookEnv<'a>>,
919}
920
921pub fn run_hook_opts(
936 repo: Option<&Repository>,
937 hook_name: &str,
938 args: &[&str],
939 config: &ConfigSet,
940 opts: RunHookOptions<'_>,
941) -> Result<HookResult, HookError> {
942 let seq = match repo {
943 Some(r) => resolve_hook_sequence(r, hook_name, config)?,
944 None => resolve_configured_hooks_only(hook_name, config)?,
945 };
946 if seq.is_empty() {
947 return Ok(HookResult::NotFound);
948 }
949
950 let work_dir: PathBuf = opts.cwd.map_or_else(
951 || match repo {
952 Some(r) => hook_run_dir(r).to_path_buf(),
953 None => PathBuf::from("."),
954 },
955 Path::to_path_buf,
956 );
957 let work_dir = work_dir.as_path();
958 let git_dir_for_configured = repo.map(|r| r.git_dir());
959 let runner: Arc<dyn CommandRunner> = match repo {
960 Some(r) => r.command_runner(),
961 None => system_command_runner(),
962 };
963
964 let mut merged_env: Vec<(String, String)> = opts
965 .env_vars
966 .iter()
967 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
968 .collect();
969 if let Some(r) = repo {
970 if let Some(ce) = opts.commit_env {
971 merged_env.extend(build_commit_hook_env(r, work_dir, ce));
972 }
973 }
974
975 let mut hook_env = match repo {
976 Some(r) => CommandEnvironment::from_repository_environment(r.environment(), &merged_env),
977 None => hook_command_env(git_dir_for_configured, &merged_env),
978 };
979 if cfg!(windows) {
980 strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
981 }
982
983 let capture = opts.stdio == HookStdio::Capture;
984 let (stdout, stderr) = match opts.stdio {
985 HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
986 HookStdio::Inherit if opts.stdout_to_stderr => {
987 (CommandStdio::ParentStderr, CommandStdio::Inherit)
988 }
989 HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
990 };
991 let mut output = HookOutput::default();
992
993 for h in &seq {
994 let (stdin_piped, stdin_file) = match opts.path_to_stdin {
995 Some(p) => (true, Some(p.to_path_buf())),
996 None => (opts.stdin_data.is_some(), None),
997 };
998
999 let ctx = HookSpawnContext {
1000 runner: runner.as_ref(),
1001 cwd: work_dir,
1002 env: &hook_env,
1003 stdin_piped,
1004 stdout,
1005 stderr,
1006 };
1007 let spawned = match h {
1008 ResolvedHook::Traditional { path, argv0 } => {
1009 let Some(r) = repo else {
1010 continue;
1011 };
1012 let effective_argv0 = path
1013 .parent()
1014 .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
1015 .unwrap_or_else(|| argv0.clone());
1016 ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
1017 }
1018 ResolvedHook::Configured { command } => ctx.spawn_configured(hook_name, command, args),
1019 };
1020 let mut child = match spawned {
1021 Ok(child) => child,
1022 Err(HookError::Spawn {
1023 hook_name,
1024 path,
1025 source,
1026 ..
1027 }) => {
1028 return Err(HookError::Spawn {
1029 hook_name,
1030 path,
1031 source,
1032 output,
1033 })
1034 }
1035 Err(other) => return Err(other),
1036 };
1037
1038 if let Some(ref path) = stdin_file {
1039 let file = match fs::File::open(path) {
1040 Ok(f) => f,
1041 Err(_e) => {
1042 return Ok(HookResult::Failed { status: 1, output });
1043 }
1044 };
1045 if let Some(stdin) = child.stdin_mut() {
1046 let mut file = file;
1047 let _ = std::io::copy(&mut file, stdin);
1048 }
1049 } else if let Some(data) = opts.stdin_data {
1050 if let Some(stdin) = child.stdin_mut() {
1051 let _ = stdin.write_all(data);
1052 }
1053 }
1054 if stdin_piped {
1055 child.close_stdin();
1056 }
1057
1058 let waited = if capture {
1059 child.wait_with_output().map(|o| {
1060 output.stdout.extend_from_slice(&o.stdout);
1061 output.stderr.extend_from_slice(&o.stderr);
1062 o.status
1063 })
1064 } else {
1065 child.wait()
1066 };
1067 let exit = match waited {
1068 Ok(exit) => exit,
1069 Err(_) => return Ok(HookResult::Failed { status: 1, output }),
1070 };
1071
1072 if !exit.success {
1073 if let Some(r) = repo {
1074 let _ = r.reload_config();
1075 }
1076 return Ok(HookResult::Failed {
1077 status: exit.code.unwrap_or(1),
1078 output,
1079 });
1080 }
1081 }
1082
1083 if let Some(r) = repo {
1084 let _ = r.reload_config();
1085 }
1086
1087 Ok(HookResult::Success { output })
1088}
1089
1090pub fn run_commit_hook_checked(
1097 repo: &Repository,
1098 hook_name: &str,
1099 args: &[&str],
1100 stdin_data: Option<&[u8]>,
1101 commit_env: &CommitHookEnv<'_>,
1102) -> Result<(), HookError> {
1103 run_commit_hook(repo, hook_name, args, stdin_data, commit_env)?
1104 .check(hook_name)
1105 .map(drop)
1106}
1107
1108pub fn run_commit_hook(
1117 repo: &Repository,
1118 hook_name: &str,
1119 args: &[&str],
1120 stdin_data: Option<&[u8]>,
1121 commit_env: &CommitHookEnv<'_>,
1122) -> Result<HookResult, HookError> {
1123 let config = repo
1124 .config()
1125 .map_err(|e| HookError::Internal(e.to_string()))?
1126 .as_ref()
1127 .clone();
1128 run_hook_opts(
1129 Some(repo),
1130 hook_name,
1131 args,
1132 &config,
1133 RunHookOptions {
1134 stdin_data,
1135 commit_env: Some(commit_env),
1136 ..repo_hook_options(repo, hook_name)
1137 },
1138 )
1139}
1140
1141fn repo_hook_options<'a>(repo: &Repository, hook_name: &str) -> RunHookOptions<'a> {
1144 RunHookOptions {
1145 stdio: repo.hook_stdio(),
1146 stdout_to_stderr: hook_name != "pre-push",
1147 ..RunHookOptions::default()
1148 }
1149}
1150
1151pub fn run_hook(
1161 repo: &Repository,
1162 hook_name: &str,
1163 args: &[&str],
1164 stdin_data: Option<&[u8]>,
1165) -> Result<HookResult, HookError> {
1166 let config = repo
1167 .config()
1168 .map_err(|e| HookError::Internal(e.to_string()))?;
1169 run_hook_opts(
1170 Some(repo),
1171 hook_name,
1172 args,
1173 config.as_ref(),
1174 RunHookOptions {
1175 stdin_data,
1176 ..repo_hook_options(repo, hook_name)
1177 },
1178 )
1179}
1180
1181pub fn run_hook_in_git_dir(
1190 repo: &Repository,
1191 hook_name: &str,
1192 args: &[&str],
1193 stdin_data: Option<&[u8]>,
1194 env_vars: &[(&str, &str)],
1195) -> HookResult {
1196 let declined = || HookResult::Failed {
1197 status: 1,
1198 output: HookOutput::default(),
1199 };
1200 let env_vars: Vec<(&str, &str)> = std::iter::once(("GIT_DIR", "."))
1201 .chain(env_vars.iter().copied())
1202 .collect();
1203 let Some(config) = repo.config().ok() else {
1204 return declined();
1205 };
1206 run_hook_opts(
1207 Some(repo),
1208 hook_name,
1209 args,
1210 config.as_ref(),
1211 RunHookOptions {
1212 stdio: HookStdio::Capture,
1213 stdout_to_stderr: true,
1214 stdin_data,
1215 env_vars: &env_vars,
1216 cwd: Some(repo.git_dir()),
1217 ..RunHookOptions::default()
1218 },
1219 )
1220 .unwrap_or_else(|_| declined())
1221}
1222
1223#[derive(Clone, Debug, PartialEq, Eq)]
1225pub struct PrePushRef {
1226 pub local_ref: Option<String>,
1229 pub local_oid: Option<ObjectId>,
1231 pub remote_ref: String,
1233 pub remote_oid: Option<ObjectId>,
1235}
1236
1237impl PrePushRef {
1238 fn stdin_line(&self, algo: crate::objects::HashAlgo) -> String {
1243 let zero = "0".repeat(algo.hex_len());
1244 let hex = |oid: Option<ObjectId>| oid.map_or_else(|| zero.clone(), |o| o.to_hex());
1245 let local_ref = match (&self.local_ref, self.local_oid) {
1246 (Some(name), _) => name.clone(),
1247 (None, Some(oid)) => oid.to_hex(),
1248 (None, None) => "(delete)".to_owned(),
1249 };
1250 format!(
1251 "{local_ref} {} {} {}\n",
1252 hex(self.local_oid),
1253 self.remote_ref,
1254 hex(self.remote_oid)
1255 )
1256 }
1257}
1258
1259pub fn run_pre_push_hook(
1276 repo: &Repository,
1277 remote: &str,
1278 url: &str,
1279 refs: &[PrePushRef],
1280) -> Result<HookOutput, HookError> {
1281 let config = repo
1282 .config()
1283 .map_err(|e| HookError::Internal(e.to_string()))?;
1284 let algo = repo.odb().hash_algo();
1285 let stdin: String = refs.iter().map(|r| r.stdin_line(algo)).collect();
1286 let result = run_hook_opts(
1287 Some(repo),
1288 "pre-push",
1289 &[remote, url],
1290 config.as_ref(),
1291 RunHookOptions {
1292 stdout_to_stderr: true,
1293 stdin_data: Some(stdin.as_bytes()),
1294 ..repo_hook_options(repo, "pre-push")
1295 },
1296 )?;
1297 let output = match &result {
1298 HookResult::Success { output } => output.clone(),
1299 HookResult::NotFound | HookResult::Failed { .. } => HookOutput::default(),
1300 };
1301 result.check("pre-push")?;
1302 Ok(output)
1303}
1304
1305pub fn run_reference_transaction_committed_for_head_update(
1315 repo: &Repository,
1316 head: &HeadState,
1317 old_head_commit: Option<ObjectId>,
1318 new_oid: ObjectId,
1319) -> Result<HookResult, HookError> {
1320 let zero = ObjectId::zero();
1321 let old_oid = old_head_commit.unwrap_or(zero);
1322 let old_hex = if old_oid == zero {
1323 "0000000000000000000000000000000000000000".to_owned()
1324 } else {
1325 old_oid.to_hex()
1326 };
1327 let new_hex = new_oid.to_hex();
1328 let mut stdin = String::new();
1329 match head {
1330 HeadState::Branch { refname, .. } => {
1331 stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
1333 stdin.push_str(&format!("{old_hex} {new_hex} {refname}\n"));
1334 }
1335 _ => {
1336 stdin.push_str(&format!("{old_hex} {new_hex} HEAD\n"));
1337 }
1338 }
1339 run_hook(
1340 repo,
1341 "reference-transaction",
1342 &["committed"],
1343 Some(stdin.as_bytes()),
1344 )
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349 use super::*;
1350
1351 fn touch(path: &Path) {
1352 fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
1353 fs::write(path, b"").expect("write");
1354 }
1355
1356 fn exe(name: &str) -> String {
1357 format!("{name}{}", std::env::consts::EXE_SUFFIX)
1358 }
1359
1360 #[test]
1361 fn hook_result_reports_status_output_and_checks() {
1362 let output = HookOutput {
1363 stdout: b"out".to_vec(),
1364 stderr: b"err".to_vec(),
1365 };
1366 assert!(!output.is_empty());
1367 assert!(HookOutput::default().is_empty());
1368 assert_eq!(HookError::Internal("bad".into()).output(), None);
1369 assert_eq!(
1370 format!("{output:?}"),
1371 r#"HookOutput { stdout: "out", stderr: "err" }"#
1372 );
1373
1374 let success = HookResult::Success {
1375 output: output.clone(),
1376 };
1377 assert!(success.is_ok() && success.was_executed());
1378 assert_eq!(success.status(), Some(0));
1379 assert_eq!(success.output(), Some(&output));
1380 assert!(success.check("pre-commit").expect("success"));
1381
1382 let missing = HookResult::NotFound;
1383 assert!(missing.is_ok() && !missing.was_executed());
1384 assert_eq!((missing.status(), missing.output()), (None, None));
1385 assert!(!missing.check("pre-commit").expect("not found"));
1386
1387 let failed = HookResult::Failed {
1388 status: 3,
1389 output: output.clone(),
1390 };
1391 assert!(!failed.is_ok() && failed.was_executed());
1392 assert_eq!(failed.status(), Some(3));
1393 match failed.check("commit-msg") {
1394 Err(err @ HookError::Failed { .. }) => {
1395 assert_eq!(err.to_string(), "hook commit-msg failed with exit status 3");
1396 let HookError::Failed {
1397 hook_name,
1398 status,
1399 output: got,
1400 } = err
1401 else {
1402 unreachable!()
1403 };
1404 assert_eq!((hook_name.as_str(), status), ("commit-msg", 3));
1405 assert_eq!(got, output);
1406 }
1407 other => panic!("expected HookError::Failed, got {other:?}"),
1408 }
1409 }
1410
1411 #[test]
1412 fn post_rewrite_stdin_is_one_old_new_line_per_pair() {
1413 let a = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("a");
1414 let b = ObjectId::from_hex("2222222222222222222222222222222222222222").expect("b");
1415 assert_eq!(post_rewrite_stdin(&[]), b"");
1416 assert_eq!(
1417 post_rewrite_stdin(&[(a, b), (b, a)]),
1418 format!(
1419 "{} {}\n{} {}\n",
1420 a.to_hex(),
1421 b.to_hex(),
1422 b.to_hex(),
1423 a.to_hex()
1424 )
1425 .into_bytes()
1426 );
1427 }
1428
1429 #[test]
1430 fn post_operation_names_and_flags() {
1431 assert_eq!(HookName::PostCheckout.as_str(), "post-checkout");
1432 assert_eq!(HookName::PostMerge.as_str(), "post-merge");
1433 assert_eq!(HookName::PostRewrite.as_str(), "post-rewrite");
1434 assert_eq!(RewriteKind::Amend.as_str(), "amend");
1435 assert_eq!(RewriteKind::Rebase.as_str(), "rebase");
1436 assert!(PostOperationHooks::default().enabled());
1437 assert!(!PostOperationHooks::Skip.enabled());
1438 }
1439
1440 #[test]
1441 fn shebang_interpreter_takes_the_interpreter_basename() {
1442 assert_eq!(
1443 shebang_interpreter(b"#!/bin/sh\nexit 0\n").as_deref(),
1444 Some("sh")
1445 );
1446 assert_eq!(
1447 shebang_interpreter(b"#!/bin/bash -e\n").as_deref(),
1448 Some("bash")
1449 );
1450 assert_eq!(shebang_interpreter(b"#! /bin/sh\n").as_deref(), Some("sh"));
1451 assert_eq!(
1452 shebang_interpreter(b"#!/bin/sh\r\nexit 0\r\n").as_deref(),
1453 Some("sh")
1454 );
1455 assert_eq!(shebang_interpreter(b"#!/bin/sh").as_deref(), Some("sh"));
1456 assert_eq!(
1457 shebang_interpreter(br"#!C:\tools\perl.exe").as_deref(),
1458 Some("perl.exe")
1459 );
1460 }
1461
1462 #[test]
1463 fn shebang_interpreter_looks_through_env() {
1464 assert_eq!(
1465 shebang_interpreter(b"#!/usr/bin/env bash\n").as_deref(),
1466 Some("bash")
1467 );
1468 assert_eq!(
1469 shebang_interpreter(b"#!/usr/bin/env -S python3 -u\n").as_deref(),
1470 Some("python3")
1471 );
1472 assert_eq!(shebang_interpreter(b"#!/usr/bin/env\n"), None);
1473 }
1474
1475 #[test]
1476 fn shebang_interpreter_rejects_non_scripts() {
1477 assert_eq!(shebang_interpreter(b"exit 0\n"), None);
1478 assert_eq!(shebang_interpreter(b""), None);
1479 assert_eq!(shebang_interpreter(b"#!\n"), None);
1480 assert_eq!(shebang_interpreter(b"#!/\n"), None);
1481 assert_eq!(shebang_interpreter(b"\x7fELF\x02\x01"), None);
1482 }
1483
1484 #[cfg(unix)]
1485 #[test]
1486 fn hook_shell_is_bin_sh_on_unix() {
1487 let mut env = CommandEnvironment::default();
1488 env.set.push(("PATH".into(), "/nonexistent".into()));
1489 assert_eq!(hook_shell(&env), ScriptInterpreter::bare("/bin/sh"));
1490 }
1491
1492 #[test]
1493 fn interpreter_from_git_tree_gets_its_tool_dirs_on_path() {
1494 let tmp = tempfile::tempdir().expect("tempdir");
1495 let root = tmp.path().join("Git");
1496 touch(&root.join("cmd").join(exe("git")));
1497 touch(&root.join("mingw64").join("bin").join(exe("git")));
1498 touch(&root.join("usr").join("bin").join(exe("sh")));
1499 let mut env = CommandEnvironment::default();
1500 env.set
1501 .push(("PATH".into(), root.join("cmd").into_os_string()));
1502 let runner = crate::command_runner::RecordingRunner::always_success();
1503 let ctx = HookSpawnContext {
1504 runner: runner.as_ref(),
1505 cwd: tmp.path(),
1506 env: &env,
1507 stdin_piped: false,
1508 stdout: CommandStdio::Inherit,
1509 stderr: CommandStdio::Inherit,
1510 };
1511 let dash_c = || ShellInvocation::DashC {
1512 script: "true".into(),
1513 argv0: Some("hook".into()),
1514 args: Vec::new(),
1515 };
1516 let interpreter = crate::command_runner::resolve_script_interpreter(
1517 "sh",
1518 Some(root.join("cmd").as_os_str()),
1519 );
1520 let spec = ctx.interpreter_spec(interpreter, dash_c(), &[]);
1521 assert_eq!(spec.program, root.join("usr").join("bin").join(exe("sh")));
1522 let expected = std::env::join_paths([
1523 root.join("mingw64").join("bin"),
1524 root.join("usr").join("bin"),
1525 root.join("cmd"),
1526 ])
1527 .expect("join");
1528 assert_eq!(
1529 crate::command_runner::child_path_var(&spec.env),
1530 Some(expected)
1531 );
1532
1533 let bare = ctx.interpreter_spec(ScriptInterpreter::bare("/bin/sh"), dash_c(), &[]);
1534 assert_eq!(
1535 crate::command_runner::child_path_var(&bare.env),
1536 Some(root.join("cmd").into_os_string()),
1537 "interpreters outside a Git tree keep PATH as is"
1538 );
1539 }
1540
1541 #[test]
1542 fn verbatim_prefixes_are_stripped_from_path_vars() {
1543 let mut set: Vec<(OsString, OsString)> = vec![
1544 ("GIT_INDEX_FILE".into(), r"\\?\C:\repo\.git\index".into()),
1545 ("GIT_DIR".into(), r"\\?\UNC\server\share\repo\.git".into()),
1546 ("GIT_WORK_TREE".into(), r"C:\repo".into()),
1547 (
1548 "GIT_OBJECT_DIRECTORY".into(),
1549 r"\\?\C:\repo\.git\objects".into(),
1550 ),
1551 ("GIT_PREFIX".into(), r"\\?\kept".into()),
1552 ];
1553 strip_verbatim_prefix_from_path_vars(&mut set);
1554 let value = |key: &str| {
1555 set.iter()
1556 .find(|(k, _)| k == key)
1557 .map(|(_, v)| v.clone())
1558 .expect("present")
1559 };
1560 assert_eq!(
1561 value("GIT_INDEX_FILE"),
1562 OsString::from(r"C:\repo\.git\index")
1563 );
1564 assert_eq!(
1565 value("GIT_DIR"),
1566 OsString::from(r"\\server\share\repo\.git")
1567 );
1568 assert_eq!(value("GIT_WORK_TREE"), OsString::from(r"C:\repo"));
1569 assert_eq!(
1570 value("GIT_OBJECT_DIRECTORY"),
1571 OsString::from(r"C:\repo\.git\objects")
1572 );
1573 assert_eq!(value("GIT_PREFIX"), OsString::from(r"\\?\kept"));
1574 }
1575
1576 #[test]
1577 fn not_an_executable_matches_only_the_platform_code() {
1578 #[cfg(unix)]
1579 assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(8)));
1580 #[cfg(windows)]
1581 assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(
1582 193
1583 )));
1584 assert!(!is_not_an_executable(&std::io::Error::from(
1585 std::io::ErrorKind::NotFound
1586 )));
1587 }
1588}