1use crate::command_runner::{
7 git_for_windows_tool_dirs, prepend_to_child_path, system_command_runner, CommandEnvironment,
8 CommandRunner, CommandSpec, CommandStdin, CommandStdio, ConcurrentStdinFeed, RunningCommand,
9 ScriptInterpreter, ShellInvocation,
10};
11use crate::config::{parse_path, ConfigSet};
12use crate::environment::Environment;
13use crate::git_path::path_without_verbatim_prefix;
14use crate::git_path::CanonicalizeExt;
15use crate::objects::HashAlgo;
16use crate::objects::ObjectId;
17use crate::repo::Repository;
18use std::collections::{HashMap, HashSet, VecDeque};
19use std::ffi::OsString;
20use std::fs;
21#[cfg(unix)]
22use std::os::unix::fs::PermissionsExt;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::time::Duration;
26use thiserror::Error;
27
28use crate::progress::{Cancel, NeverCancel};
29
30fn is_not_an_executable(err: &std::io::Error) -> bool {
33 #[cfg(unix)]
34 const NOT_EXECUTABLE: i32 = 8;
35 #[cfg(windows)]
36 const NOT_EXECUTABLE: i32 = 193;
37 #[cfg(not(any(unix, windows)))]
38 const NOT_EXECUTABLE: i32 = -1;
39 err.raw_os_error() == Some(NOT_EXECUTABLE)
40}
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub enum HookStdio {
48 #[default]
52 Capture,
53 Inherit,
57}
58
59#[derive(Clone, Default, PartialEq, Eq)]
64pub struct HookOutput {
65 pub stdout: Vec<u8>,
67 pub stderr: Vec<u8>,
69}
70
71impl std::fmt::Debug for HookOutput {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("HookOutput")
74 .field("stdout", &String::from_utf8_lossy(&self.stdout))
75 .field("stderr", &String::from_utf8_lossy(&self.stderr))
76 .finish()
77 }
78}
79
80impl HookOutput {
81 #[must_use]
83 pub fn is_empty(&self) -> bool {
84 self.stdout.is_empty() && self.stderr.is_empty()
85 }
86}
87
88#[derive(Debug, Error)]
90pub enum HookError {
91 #[error("hook {hook_name} failed with exit status {status}")]
95 Failed {
96 hook_name: String,
98 status: i32,
100 output: HookOutput,
102 },
103 #[error("failed to run hook {hook_name} ({})", path.display())]
105 Spawn {
106 hook_name: String,
108 path: PathBuf,
110 #[source]
112 source: std::io::Error,
113 output: HookOutput,
117 },
118 #[error("{0}")]
120 Internal(String),
121 #[error("hook run cancelled")]
123 Cancelled,
124}
125
126impl HookError {
127 #[must_use]
129 pub fn output(&self) -> Option<&HookOutput> {
130 match self {
131 HookError::Failed { output, .. } | HookError::Spawn { output, .. } => Some(output),
132 HookError::Internal(_) | HookError::Cancelled => None,
133 }
134 }
135}
136
137impl From<String> for HookError {
138 fn from(value: String) -> Self {
139 Self::Internal(value)
140 }
141}
142
143fn hook_command_env(git_dir: Option<&Path>, extra_env: &[(String, String)]) -> CommandEnvironment {
144 let mut env = CommandEnvironment::inherit_process_only();
145 if let Some(gd) = git_dir {
146 env.set
147 .push((OsString::from("GIT_DIR"), gd.as_os_str().to_os_string()));
148 }
149 for (k, v) in extra_env {
150 env.set
151 .push((OsString::from(k.as_str()), OsString::from(v.as_str())));
152 }
153 env
154}
155
156#[derive(Debug, Clone, Default)]
158pub struct CommitHookEnv<'a> {
159 pub index_file: Option<&'a Path>,
161 pub git_editor: Option<&'a str>,
163 pub git_prefix: Option<&'a str>,
165 pub extra_env: &'a [(&'a str, &'a str)],
167}
168
169fn absolute_index_path(index_file: &Path, cwd: &Path) -> PathBuf {
170 if index_file.is_absolute() {
171 index_file.to_path_buf()
172 } else {
173 cwd.join(index_file)
174 }
175}
176
177fn git_prefix_for_invocation(repo: &Repository, invocation_cwd: &Path) -> String {
180 let Some(wt) = repo.work_tree() else {
181 return String::new();
182 };
183 if invocation_cwd == repo.git_dir() {
184 return String::new();
185 }
186 let wt_canon = wt
187 .canonicalize_simplified()
188 .unwrap_or_else(|_| wt.to_path_buf());
189 let wd_canon = invocation_cwd
190 .canonicalize_simplified()
191 .unwrap_or_else(|_| invocation_cwd.to_path_buf());
192 let rel = wd_canon.strip_prefix(&wt_canon).ok();
193 let Some(rel) = rel else {
194 return String::new();
195 };
196 let Some(s) = rel.to_str() else {
197 return String::new();
198 };
199 if s.is_empty() {
200 return String::new();
201 }
202 let mut out = s.replace('\\', "/");
203 if !out.ends_with('/') {
204 out.push('/');
205 }
206 out
207}
208
209fn build_commit_hook_env(
210 repo: &Repository,
211 _work_dir: &Path,
212 opts: &CommitHookEnv<'_>,
213) -> Vec<(String, String)> {
214 let mut env: Vec<(String, String)> = Vec::new();
215 if let Some(p) = opts.index_file {
216 env.push((
217 "GIT_INDEX_FILE".to_string(),
218 absolute_index_path(p, &repo.environment().cwd)
219 .to_string_lossy()
220 .into_owned(),
221 ));
222 }
223 let invocation_cwd = repo.environment().discovery_cwd();
224 let prefix = opts
225 .git_prefix
226 .map(|s| s.to_string())
227 .unwrap_or_else(|| git_prefix_for_invocation(repo, &invocation_cwd));
228 env.push(("GIT_PREFIX".to_string(), prefix));
229 if let Some(ed) = opts.git_editor {
230 env.push(("GIT_EDITOR".to_string(), ed.to_string()));
231 }
232 for (k, v) in opts.extra_env {
233 env.push(((*k).to_string(), (*v).to_string()));
234 }
235 env
236}
237
238fn parse_maybe_bool(value: &str) -> Option<bool> {
240 let v = value.trim().to_ascii_lowercase();
241 match v.as_str() {
242 "true" | "yes" | "on" | "1" => Some(true),
243 "false" | "no" | "off" | "0" => Some(false),
244 _ => None,
245 }
246}
247
248fn parse_hook_config_key(key: &str) -> Option<(&str, &str)> {
250 let rest = key.strip_prefix("hook.")?;
251 let (subsection, var) = rest.rsplit_once('.')?;
252 if subsection.is_empty() || var.is_empty() {
253 return None;
254 }
255 Some((subsection, var))
256}
257
258#[derive(Debug, Default)]
260struct HookConfigTables {
261 commands: HashMap<String, String>,
263 event_hooks: HashMap<String, VecDeque<String>>,
265 disabled: HashSet<String>,
266}
267
268impl HookConfigTables {
269 fn apply_entry(&mut self, key: &str, value: Option<&str>) {
270 let Some((hook_name, subkey)) = parse_hook_config_key(key) else {
271 return;
272 };
273 let Some(value) = value else {
274 return;
275 };
276 let hook_name = hook_name.to_string();
277
278 match subkey {
279 "event" => {
280 if value.is_empty() {
281 for hooks in self.event_hooks.values_mut() {
282 hooks.retain(|n| n != &hook_name);
283 }
284 } else {
285 let event = value.to_string();
286 let hooks = self.event_hooks.entry(event).or_default();
287 hooks.retain(|n| n != &hook_name);
288 hooks.push_back(hook_name);
289 }
290 }
291 "command" => {
292 self.commands.insert(hook_name, value.to_string());
293 }
294 "enabled" => match parse_maybe_bool(value) {
295 Some(false) => {
296 self.disabled.insert(hook_name);
297 }
298 Some(true) => {
299 self.disabled.remove(&hook_name);
300 }
301 None => {}
302 },
303 _ => {}
304 }
305 }
306
307 fn from_config(config: &ConfigSet) -> Self {
308 let mut t = Self::default();
309 for e in config.entries() {
310 t.apply_entry(&e.key, e.value.as_deref());
311 }
312 t
313 }
314
315 fn hooks_for_event(&self, event: &str) -> Result<Vec<(String, String)>, String> {
319 let Some(names) = self.event_hooks.get(event) else {
320 return Ok(Vec::new());
321 };
322 let mut out = Vec::new();
323 for name in names {
324 if self.disabled.contains(name) {
325 continue;
326 }
327 let Some(cmd) = self.commands.get(name) else {
328 return Err(format!(
329 "'hook.{name}.command' must be configured or 'hook.{name}.event' must be removed; aborting."
330 ));
331 };
332 out.push((name.clone(), cmd.clone()));
333 }
334 Ok(out)
335 }
336}
337
338#[derive(Debug)]
340enum ResolvedHook {
341 Configured { command: String },
342 Traditional { path: PathBuf, argv0: PathBuf },
343}
344
345pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
354 resolve_hooks_dir_for_config(repo, repo.config().ok().as_deref())
355}
356
357fn resolve_hooks_dir_for_config(repo: &Repository, config: Option<&ConfigSet>) -> PathBuf {
358 let dir = match config.and_then(|cfg| cfg.get("core.hooksPath")) {
359 Some(hooks_path) => {
360 let p = PathBuf::from(parse_path(repo.environment(), &hooks_path));
361 if p.is_absolute() {
362 p
363 } else {
364 hook_run_dir(repo).join(p)
365 }
366 }
367 None => crate::repo::common_git_dir_for_config(repo.git_dir()).join("hooks"),
368 };
369 path_without_verbatim_prefix(&dir).into_owned()
370}
371
372fn hook_run_dir(repo: &Repository) -> &Path {
374 repo.work_tree().unwrap_or(repo.git_dir())
375}
376
377fn hook_argv0(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
378 let default_hooks_dir = repo.git_dir().join("hooks");
379 if same_dir(hooks_dir, &default_hooks_dir) {
380 if cwd == repo.git_dir() {
381 return PathBuf::from("hooks").join(hook_name);
382 }
383 if let Some(work_tree) = repo.work_tree() {
384 if cwd == work_tree && same_dir(repo.git_dir(), &work_tree.join(".git")) {
392 return PathBuf::from(".git").join("hooks").join(hook_name);
393 }
394 }
395 }
396 hooks_dir.join(hook_name)
397}
398
399fn same_dir(a: &Path, b: &Path) -> bool {
402 if a == b {
403 return true;
404 }
405 match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
406 (Ok(ca), Ok(cb)) => ca == cb,
407 _ => false,
408 }
409}
410
411#[cfg_attr(not(unix), allow(unused_variables))]
414fn traditional_hook_candidate(
415 repo: &Repository,
416 hooks_dir: &Path,
417 hook_name: &str,
418) -> Option<PathBuf> {
419 let path = hooks_dir.join(hook_name);
420 if !path.exists() {
421 return None;
422 }
423 let meta = fs::metadata(&path).ok()?;
424 #[cfg(unix)]
425 if meta.permissions().mode() & 0o111 == 0 {
426 let config = repo.config().ok();
427 let show_warning = config
428 .as_deref()
429 .and_then(|c| c.get("advice.ignoredHook"))
430 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
431 .unwrap_or(true);
432 if show_warning {
433 repo.warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
434 hook_name: hook_name.to_string(),
435 hook_path: Some(path.clone()),
436 });
437 }
438 return None;
439 }
440 Some(path)
441}
442
443fn resolve_configured_hooks_only(
445 hook_name: &str,
446 config: &ConfigSet,
447) -> Result<Vec<ResolvedHook>, String> {
448 let tables = HookConfigTables::from_config(config);
449 let mut seq = Vec::new();
450 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
451 seq.push(ResolvedHook::Configured { command });
452 }
453 Ok(seq)
454}
455
456fn resolve_hook_sequence(
458 repo: &Repository,
459 hook_name: &str,
460 config: &ConfigSet,
461) -> Result<Vec<ResolvedHook>, String> {
462 let tables = HookConfigTables::from_config(config);
463 let mut seq = Vec::new();
464 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
465 seq.push(ResolvedHook::Configured { command });
466 }
467 let hooks_dir = resolve_hooks_dir_for_config(repo, Some(config));
468 if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
469 let argv0 = hook_argv0(repo, &hooks_dir, hook_name, hook_run_dir(repo));
470 seq.push(ResolvedHook::Traditional { path, argv0 });
471 }
472 Ok(seq)
473}
474
475#[cfg_attr(not(windows), allow(dead_code))]
480fn shebang_interpreter(head: &[u8]) -> Option<String> {
481 let rest = head.strip_prefix(b"#!")?;
482 let line_len = rest
483 .iter()
484 .position(|&b| b == b'\n' || b == b'\r')
485 .unwrap_or(rest.len());
486 let line = std::str::from_utf8(&rest[..line_len]).ok()?;
487 let basename = |word: &str| word.rsplit(['/', '\\']).next().map(str::to_owned);
488 let mut words = line.split_ascii_whitespace();
489 let mut name = basename(words.next()?)?;
490 if name == "env" {
491 name = basename(words.find(|w| !w.starts_with('-'))?)?;
492 }
493 (!name.is_empty()).then_some(name)
494}
495
496fn strip_verbatim_prefix_from_path_vars(set: &mut [(OsString, OsString)]) {
502 crate::git_path::strip_verbatim_prefix_from_subprocess_path_vars(set);
503}
504
505fn hook_shell(env: &CommandEnvironment) -> ScriptInterpreter {
510 crate::command_runner::shell_interpreter(env)
511}
512
513#[cfg(windows)]
515fn script_shebang_interpreter(
516 script: &Path,
517 env: &CommandEnvironment,
518) -> Option<ScriptInterpreter> {
519 use std::io::Read;
520 let mut head = [0u8; 256];
521 let mut file = fs::File::open(script).ok()?;
522 let n = file.read(&mut head).ok()?;
523 let name = shebang_interpreter(&head[..n])?;
524 Some(crate::command_runner::resolve_script_interpreter(
525 &name,
526 crate::command_runner::child_path_var(env).as_deref(),
527 ))
528}
529
530struct HookSpawnContext<'a> {
532 runner: &'a dyn CommandRunner,
533 cwd: &'a Path,
534 env: &'a CommandEnvironment,
535 stdin_piped: bool,
536 stdout: CommandStdio,
537 stderr: CommandStdio,
538 server_receive_hook: bool,
539}
540
541impl HookSpawnContext<'_> {
542 fn spec(&self, program: PathBuf, shell: Option<ShellInvocation>, args: &[&str]) -> CommandSpec {
543 CommandSpec {
544 program,
545 args: if shell.is_some() {
546 Vec::new()
547 } else {
548 args.iter().map(OsString::from).collect()
549 },
550 shell,
551 cwd: Some(path_without_verbatim_prefix(self.cwd).into_owned()),
552 env: self.env.clone(),
553 stdin: if self.stdin_piped {
554 CommandStdin::Pipe(Vec::new())
555 } else {
556 CommandStdin::Inherit
557 },
558 stdout: self.stdout,
559 stderr: self.stderr,
560 }
561 }
562
563 fn interpreter_spec(
566 &self,
567 interpreter: ScriptInterpreter,
568 invocation: ShellInvocation,
569 args: &[&str],
570 ) -> CommandSpec {
571 let mut spec = self.spec(interpreter.program, Some(invocation), args);
572 if let Some(root) = &interpreter.git_root {
573 prepend_to_child_path(&mut spec.env, &git_for_windows_tool_dirs(root));
574 }
575 spec
576 }
577
578 fn spawn(&self, spec: &CommandSpec, hook_name: &str) -> Result<RunningCommand, HookError> {
579 let spawn = if self.server_receive_hook {
580 self.runner.spawn_server_receive_hook(spec)
581 } else {
582 self.runner.spawn(spec)
583 };
584 spawn.map_err(|source| HookError::Spawn {
585 hook_name: hook_name.to_owned(),
586 path: spec.program.clone(),
587 source,
588 output: HookOutput::default(),
589 })
590 }
591
592 #[cfg_attr(not(windows), allow(unused_variables))]
596 fn spawn_traditional(
597 &self,
598 hook_name: &str,
599 script: &Path,
600 argv0: &Path,
601 args: &[&str],
602 ) -> Result<RunningCommand, HookError> {
603 let argv0 = path_without_verbatim_prefix(argv0);
604 let via_shell = |shell: ScriptInterpreter| {
605 let invocation = ShellInvocation::ScriptPath {
606 script_path: argv0.to_path_buf(),
607 args: args.iter().map(OsString::from).collect(),
608 };
609 self.interpreter_spec(shell, invocation, args)
610 };
611 #[cfg(windows)]
612 if let Some(interpreter) = script_shebang_interpreter(script, self.env) {
613 return self.spawn(&via_shell(interpreter), hook_name);
614 }
615 let direct = self.spec(argv0.to_path_buf(), None, args);
616 match self.spawn(&direct, hook_name) {
617 Err(HookError::Spawn { source, path, .. }) if is_not_an_executable(&source) => {
618 self.spawn(&via_shell(hook_shell(self.env)), hook_name)
619 }
620 other => other,
621 }
622 }
623
624 fn spawn_configured(
626 &self,
627 hook_name: &str,
628 command: &str,
629 args: &[&str],
630 ) -> Result<RunningCommand, HookError> {
631 let invocation = ShellInvocation::DashC {
632 script: command.to_owned(),
633 argv0: Some("hook".to_owned()),
634 args: args.iter().map(OsString::from).collect(),
635 };
636 self.spawn(
637 &self.interpreter_spec(hook_shell(self.env), invocation, args),
638 hook_name,
639 )
640 }
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
646#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
647#[cfg_attr(feature = "serde-models", serde(rename_all = "kebab-case"))]
648pub enum HookName {
649 PreCommit,
651 PreMergeCommit,
653 PrepareCommitMsg,
656 CommitMsg,
658 PostCommit,
660 PostCheckout,
662 PostMerge,
664 PostRewrite,
666 PreRebase,
669}
670
671impl HookName {
672 #[must_use]
674 pub const fn as_str(self) -> &'static str {
675 match self {
676 Self::PreCommit => "pre-commit",
677 Self::PreMergeCommit => "pre-merge-commit",
678 Self::PrepareCommitMsg => "prepare-commit-msg",
679 Self::PreRebase => "pre-rebase",
680 Self::CommitMsg => "commit-msg",
681 Self::PostCommit => "post-commit",
682 Self::PostCheckout => "post-checkout",
683 Self::PostMerge => "post-merge",
684 Self::PostRewrite => "post-rewrite",
685 }
686 }
687}
688
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
696pub enum PostOperationHooks {
697 #[default]
699 Run,
700 Skip,
702}
703
704impl PostOperationHooks {
705 #[must_use]
707 pub const fn enabled(self) -> bool {
708 matches!(self, Self::Run)
709 }
710}
711
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714pub enum RewriteKind {
715 Amend,
717 Rebase,
719}
720
721impl RewriteKind {
722 #[must_use]
724 pub const fn as_str(self) -> &'static str {
725 match self {
726 Self::Amend => "amend",
727 Self::Rebase => "rebase",
728 }
729 }
730}
731
732pub(crate) fn run_advisory_hook(
740 repo: &Repository,
741 hook: HookName,
742 args: &[&str],
743 stdin_data: Option<&[u8]>,
744 commit_env: Option<&CommitHookEnv<'_>>,
745) -> bool {
746 let result = match commit_env {
747 Some(env) => run_commit_hook(repo, hook.as_str(), args, stdin_data, env),
748 None => run_hook(repo, hook.as_str(), args, stdin_data),
749 };
750 match result {
751 Ok(result) => result.was_executed(),
752 Err(err) => {
753 let mut detail = err.to_string();
754 let mut cause = std::error::Error::source(&err);
755 while let Some(c) = cause {
756 detail.push_str(": ");
757 detail.push_str(&c.to_string());
758 cause = c.source();
759 }
760 repo.warn(crate::diagnostics::Warning::HookNotRun {
761 hook_name: hook.as_str().to_owned(),
762 detail,
763 });
764 false
765 }
766 }
767}
768
769pub fn run_post_checkout(
779 repo: &Repository,
780 old: Option<ObjectId>,
781 new: ObjectId,
782 branch_checkout: bool,
783) -> bool {
784 let old_hex = old
785 .unwrap_or_else(|| ObjectId::null(repo.odb().hash_algo()))
786 .to_hex();
787 let new_hex = new.to_hex();
788 let flag = if branch_checkout { "1" } else { "0" };
789 run_advisory_hook(
790 repo,
791 HookName::PostCheckout,
792 &[old_hex.as_str(), new_hex.as_str(), flag],
793 None,
794 None,
795 )
796}
797
798pub fn run_post_merge(repo: &Repository, squash: bool) -> bool {
803 run_advisory_hook(
804 repo,
805 HookName::PostMerge,
806 &[if squash { "1" } else { "0" }],
807 None,
808 None,
809 )
810}
811
812pub fn run_pre_rebase(repo: &Repository, upstream: Option<&str>) -> Result<bool, HookError> {
826 let configured;
827 let upstream_arg = match upstream {
828 Some(arg) => arg,
829 None => {
830 configured = crate::state::resolve_head_for_repository(repo)
831 .ok()
832 .and_then(|head| {
833 head.branch_name().and_then(|branch| {
834 crate::branch_tracking::upstream_tracking_full_ref(repo, branch)
835 })
836 })
837 .unwrap_or_else(|| "@{upstream}".to_owned());
838 configured.as_str()
839 }
840 };
841 let name = HookName::PreRebase.as_str();
842 run_hook(repo, name, &[upstream_arg], None)?.check(name)
843}
844
845#[must_use]
847pub fn post_rewrite_stdin(pairs: &[(ObjectId, ObjectId)]) -> Vec<u8> {
848 let mut out = Vec::with_capacity(pairs.len() * 82);
849 for (old, new) in pairs {
850 out.extend_from_slice(old.to_hex().as_bytes());
851 out.push(b' ');
852 out.extend_from_slice(new.to_hex().as_bytes());
853 out.push(b'\n');
854 }
855 out
856}
857
858pub fn run_post_rewrite(
863 repo: &Repository,
864 kind: RewriteKind,
865 pairs: &[(ObjectId, ObjectId)],
866) -> bool {
867 if pairs.is_empty() {
868 return false;
869 }
870 let stdin = post_rewrite_stdin(pairs);
871 run_advisory_hook(
872 repo,
873 HookName::PostRewrite,
874 &[kind.as_str()],
875 Some(&stdin),
876 None,
877 )
878}
879
880#[derive(Debug, Clone, PartialEq, Eq)]
882pub enum HookResult {
883 Success {
885 output: HookOutput,
887 },
888 NotFound,
890 Failed {
892 status: i32,
894 output: HookOutput,
897 },
898}
899
900impl HookResult {
901 #[must_use]
903 pub fn is_ok(&self) -> bool {
904 matches!(self, HookResult::Success { .. } | HookResult::NotFound)
905 }
906
907 #[must_use]
909 pub fn was_executed(&self) -> bool {
910 !matches!(self, HookResult::NotFound)
911 }
912
913 #[must_use]
915 pub fn status(&self) -> Option<i32> {
916 match self {
917 HookResult::Success { .. } => Some(0),
918 HookResult::NotFound => None,
919 HookResult::Failed { status, .. } => Some(*status),
920 }
921 }
922
923 #[must_use]
925 pub fn output(&self) -> Option<&HookOutput> {
926 match self {
927 HookResult::Success { output } | HookResult::Failed { output, .. } => Some(output),
928 HookResult::NotFound => None,
929 }
930 }
931
932 pub fn check(self, hook_name: &str) -> Result<bool, HookError> {
938 match self {
939 HookResult::Success { .. } => Ok(true),
940 HookResult::NotFound => Ok(false),
941 HookResult::Failed { status, output } => Err(HookError::Failed {
942 hook_name: hook_name.to_owned(),
943 status,
944 output,
945 }),
946 }
947 }
948}
949
950#[derive(Debug, Clone, Default)]
952pub struct RunHookOptions<'a> {
953 pub stdio: HookStdio,
957 pub stdout_to_stderr: bool,
960 pub path_to_stdin: Option<&'a Path>,
962 pub stdin_data: Option<&'a [u8]>,
964 pub env_vars: &'a [(&'a str, &'a str)],
966 pub cwd: Option<&'a Path>,
968 pub commit_env: Option<&'a CommitHookEnv<'a>>,
970}
971
972const HOOK_WAIT_POLL: Duration = Duration::from_millis(50);
982
983fn kill_pid_sigkill(pid: u32, kill_process_group: bool) {
984 crate::command_runner::force_kill_process_pid(pid, kill_process_group);
985}
986
987fn wait_hook_command(
988 child: RunningCommand,
989 capture: bool,
990 cancel: &dyn Cancel,
991 output: &mut HookOutput,
992) -> Result<crate::command_runner::CommandExit, HookError> {
993 use crate::command_runner::CommandOutput;
994
995 if cancel.is_cancelled() {
996 return stop_hook_child(child, capture, output);
997 }
998
999 if child.child_pid().is_none() {
1000 return finish_hook_wait(child, capture, output);
1001 }
1002
1003 let pid = child.child_pid();
1004 let kill_process_group = child.kill_process_group();
1005 let shared = std::sync::Arc::new(std::sync::Mutex::new(Some(child)));
1006 let shared_wait = std::sync::Arc::clone(&shared);
1007 let (tx, rx) = std::sync::mpsc::sync_channel(1);
1008 std::thread::spawn(move || {
1009 let child = shared_wait.lock().unwrap_or_else(|e| e.into_inner()).take();
1010 let Some(child) = child else {
1011 return;
1012 };
1013 let result = if capture {
1014 child.wait_with_output()
1015 } else {
1016 child.wait().map(|status| CommandOutput {
1017 status,
1018 stdout: Vec::new(),
1019 stderr: Vec::new(),
1020 })
1021 };
1022 let _ = tx.send(result);
1023 });
1024
1025 loop {
1026 if cancel.is_cancelled() {
1027 if let Some(mut child) = shared.lock().unwrap_or_else(|e| e.into_inner()).take() {
1028 let _ = child.kill();
1029 if capture {
1030 if let Ok(out) = child.wait_with_output() {
1031 output.stdout.extend_from_slice(&out.stdout);
1032 output.stderr.extend_from_slice(&out.stderr);
1033 }
1034 } else {
1035 let _ = child.wait();
1036 }
1037 }
1038 if let Some(pid) = pid {
1039 kill_pid_sigkill(pid, kill_process_group);
1040 }
1041 if let Ok(Ok(out)) = rx.recv_timeout(HOOK_WAIT_POLL.saturating_mul(20)) {
1042 if capture {
1043 output.stdout.extend_from_slice(&out.stdout);
1044 output.stderr.extend_from_slice(&out.stderr);
1045 }
1046 }
1047 return Err(HookError::Cancelled);
1048 }
1049 match rx.try_recv() {
1050 Ok(Ok(out)) => {
1051 if capture {
1052 output.stdout.extend_from_slice(&out.stdout);
1053 output.stderr.extend_from_slice(&out.stderr);
1054 }
1055 return Ok(out.status);
1056 }
1057 Ok(Err(_)) => {
1058 return Ok(crate::command_runner::CommandExit {
1059 success: false,
1060 code: Some(1),
1061 });
1062 }
1063 Err(std::sync::mpsc::TryRecvError::Empty) => {
1064 std::thread::sleep(HOOK_WAIT_POLL);
1065 }
1066 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
1067 return Ok(crate::command_runner::CommandExit {
1068 success: false,
1069 code: Some(1),
1070 });
1071 }
1072 }
1073 }
1074}
1075
1076fn stop_hook_child(
1077 child: RunningCommand,
1078 capture: bool,
1079 output: &mut HookOutput,
1080) -> Result<crate::command_runner::CommandExit, HookError> {
1081 let pid = child.child_pid();
1082 let kill_process_group = child.kill_process_group();
1083 let mut child = child;
1084 let _ = child.kill();
1085 let _ = finish_hook_wait(child, capture, output);
1086 if let Some(pid) = pid {
1087 kill_pid_sigkill(pid, kill_process_group);
1088 }
1089 Err(HookError::Cancelled)
1090}
1091
1092fn finish_hook_wait(
1093 child: RunningCommand,
1094 capture: bool,
1095 output: &mut HookOutput,
1096) -> Result<crate::command_runner::CommandExit, HookError> {
1097 if capture {
1098 match child.wait_with_output() {
1099 Ok(out) => {
1100 output.stdout.extend_from_slice(&out.stdout);
1101 output.stderr.extend_from_slice(&out.stderr);
1102 Ok(out.status)
1103 }
1104 Err(_) => Ok(crate::command_runner::CommandExit {
1105 success: false,
1106 code: Some(1),
1107 }),
1108 }
1109 } else {
1110 match child.wait() {
1111 Ok(status) => Ok(status),
1112 Err(_) => Ok(crate::command_runner::CommandExit {
1113 success: false,
1114 code: Some(1),
1115 }),
1116 }
1117 }
1118}
1119
1120pub fn run_hook_opts(
1126 repo: Option<&Repository>,
1127 hook_name: &str,
1128 args: &[&str],
1129 config: &ConfigSet,
1130 opts: RunHookOptions<'_>,
1131) -> Result<HookResult, HookError> {
1132 run_hook_opts_with_cancel(repo, hook_name, args, config, opts, &NeverCancel, false)
1133}
1134
1135fn run_hook_opts_with_cancel(
1136 repo: Option<&Repository>,
1137 hook_name: &str,
1138 args: &[&str],
1139 config: &ConfigSet,
1140 opts: RunHookOptions<'_>,
1141 cancel: &dyn Cancel,
1142 server_receive_hook: bool,
1143) -> Result<HookResult, HookError> {
1144 let seq = match repo {
1145 Some(r) => resolve_hook_sequence(r, hook_name, config)?,
1146 None => resolve_configured_hooks_only(hook_name, config)?,
1147 };
1148 if seq.is_empty() {
1149 return Ok(HookResult::NotFound);
1150 }
1151
1152 let work_dir: PathBuf = opts.cwd.map_or_else(
1153 || match repo {
1154 Some(r) => hook_run_dir(r).to_path_buf(),
1155 None => PathBuf::from("."),
1156 },
1157 Path::to_path_buf,
1158 );
1159 let work_dir = work_dir.as_path();
1160 let git_dir_for_configured = repo.map(|r| r.git_dir());
1161 let runner: Arc<dyn CommandRunner> = match repo {
1162 Some(r) => r.command_runner(),
1163 None => system_command_runner(),
1164 };
1165
1166 let mut merged_env: Vec<(String, String)> = opts
1167 .env_vars
1168 .iter()
1169 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1170 .collect();
1171 if let Some(r) = repo {
1172 if let Some(ce) = opts.commit_env {
1173 merged_env.extend(build_commit_hook_env(r, work_dir, ce));
1174 }
1175 }
1176
1177 let mut hook_env = match repo {
1178 Some(r) => CommandEnvironment::from_repository_environment(r.environment(), &merged_env),
1179 None => hook_command_env(git_dir_for_configured, &merged_env),
1180 };
1181 if cfg!(windows) {
1182 strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
1183 }
1184
1185 let capture = opts.stdio == HookStdio::Capture;
1186 let (stdout, stderr) = match opts.stdio {
1187 HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
1188 HookStdio::Inherit if opts.stdout_to_stderr => {
1189 (CommandStdio::ParentStderr, CommandStdio::Inherit)
1190 }
1191 HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
1192 };
1193 let mut output = HookOutput::default();
1194
1195 for h in &seq {
1196 let (stdin_piped, stdin_file) = match opts.path_to_stdin {
1197 Some(p) => (true, Some(p.to_path_buf())),
1198 None => (opts.stdin_data.is_some(), None),
1199 };
1200
1201 let ctx = HookSpawnContext {
1202 runner: runner.as_ref(),
1203 cwd: work_dir,
1204 env: &hook_env,
1205 stdin_piped,
1206 stdout,
1207 stderr,
1208 server_receive_hook,
1209 };
1210 let spawned = match h {
1211 ResolvedHook::Traditional { path, argv0 } => {
1212 let Some(r) = repo else {
1213 continue;
1214 };
1215 let effective_argv0 = path
1216 .parent()
1217 .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
1218 .unwrap_or_else(|| argv0.clone());
1219 ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
1220 }
1221 ResolvedHook::Configured { command } => ctx.spawn_configured(hook_name, command, args),
1222 };
1223 let mut child = match spawned {
1224 Ok(child) => child,
1225 Err(HookError::Spawn {
1226 hook_name,
1227 path,
1228 source,
1229 ..
1230 }) => {
1231 return Err(HookError::Spawn {
1232 hook_name,
1233 path,
1234 source,
1235 output,
1236 })
1237 }
1238 Err(other) => return Err(other),
1239 };
1240
1241 let exit = if capture && stdin_piped && (stdin_file.is_some() || opts.stdin_data.is_some())
1242 {
1243 if cancel.is_cancelled() {
1244 let pid = child.child_pid();
1245 let kill_process_group = child.kill_process_group();
1246 let mut child = child;
1247 let _ = child.kill();
1248 if let Some(pid) = pid {
1249 kill_pid_sigkill(pid, kill_process_group);
1250 }
1251 return Err(HookError::Cancelled);
1252 }
1253 let output_result = if let Some(ref path) = stdin_file {
1254 let file = match fs::File::open(path) {
1255 Ok(f) => f,
1256 Err(_e) => {
1257 return Ok(HookResult::Failed { status: 1, output });
1258 }
1259 };
1260 child.feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::File(file)))
1261 } else if let Some(data) = opts.stdin_data {
1262 child.feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::Bytes(data)))
1263 } else {
1264 child.close_stdin();
1265 child.wait_with_output()
1266 };
1267 match output_result {
1268 Ok(o) => {
1269 output.stdout.extend_from_slice(&o.stdout);
1270 output.stderr.extend_from_slice(&o.stderr);
1271 o.status
1272 }
1273 Err(_) => crate::command_runner::CommandExit {
1274 success: false,
1275 code: Some(1),
1276 },
1277 }
1278 } else {
1279 if let Some(ref path) = stdin_file {
1280 let file = match fs::File::open(path) {
1281 Ok(f) => f,
1282 Err(_e) => {
1283 return Ok(HookResult::Failed { status: 1, output });
1284 }
1285 };
1286 if let Some(stdin) = child.stdin_mut() {
1287 let mut file = file;
1288 let _ = crate::sigpipe::copy(&mut file, stdin);
1289 }
1290 } else if let Some(data) = opts.stdin_data {
1291 if let Some(stdin) = child.stdin_mut() {
1292 let _ = crate::sigpipe::write_all(stdin, data);
1293 }
1294 }
1295 if stdin_piped {
1296 child.close_stdin();
1297 }
1298 match wait_hook_command(child, capture, cancel, &mut output) {
1299 Ok(exit) => exit,
1300 Err(HookError::Cancelled) => return Err(HookError::Cancelled),
1301 Err(other) => return Err(other),
1302 }
1303 };
1304
1305 if !exit.success {
1306 if let Some(r) = repo {
1307 let _ = r.reload_config();
1308 }
1309 return Ok(HookResult::Failed {
1310 status: exit.code.unwrap_or(1),
1311 output,
1312 });
1313 }
1314 }
1315
1316 if let Some(r) = repo {
1317 let _ = r.reload_config();
1318 }
1319
1320 Ok(HookResult::Success { output })
1321}
1322
1323pub fn run_commit_hook_checked(
1330 repo: &Repository,
1331 hook_name: &str,
1332 args: &[&str],
1333 stdin_data: Option<&[u8]>,
1334 commit_env: &CommitHookEnv<'_>,
1335) -> Result<(), HookError> {
1336 run_commit_hook(repo, hook_name, args, stdin_data, commit_env)?
1337 .check(hook_name)
1338 .map(drop)
1339}
1340
1341pub fn run_commit_hook(
1350 repo: &Repository,
1351 hook_name: &str,
1352 args: &[&str],
1353 stdin_data: Option<&[u8]>,
1354 commit_env: &CommitHookEnv<'_>,
1355) -> Result<HookResult, HookError> {
1356 run_commit_hook_with_cancel(repo, hook_name, args, stdin_data, commit_env, &NeverCancel)
1357}
1358
1359pub(crate) fn run_commit_hook_with_cancel(
1361 repo: &Repository,
1362 hook_name: &str,
1363 args: &[&str],
1364 stdin_data: Option<&[u8]>,
1365 commit_env: &CommitHookEnv<'_>,
1366 cancel: &dyn Cancel,
1367) -> Result<HookResult, HookError> {
1368 let config = repo
1369 .config()
1370 .map_err(|e| HookError::Internal(e.to_string()))?;
1371 run_hook_opts_with_cancel(
1372 Some(repo),
1373 hook_name,
1374 args,
1375 config.as_ref(),
1376 RunHookOptions {
1377 stdin_data,
1378 commit_env: Some(commit_env),
1379 ..repo_hook_options(repo, hook_name)
1380 },
1381 cancel,
1382 false,
1383 )
1384}
1385
1386fn repo_hook_options<'a>(repo: &Repository, hook_name: &str) -> RunHookOptions<'a> {
1389 RunHookOptions {
1390 stdio: repo.hook_stdio(),
1391 stdout_to_stderr: hook_name != "pre-push",
1392 ..RunHookOptions::default()
1393 }
1394}
1395
1396pub fn run_hook(
1406 repo: &Repository,
1407 hook_name: &str,
1408 args: &[&str],
1409 stdin_data: Option<&[u8]>,
1410) -> Result<HookResult, HookError> {
1411 let config = repo
1412 .config()
1413 .map_err(|e| HookError::Internal(e.to_string()))?;
1414 run_hook_opts(
1415 Some(repo),
1416 hook_name,
1417 args,
1418 config.as_ref(),
1419 RunHookOptions {
1420 stdin_data,
1421 ..repo_hook_options(repo, hook_name)
1422 },
1423 )
1424}
1425
1426pub fn run_hook_in_git_dir(
1435 repo: &Repository,
1436 hook_name: &str,
1437 args: &[&str],
1438 stdin_data: Option<&[u8]>,
1439 env_vars: &[(&str, &str)],
1440) -> HookResult {
1441 run_hook_in_git_dir_with_cancel(repo, hook_name, args, stdin_data, env_vars, &NeverCancel)
1442 .unwrap_or_else(|_| hook_declined())
1443}
1444
1445fn hook_declined() -> HookResult {
1446 HookResult::Failed {
1447 status: 1,
1448 output: HookOutput::default(),
1449 }
1450}
1451
1452pub(crate) fn run_hook_in_git_dir_with_cancel(
1458 repo: &Repository,
1459 hook_name: &str,
1460 args: &[&str],
1461 stdin_data: Option<&[u8]>,
1462 env_vars: &[(&str, &str)],
1463 cancel: &dyn Cancel,
1464) -> Result<HookResult, HookError> {
1465 let env_vars: Vec<(&str, &str)> = std::iter::once(("GIT_DIR", "."))
1466 .chain(env_vars.iter().copied())
1467 .collect();
1468 let config = repo
1469 .config()
1470 .map_err(|e| HookError::Internal(e.to_string()))?;
1471 run_hook_opts_with_cancel(
1472 Some(repo),
1473 hook_name,
1474 args,
1475 config.as_ref(),
1476 RunHookOptions {
1477 stdio: HookStdio::Capture,
1478 stdout_to_stderr: true,
1479 stdin_data,
1480 env_vars: &env_vars,
1481 cwd: Some(repo.git_dir()),
1482 ..RunHookOptions::default()
1483 },
1484 cancel,
1485 true,
1486 )
1487}
1488
1489#[derive(Clone, Debug, PartialEq, Eq)]
1491pub struct PrePushRef {
1492 pub local_ref: Option<String>,
1495 pub local_oid: Option<ObjectId>,
1497 pub remote_ref: String,
1499 pub remote_oid: Option<ObjectId>,
1501}
1502
1503impl PrePushRef {
1504 fn stdin_line(&self, algo: crate::objects::HashAlgo) -> String {
1509 let zero = "0".repeat(algo.hex_len());
1510 let hex = |oid: Option<ObjectId>| oid.map_or_else(|| zero.clone(), |o| o.to_hex());
1511 let local_ref = match (&self.local_ref, self.local_oid) {
1512 (Some(name), _) => name.clone(),
1513 (None, Some(oid)) => oid.to_hex(),
1514 (None, None) => "(delete)".to_owned(),
1515 };
1516 format!(
1517 "{local_ref} {} {} {}\n",
1518 hex(self.local_oid),
1519 self.remote_ref,
1520 hex(self.remote_oid)
1521 )
1522 }
1523}
1524
1525pub fn run_pre_push_hook(
1542 repo: &Repository,
1543 remote: &str,
1544 url: &str,
1545 refs: &[PrePushRef],
1546) -> Result<HookOutput, HookError> {
1547 let config = repo
1548 .config()
1549 .map_err(|e| HookError::Internal(e.to_string()))?;
1550 let algo = repo.odb().hash_algo();
1551 let stdin: String = refs.iter().map(|r| r.stdin_line(algo)).collect();
1552 let result = run_hook_opts(
1553 Some(repo),
1554 "pre-push",
1555 &[remote, url],
1556 config.as_ref(),
1557 RunHookOptions {
1558 stdout_to_stderr: true,
1559 stdin_data: Some(stdin.as_bytes()),
1560 ..repo_hook_options(repo, "pre-push")
1561 },
1562 )?;
1563 let output = match &result {
1564 HookResult::Success { output } => output.clone(),
1565 HookResult::NotFound | HookResult::Failed { .. } => HookOutput::default(),
1566 };
1567 result.check("pre-push")?;
1568 Ok(output)
1569}
1570
1571#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1573pub(crate) enum ReferenceTransactionPhase {
1574 Prepared,
1576 Committed,
1578 Aborted,
1580}
1581
1582impl ReferenceTransactionPhase {
1583 fn as_arg(self) -> &'static str {
1584 match self {
1585 Self::Prepared => "prepared",
1586 Self::Committed => "committed",
1587 Self::Aborted => "aborted",
1588 }
1589 }
1590}
1591
1592#[derive(Clone)]
1594pub struct RefHookContext {
1595 git_dir: PathBuf,
1596 work_tree: Option<PathBuf>,
1597 environment: Arc<Environment>,
1598 command_runner: Arc<dyn CommandRunner>,
1599 hook_stdio: HookStdio,
1600 #[cfg_attr(not(unix), allow(dead_code))]
1601 diagnostics: crate::diagnostics::DiagnosticsHandle,
1602 hash_algo: HashAlgo,
1603}
1604
1605impl RefHookContext {
1606 #[allow(clippy::too_many_arguments)]
1608 pub(crate) fn new(
1609 git_dir: PathBuf,
1610 work_tree: Option<PathBuf>,
1611 environment: Arc<Environment>,
1612 command_runner: Arc<dyn CommandRunner>,
1613 hook_stdio: HookStdio,
1614 diagnostics: crate::diagnostics::DiagnosticsHandle,
1615 hash_algo: HashAlgo,
1616 ) -> Self {
1617 Self {
1618 git_dir,
1619 work_tree,
1620 environment,
1621 command_runner,
1622 hook_stdio,
1623 diagnostics,
1624 hash_algo,
1625 }
1626 }
1627
1628 #[must_use]
1630 pub(crate) fn git_dir(&self) -> &Path {
1631 &self.git_dir
1632 }
1633
1634 #[must_use]
1636 pub(crate) fn null_oid(&self) -> ObjectId {
1637 ObjectId::null(self.hash_algo)
1638 }
1639
1640 fn hook_run_dir(&self) -> &Path {
1641 self.work_tree.as_deref().unwrap_or(&self.git_dir)
1642 }
1643
1644 fn load_config(&self) -> Result<Arc<crate::config::ConfigSet>, HookError> {
1645 crate::config::ConfigSet::load(&self.environment, Some(&self.git_dir), true)
1646 .map(Arc::new)
1647 .map_err(|e| HookError::Internal(e.to_string()))
1648 }
1649
1650 pub(crate) fn run_reference_transaction(
1655 &self,
1656 phase: ReferenceTransactionPhase,
1657 stdin: &[u8],
1658 ) -> Result<(), HookError> {
1659 if stdin.is_empty() {
1660 return Ok(());
1661 }
1662 let config = self.load_config()?;
1663 let result = run_hook_opts_for_ref_context(
1664 self,
1665 "reference-transaction",
1666 &[phase.as_arg()],
1667 config.as_ref(),
1668 RunHookOptions {
1669 stdin_data: Some(stdin),
1670 stdout_to_stderr: true,
1671 stdio: self.hook_stdio,
1672 ..RunHookOptions::default()
1673 },
1674 &NeverCancel,
1675 )?;
1676 if phase == ReferenceTransactionPhase::Prepared {
1677 result.check("reference-transaction")?;
1678 }
1679 Ok(())
1680 }
1681}
1682
1683fn resolve_hooks_dir_for_ref(ctx: &RefHookContext, config: &ConfigSet) -> PathBuf {
1684 let dir = match config.get("core.hooksPath") {
1685 Some(hooks_path) => {
1686 let p = PathBuf::from(parse_path(ctx.environment.as_ref(), &hooks_path));
1687 if p.is_absolute() {
1688 p
1689 } else {
1690 ctx.hook_run_dir().join(p)
1691 }
1692 }
1693 None => crate::repo::common_git_dir_for_config(&ctx.git_dir).join("hooks"),
1694 };
1695 path_without_verbatim_prefix(&dir).into_owned()
1696}
1697
1698fn hook_argv0_for_ref(
1699 ctx: &RefHookContext,
1700 hooks_dir: &Path,
1701 hook_name: &str,
1702 cwd: &Path,
1703) -> PathBuf {
1704 let default_hooks_dir = ctx.git_dir.join("hooks");
1705 if same_dir(hooks_dir, &default_hooks_dir) {
1706 if cwd == ctx.git_dir.as_path() {
1707 return PathBuf::from("hooks").join(hook_name);
1708 }
1709 if let Some(work_tree) = &ctx.work_tree {
1710 if cwd == work_tree.as_path() && same_dir(&ctx.git_dir, &work_tree.join(".git")) {
1711 return PathBuf::from(".git").join("hooks").join(hook_name);
1712 }
1713 }
1714 }
1715 hooks_dir.join(hook_name)
1716}
1717
1718fn resolve_hook_sequence_for_ref(
1719 ctx: &RefHookContext,
1720 hook_name: &str,
1721 config: &ConfigSet,
1722) -> Result<Vec<ResolvedHook>, String> {
1723 let tables = HookConfigTables::from_config(config);
1724 let mut seq = Vec::new();
1725 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
1726 seq.push(ResolvedHook::Configured { command });
1727 }
1728 let hooks_dir = resolve_hooks_dir_for_ref(ctx, config);
1729 if let Some(path) = traditional_hook_candidate_for_ref(ctx, &hooks_dir, hook_name) {
1730 let argv0 = hook_argv0_for_ref(ctx, &hooks_dir, hook_name, ctx.hook_run_dir());
1731 seq.push(ResolvedHook::Traditional { path, argv0 });
1732 }
1733 Ok(seq)
1734}
1735
1736#[cfg_attr(not(unix), allow(unused_variables))]
1737fn traditional_hook_candidate_for_ref(
1738 ctx: &RefHookContext,
1739 hooks_dir: &Path,
1740 hook_name: &str,
1741) -> Option<PathBuf> {
1742 let path = hooks_dir.join(hook_name);
1743 if !path.exists() {
1744 return None;
1745 }
1746 let meta = fs::metadata(&path).ok()?;
1747 #[cfg(unix)]
1748 if meta.permissions().mode() & 0o111 == 0 {
1749 let show_warning = ctx
1750 .load_config()
1751 .ok()
1752 .and_then(|c| c.get("advice.ignoredHook"))
1753 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
1754 .unwrap_or(true);
1755 if show_warning {
1756 ctx.diagnostics
1757 .warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
1758 hook_name: hook_name.to_string(),
1759 hook_path: Some(path.clone()),
1760 });
1761 }
1762 return None;
1763 }
1764 Some(path)
1765}
1766
1767fn run_hook_opts_for_ref_context(
1768 ctx: &RefHookContext,
1769 hook_name: &str,
1770 args: &[&str],
1771 config: &ConfigSet,
1772 opts: RunHookOptions<'_>,
1773 cancel: &dyn Cancel,
1774) -> Result<HookResult, HookError> {
1775 let seq = resolve_hook_sequence_for_ref(ctx, hook_name, config)?;
1776 if seq.is_empty() {
1777 return Ok(HookResult::NotFound);
1778 }
1779
1780 let work_dir = opts
1781 .cwd
1782 .map(Path::to_path_buf)
1783 .unwrap_or_else(|| ctx.hook_run_dir().to_path_buf());
1784 let work_dir = work_dir.as_path();
1785 let runner = ctx.command_runner.as_ref();
1786
1787 let merged_env: Vec<(String, String)> = opts
1788 .env_vars
1789 .iter()
1790 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1791 .collect();
1792
1793 let mut hook_env =
1794 CommandEnvironment::from_repository_environment(ctx.environment.as_ref(), &merged_env);
1795 if cfg!(windows) {
1796 strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
1797 }
1798 hook_env.set.push((
1799 OsString::from("GIT_DIR"),
1800 ctx.git_dir.as_os_str().to_os_string(),
1801 ));
1802
1803 let capture = opts.stdio == HookStdio::Capture;
1804 let (stdout, stderr) = match opts.stdio {
1805 HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
1806 HookStdio::Inherit if opts.stdout_to_stderr => {
1807 (CommandStdio::ParentStderr, CommandStdio::Inherit)
1808 }
1809 HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
1810 };
1811 let mut output = HookOutput::default();
1812
1813 for h in &seq {
1814 let (stdin_piped, stdin_file) = match opts.path_to_stdin {
1815 Some(p) => (true, Some(p.to_path_buf())),
1816 None => (opts.stdin_data.is_some(), None),
1817 };
1818
1819 let hook_ctx = HookSpawnContext {
1820 runner,
1821 cwd: work_dir,
1822 env: &hook_env,
1823 stdin_piped,
1824 stdout,
1825 stderr,
1826 server_receive_hook: false,
1827 };
1828 let spawned = match h {
1829 ResolvedHook::Traditional { path, argv0 } => {
1830 let effective_argv0 = path
1831 .parent()
1832 .map(|hooks_dir| hook_argv0_for_ref(ctx, hooks_dir, hook_name, work_dir))
1833 .unwrap_or_else(|| argv0.clone());
1834 hook_ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
1835 }
1836 ResolvedHook::Configured { command } => {
1837 hook_ctx.spawn_configured(hook_name, command, args)
1838 }
1839 };
1840 let mut child = match spawned {
1841 Ok(child) => child,
1842 Err(HookError::Spawn {
1843 hook_name,
1844 path,
1845 source,
1846 ..
1847 }) => {
1848 return Err(HookError::Spawn {
1849 hook_name,
1850 path,
1851 source,
1852 output,
1853 })
1854 }
1855 Err(other) => return Err(other),
1856 };
1857
1858 let exit = if capture && stdin_piped && (stdin_file.is_some() || opts.stdin_data.is_some())
1859 {
1860 if cancel.is_cancelled() {
1861 let pid = child.child_pid();
1862 let kill_process_group = child.kill_process_group();
1863 let mut child = child;
1864 let _ = child.kill();
1865 if let Some(pid) = pid {
1866 kill_pid_sigkill(pid, kill_process_group);
1867 }
1868 return Err(HookError::Cancelled);
1869 }
1870 let output_result = if let Some(ref path) = stdin_file {
1871 let file = match fs::File::open(path) {
1872 Ok(f) => f,
1873 Err(_e) => {
1874 return Ok(HookResult::Failed { status: 1, output });
1875 }
1876 };
1877 child.feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::File(file)))
1878 } else if let Some(data) = opts.stdin_data {
1879 child.feed_stdin_and_wait_with_output(Some(ConcurrentStdinFeed::Bytes(data)))
1880 } else {
1881 child.close_stdin();
1882 child.wait_with_output()
1883 };
1884 match output_result {
1885 Ok(o) => {
1886 output.stdout.extend_from_slice(&o.stdout);
1887 output.stderr.extend_from_slice(&o.stderr);
1888 o.status
1889 }
1890 Err(_) => crate::command_runner::CommandExit {
1891 success: false,
1892 code: Some(1),
1893 },
1894 }
1895 } else {
1896 if let Some(ref path) = stdin_file {
1897 let file = match fs::File::open(path) {
1898 Ok(f) => f,
1899 Err(_e) => {
1900 return Ok(HookResult::Failed { status: 1, output });
1901 }
1902 };
1903 if let Some(stdin) = child.stdin_mut() {
1904 let mut file = file;
1905 let _ = crate::sigpipe::copy(&mut file, stdin);
1906 }
1907 } else if let Some(data) = opts.stdin_data {
1908 if let Some(stdin) = child.stdin_mut() {
1909 let _ = crate::sigpipe::write_all(stdin, data);
1910 }
1911 }
1912 if stdin_piped {
1913 child.close_stdin();
1914 }
1915 match wait_hook_command(child, capture, cancel, &mut output) {
1916 Ok(exit) => exit,
1917 Err(HookError::Cancelled) => return Err(HookError::Cancelled),
1918 Err(other) => return Err(other),
1919 }
1920 };
1921
1922 if !exit.success {
1923 return Ok(HookResult::Failed {
1924 status: exit.code.unwrap_or(1),
1925 output,
1926 });
1927 }
1928 }
1929
1930 Ok(HookResult::Success { output })
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935 use super::*;
1936
1937 fn touch(path: &Path) {
1938 fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
1939 fs::write(path, b"").expect("write");
1940 }
1941
1942 fn exe(name: &str) -> String {
1943 format!("{name}{}", std::env::consts::EXE_SUFFIX)
1944 }
1945
1946 #[test]
1947 fn hook_result_reports_status_output_and_checks() {
1948 let output = HookOutput {
1949 stdout: b"out".to_vec(),
1950 stderr: b"err".to_vec(),
1951 };
1952 assert!(!output.is_empty());
1953 assert!(HookOutput::default().is_empty());
1954 assert_eq!(HookError::Internal("bad".into()).output(), None);
1955 assert_eq!(
1956 format!("{output:?}"),
1957 r#"HookOutput { stdout: "out", stderr: "err" }"#
1958 );
1959
1960 let success = HookResult::Success {
1961 output: output.clone(),
1962 };
1963 assert!(success.is_ok() && success.was_executed());
1964 assert_eq!(success.status(), Some(0));
1965 assert_eq!(success.output(), Some(&output));
1966 assert!(success.check("pre-commit").expect("success"));
1967
1968 let missing = HookResult::NotFound;
1969 assert!(missing.is_ok() && !missing.was_executed());
1970 assert_eq!((missing.status(), missing.output()), (None, None));
1971 assert!(!missing.check("pre-commit").expect("not found"));
1972
1973 let failed = HookResult::Failed {
1974 status: 3,
1975 output: output.clone(),
1976 };
1977 assert!(!failed.is_ok() && failed.was_executed());
1978 assert_eq!(failed.status(), Some(3));
1979 match failed.check("commit-msg") {
1980 Err(err @ HookError::Failed { .. }) => {
1981 assert_eq!(err.to_string(), "hook commit-msg failed with exit status 3");
1982 let HookError::Failed {
1983 hook_name,
1984 status,
1985 output: got,
1986 } = err
1987 else {
1988 unreachable!()
1989 };
1990 assert_eq!((hook_name.as_str(), status), ("commit-msg", 3));
1991 assert_eq!(got, output);
1992 }
1993 other => panic!("expected HookError::Failed, got {other:?}"),
1994 }
1995 }
1996
1997 #[test]
1998 fn post_rewrite_stdin_is_one_old_new_line_per_pair() {
1999 let a = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("a");
2000 let b = ObjectId::from_hex("2222222222222222222222222222222222222222").expect("b");
2001 assert_eq!(post_rewrite_stdin(&[]), b"");
2002 assert_eq!(
2003 post_rewrite_stdin(&[(a, b), (b, a)]),
2004 format!(
2005 "{} {}\n{} {}\n",
2006 a.to_hex(),
2007 b.to_hex(),
2008 b.to_hex(),
2009 a.to_hex()
2010 )
2011 .into_bytes()
2012 );
2013 }
2014
2015 #[test]
2016 fn post_operation_names_and_flags() {
2017 assert_eq!(HookName::PostCheckout.as_str(), "post-checkout");
2018 assert_eq!(HookName::PostMerge.as_str(), "post-merge");
2019 assert_eq!(HookName::PostRewrite.as_str(), "post-rewrite");
2020 assert_eq!(HookName::PreMergeCommit.as_str(), "pre-merge-commit");
2021 assert_eq!(HookName::PrepareCommitMsg.as_str(), "prepare-commit-msg");
2022 assert_eq!(HookName::PreRebase.as_str(), "pre-rebase");
2023 assert_eq!(RewriteKind::Amend.as_str(), "amend");
2024 assert_eq!(RewriteKind::Rebase.as_str(), "rebase");
2025 assert!(PostOperationHooks::default().enabled());
2026 assert!(!PostOperationHooks::Skip.enabled());
2027 }
2028
2029 #[test]
2030 fn shebang_interpreter_takes_the_interpreter_basename() {
2031 assert_eq!(
2032 shebang_interpreter(b"#!/bin/sh\nexit 0\n").as_deref(),
2033 Some("sh")
2034 );
2035 assert_eq!(
2036 shebang_interpreter(b"#!/bin/bash -e\n").as_deref(),
2037 Some("bash")
2038 );
2039 assert_eq!(shebang_interpreter(b"#! /bin/sh\n").as_deref(), Some("sh"));
2040 assert_eq!(
2041 shebang_interpreter(b"#!/bin/sh\r\nexit 0\r\n").as_deref(),
2042 Some("sh")
2043 );
2044 assert_eq!(shebang_interpreter(b"#!/bin/sh").as_deref(), Some("sh"));
2045 assert_eq!(
2046 shebang_interpreter(br"#!C:\tools\perl.exe").as_deref(),
2047 Some("perl.exe")
2048 );
2049 }
2050
2051 #[test]
2052 fn shebang_interpreter_looks_through_env() {
2053 assert_eq!(
2054 shebang_interpreter(b"#!/usr/bin/env bash\n").as_deref(),
2055 Some("bash")
2056 );
2057 assert_eq!(
2058 shebang_interpreter(b"#!/usr/bin/env -S python3 -u\n").as_deref(),
2059 Some("python3")
2060 );
2061 assert_eq!(shebang_interpreter(b"#!/usr/bin/env\n"), None);
2062 }
2063
2064 #[test]
2065 fn shebang_interpreter_rejects_non_scripts() {
2066 assert_eq!(shebang_interpreter(b"exit 0\n"), None);
2067 assert_eq!(shebang_interpreter(b""), None);
2068 assert_eq!(shebang_interpreter(b"#!\n"), None);
2069 assert_eq!(shebang_interpreter(b"#!/\n"), None);
2070 assert_eq!(shebang_interpreter(b"\x7fELF\x02\x01"), None);
2071 }
2072
2073 #[cfg(unix)]
2074 #[test]
2075 fn hook_shell_is_bin_sh_on_unix() {
2076 let mut env = CommandEnvironment::default();
2077 env.set.push(("PATH".into(), "/nonexistent".into()));
2078 assert_eq!(hook_shell(&env), ScriptInterpreter::bare("/bin/sh"));
2079 }
2080
2081 #[test]
2082 fn interpreter_from_git_tree_gets_its_tool_dirs_on_path() {
2083 let tmp = tempfile::tempdir().expect("tempdir");
2084 let root = tmp.path().join("Git");
2085 touch(&root.join("cmd").join(exe("git")));
2086 touch(&root.join("mingw64").join("bin").join(exe("git")));
2087 touch(&root.join("usr").join("bin").join(exe("sh")));
2088 let mut env = CommandEnvironment::default();
2089 env.set
2090 .push(("PATH".into(), root.join("cmd").into_os_string()));
2091 let runner = crate::command_runner::RecordingRunner::always_success();
2092 let ctx = HookSpawnContext {
2093 runner: runner.as_ref(),
2094 cwd: tmp.path(),
2095 env: &env,
2096 stdin_piped: false,
2097 stdout: CommandStdio::Inherit,
2098 stderr: CommandStdio::Inherit,
2099 server_receive_hook: false,
2100 };
2101 let dash_c = || ShellInvocation::DashC {
2102 script: "true".into(),
2103 argv0: Some("hook".into()),
2104 args: Vec::new(),
2105 };
2106 let interpreter = crate::command_runner::resolve_script_interpreter(
2107 "sh",
2108 Some(root.join("cmd").as_os_str()),
2109 );
2110 let spec = ctx.interpreter_spec(interpreter, dash_c(), &[]);
2111 assert_eq!(spec.program, root.join("usr").join("bin").join(exe("sh")));
2112 let expected = std::env::join_paths([
2113 root.join("mingw64").join("bin"),
2114 root.join("usr").join("bin"),
2115 root.join("cmd"),
2116 ])
2117 .expect("join");
2118 assert_eq!(
2119 crate::command_runner::child_path_var(&spec.env),
2120 Some(expected)
2121 );
2122
2123 let bare = ctx.interpreter_spec(ScriptInterpreter::bare("/bin/sh"), dash_c(), &[]);
2124 assert_eq!(
2125 crate::command_runner::child_path_var(&bare.env),
2126 Some(root.join("cmd").into_os_string()),
2127 "interpreters outside a Git tree keep PATH as is"
2128 );
2129 }
2130
2131 #[test]
2132 fn verbatim_prefixes_are_stripped_from_path_vars() {
2133 let mut set: Vec<(OsString, OsString)> = vec![
2134 ("GIT_INDEX_FILE".into(), r"\\?\C:\repo\.git\index".into()),
2135 ("GIT_DIR".into(), r"\\?\UNC\server\share\repo\.git".into()),
2136 ("GIT_WORK_TREE".into(), r"C:\repo".into()),
2137 (
2138 "GIT_OBJECT_DIRECTORY".into(),
2139 r"\\?\C:\repo\.git\objects".into(),
2140 ),
2141 ("GIT_PREFIX".into(), r"\\?\kept".into()),
2142 ];
2143 strip_verbatim_prefix_from_path_vars(&mut set);
2144 let value = |key: &str| {
2145 set.iter()
2146 .find(|(k, _)| k == key)
2147 .map(|(_, v)| v.clone())
2148 .expect("present")
2149 };
2150 assert_eq!(
2151 value("GIT_INDEX_FILE"),
2152 OsString::from(r"C:\repo\.git\index")
2153 );
2154 assert_eq!(
2155 value("GIT_DIR"),
2156 OsString::from(r"\\server\share\repo\.git")
2157 );
2158 assert_eq!(value("GIT_WORK_TREE"), OsString::from(r"C:\repo"));
2159 assert_eq!(
2160 value("GIT_OBJECT_DIRECTORY"),
2161 OsString::from(r"C:\repo\.git\objects")
2162 );
2163 assert_eq!(value("GIT_PREFIX"), OsString::from(r"\\?\kept"));
2164 }
2165
2166 #[cfg(unix)]
2167 #[test]
2168 fn run_hook_in_git_dir_drains_stderr_while_reading_large_stdin() {
2169 use crate::repo::{InitOptions, Repository};
2170
2171 let dir = tempfile::tempdir().expect("tempdir");
2172 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
2173 let hook = repo.git_dir().join("hooks").join("pre-receive");
2174 fs::write(
2175 &hook,
2176 b"#!/bin/sh\nwhile read old new ref; do echo \"checking $ref\" >&2; done\nexit 0\n",
2177 )
2178 .expect("write hook");
2179 let mut perms = fs::metadata(&hook).expect("meta").permissions();
2180 perms.set_mode(0o755);
2181 fs::set_permissions(&hook, perms).expect("chmod");
2182
2183 let stdin: Vec<u8> = (1..=3000)
2184 .map(|i| {
2185 format!(
2186 "1111111111111111111111111111111111111111 \
2187 2222222222222222222222222222222222222222 refs/heads/b{i}\n"
2188 )
2189 })
2190 .collect::<String>()
2191 .into_bytes();
2192
2193 let started = std::time::Instant::now();
2194 let result = run_hook_in_git_dir(&repo, "pre-receive", &[], Some(&stdin), &[]);
2195 assert!(
2196 started.elapsed() < std::time::Duration::from_secs(5),
2197 "pre-receive hook I/O took {:?}",
2198 started.elapsed()
2199 );
2200 assert!(result.is_ok());
2201 let output = result.output().expect("hook output");
2202 assert!(output.stderr.len() > 64 * 1024);
2203 assert!(output.stderr.starts_with(b"checking "));
2204 }
2205
2206 #[test]
2207 fn not_an_executable_matches_only_the_platform_code() {
2208 #[cfg(unix)]
2209 assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(8)));
2210 #[cfg(windows)]
2211 assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(
2212 193
2213 )));
2214 assert!(!is_not_an_executable(&std::io::Error::from(
2215 std::io::ErrorKind::NotFound
2216 )));
2217 }
2218}