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::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 thiserror::Error;
26
27fn is_not_an_executable(err: &std::io::Error) -> bool {
30 #[cfg(unix)]
31 const NOT_EXECUTABLE: i32 = 8;
32 #[cfg(windows)]
33 const NOT_EXECUTABLE: i32 = 193;
34 #[cfg(not(any(unix, windows)))]
35 const NOT_EXECUTABLE: i32 = -1;
36 err.raw_os_error() == Some(NOT_EXECUTABLE)
37}
38
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub enum HookStdio {
45 #[default]
49 Capture,
50 Inherit,
54}
55
56#[derive(Clone, Default, PartialEq, Eq)]
61pub struct HookOutput {
62 pub stdout: Vec<u8>,
64 pub stderr: Vec<u8>,
66}
67
68impl std::fmt::Debug for HookOutput {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("HookOutput")
71 .field("stdout", &String::from_utf8_lossy(&self.stdout))
72 .field("stderr", &String::from_utf8_lossy(&self.stderr))
73 .finish()
74 }
75}
76
77impl HookOutput {
78 #[must_use]
80 pub fn is_empty(&self) -> bool {
81 self.stdout.is_empty() && self.stderr.is_empty()
82 }
83}
84
85#[derive(Debug, Error)]
87pub enum HookError {
88 #[error("hook {hook_name} failed with exit status {status}")]
92 Failed {
93 hook_name: String,
95 status: i32,
97 output: HookOutput,
99 },
100 #[error("failed to run hook {hook_name} ({})", path.display())]
102 Spawn {
103 hook_name: String,
105 path: PathBuf,
107 #[source]
109 source: std::io::Error,
110 output: HookOutput,
114 },
115 #[error("{0}")]
117 Internal(String),
118}
119
120impl HookError {
121 #[must_use]
123 pub fn output(&self) -> Option<&HookOutput> {
124 match self {
125 HookError::Failed { output, .. } | HookError::Spawn { output, .. } => Some(output),
126 HookError::Internal(_) => None,
127 }
128 }
129}
130
131impl From<String> for HookError {
132 fn from(value: String) -> Self {
133 Self::Internal(value)
134 }
135}
136
137fn hook_command_env(git_dir: Option<&Path>, extra_env: &[(String, String)]) -> CommandEnvironment {
138 let mut env = CommandEnvironment::inherit_process_only();
139 if let Some(gd) = git_dir {
140 env.set
141 .push((OsString::from("GIT_DIR"), gd.as_os_str().to_os_string()));
142 }
143 for (k, v) in extra_env {
144 env.set
145 .push((OsString::from(k.as_str()), OsString::from(v.as_str())));
146 }
147 env
148}
149
150#[derive(Debug, Clone, Default)]
152pub struct CommitHookEnv<'a> {
153 pub index_file: Option<&'a Path>,
155 pub git_editor: Option<&'a str>,
157 pub git_prefix: Option<&'a str>,
159 pub extra_env: &'a [(&'a str, &'a str)],
161}
162
163fn absolute_index_path(index_file: &Path, cwd: &Path) -> PathBuf {
164 if index_file.is_absolute() {
165 index_file.to_path_buf()
166 } else {
167 cwd.join(index_file)
168 }
169}
170
171fn git_prefix_for_invocation(repo: &Repository, invocation_cwd: &Path) -> String {
174 let Some(wt) = repo.work_tree() else {
175 return String::new();
176 };
177 if invocation_cwd == repo.git_dir() {
178 return String::new();
179 }
180 let wt_canon = wt
181 .canonicalize_simplified()
182 .unwrap_or_else(|_| wt.to_path_buf());
183 let wd_canon = invocation_cwd
184 .canonicalize_simplified()
185 .unwrap_or_else(|_| invocation_cwd.to_path_buf());
186 let rel = wd_canon.strip_prefix(&wt_canon).ok();
187 let Some(rel) = rel else {
188 return String::new();
189 };
190 let Some(s) = rel.to_str() else {
191 return String::new();
192 };
193 if s.is_empty() {
194 return String::new();
195 }
196 let mut out = s.replace('\\', "/");
197 if !out.ends_with('/') {
198 out.push('/');
199 }
200 out
201}
202
203fn build_commit_hook_env(
204 repo: &Repository,
205 _work_dir: &Path,
206 opts: &CommitHookEnv<'_>,
207) -> Vec<(String, String)> {
208 let mut env: Vec<(String, String)> = Vec::new();
209 if let Some(p) = opts.index_file {
210 env.push((
211 "GIT_INDEX_FILE".to_string(),
212 absolute_index_path(p, &repo.environment().cwd)
213 .to_string_lossy()
214 .into_owned(),
215 ));
216 }
217 let invocation_cwd = repo.environment().discovery_cwd();
218 let prefix = opts
219 .git_prefix
220 .map(|s| s.to_string())
221 .unwrap_or_else(|| git_prefix_for_invocation(repo, &invocation_cwd));
222 env.push(("GIT_PREFIX".to_string(), prefix));
223 if let Some(ed) = opts.git_editor {
224 env.push(("GIT_EDITOR".to_string(), ed.to_string()));
225 }
226 for (k, v) in opts.extra_env {
227 env.push(((*k).to_string(), (*v).to_string()));
228 }
229 env
230}
231
232fn parse_maybe_bool(value: &str) -> Option<bool> {
234 let v = value.trim().to_ascii_lowercase();
235 match v.as_str() {
236 "true" | "yes" | "on" | "1" => Some(true),
237 "false" | "no" | "off" | "0" => Some(false),
238 _ => None,
239 }
240}
241
242fn parse_hook_config_key(key: &str) -> Option<(&str, &str)> {
244 let rest = key.strip_prefix("hook.")?;
245 let (subsection, var) = rest.rsplit_once('.')?;
246 if subsection.is_empty() || var.is_empty() {
247 return None;
248 }
249 Some((subsection, var))
250}
251
252#[derive(Debug, Default)]
254struct HookConfigTables {
255 commands: HashMap<String, String>,
257 event_hooks: HashMap<String, VecDeque<String>>,
259 disabled: HashSet<String>,
260}
261
262impl HookConfigTables {
263 fn apply_entry(&mut self, key: &str, value: Option<&str>) {
264 let Some((hook_name, subkey)) = parse_hook_config_key(key) else {
265 return;
266 };
267 let Some(value) = value else {
268 return;
269 };
270 let hook_name = hook_name.to_string();
271
272 match subkey {
273 "event" => {
274 if value.is_empty() {
275 for hooks in self.event_hooks.values_mut() {
276 hooks.retain(|n| n != &hook_name);
277 }
278 } else {
279 let event = value.to_string();
280 let hooks = self.event_hooks.entry(event).or_default();
281 hooks.retain(|n| n != &hook_name);
282 hooks.push_back(hook_name);
283 }
284 }
285 "command" => {
286 self.commands.insert(hook_name, value.to_string());
287 }
288 "enabled" => match parse_maybe_bool(value) {
289 Some(false) => {
290 self.disabled.insert(hook_name);
291 }
292 Some(true) => {
293 self.disabled.remove(&hook_name);
294 }
295 None => {}
296 },
297 _ => {}
298 }
299 }
300
301 fn from_config(config: &ConfigSet) -> Self {
302 let mut t = Self::default();
303 for e in config.entries() {
304 t.apply_entry(&e.key, e.value.as_deref());
305 }
306 t
307 }
308
309 fn hooks_for_event(&self, event: &str) -> Result<Vec<(String, String)>, String> {
313 let Some(names) = self.event_hooks.get(event) else {
314 return Ok(Vec::new());
315 };
316 let mut out = Vec::new();
317 for name in names {
318 if self.disabled.contains(name) {
319 continue;
320 }
321 let Some(cmd) = self.commands.get(name) else {
322 return Err(format!(
323 "'hook.{name}.command' must be configured or 'hook.{name}.event' must be removed; aborting."
324 ));
325 };
326 out.push((name.clone(), cmd.clone()));
327 }
328 Ok(out)
329 }
330}
331
332#[derive(Debug)]
334enum ResolvedHook {
335 Configured { command: String },
336 Traditional { path: PathBuf, argv0: PathBuf },
337}
338
339pub fn resolve_hooks_dir(repo: &Repository) -> PathBuf {
348 resolve_hooks_dir_for_config(repo, repo.config().ok().as_deref())
349}
350
351fn resolve_hooks_dir_for_config(repo: &Repository, config: Option<&ConfigSet>) -> PathBuf {
352 let dir = match config.and_then(|cfg| cfg.get("core.hooksPath")) {
353 Some(hooks_path) => {
354 let p = PathBuf::from(parse_path(repo.environment(), &hooks_path));
355 if p.is_absolute() {
356 p
357 } else {
358 hook_run_dir(repo).join(p)
359 }
360 }
361 None => crate::repo::common_git_dir_for_config(repo.git_dir()).join("hooks"),
362 };
363 path_without_verbatim_prefix(&dir).into_owned()
364}
365
366fn hook_run_dir(repo: &Repository) -> &Path {
368 repo.work_tree().unwrap_or(repo.git_dir())
369}
370
371fn hook_argv0(repo: &Repository, hooks_dir: &Path, hook_name: &str, cwd: &Path) -> PathBuf {
372 let default_hooks_dir = repo.git_dir().join("hooks");
373 if same_dir(hooks_dir, &default_hooks_dir) {
374 if cwd == repo.git_dir() {
375 return PathBuf::from("hooks").join(hook_name);
376 }
377 if let Some(work_tree) = repo.work_tree() {
378 if cwd == work_tree && same_dir(repo.git_dir(), &work_tree.join(".git")) {
386 return PathBuf::from(".git").join("hooks").join(hook_name);
387 }
388 }
389 }
390 hooks_dir.join(hook_name)
391}
392
393fn same_dir(a: &Path, b: &Path) -> bool {
396 if a == b {
397 return true;
398 }
399 match (a.canonicalize_simplified(), b.canonicalize_simplified()) {
400 (Ok(ca), Ok(cb)) => ca == cb,
401 _ => false,
402 }
403}
404
405#[cfg_attr(not(unix), allow(unused_variables))]
408fn traditional_hook_candidate(
409 repo: &Repository,
410 hooks_dir: &Path,
411 hook_name: &str,
412) -> Option<PathBuf> {
413 let path = hooks_dir.join(hook_name);
414 if !path.exists() {
415 return None;
416 }
417 let meta = fs::metadata(&path).ok()?;
418 #[cfg(unix)]
419 if meta.permissions().mode() & 0o111 == 0 {
420 let config = repo.config().ok();
421 let show_warning = config
422 .as_deref()
423 .and_then(|c| c.get("advice.ignoredHook"))
424 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
425 .unwrap_or(true);
426 if show_warning {
427 repo.warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
428 hook_name: hook_name.to_string(),
429 hook_path: Some(path.clone()),
430 });
431 }
432 return None;
433 }
434 Some(path)
435}
436
437fn resolve_configured_hooks_only(
439 hook_name: &str,
440 config: &ConfigSet,
441) -> Result<Vec<ResolvedHook>, String> {
442 let tables = HookConfigTables::from_config(config);
443 let mut seq = Vec::new();
444 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
445 seq.push(ResolvedHook::Configured { command });
446 }
447 Ok(seq)
448}
449
450fn resolve_hook_sequence(
452 repo: &Repository,
453 hook_name: &str,
454 config: &ConfigSet,
455) -> Result<Vec<ResolvedHook>, String> {
456 let tables = HookConfigTables::from_config(config);
457 let mut seq = Vec::new();
458 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
459 seq.push(ResolvedHook::Configured { command });
460 }
461 let hooks_dir = resolve_hooks_dir_for_config(repo, Some(config));
462 if let Some(path) = traditional_hook_candidate(repo, &hooks_dir, hook_name) {
463 let argv0 = hook_argv0(repo, &hooks_dir, hook_name, hook_run_dir(repo));
464 seq.push(ResolvedHook::Traditional { path, argv0 });
465 }
466 Ok(seq)
467}
468
469#[cfg_attr(not(windows), allow(dead_code))]
474fn shebang_interpreter(head: &[u8]) -> Option<String> {
475 let rest = head.strip_prefix(b"#!")?;
476 let line_len = rest
477 .iter()
478 .position(|&b| b == b'\n' || b == b'\r')
479 .unwrap_or(rest.len());
480 let line = std::str::from_utf8(&rest[..line_len]).ok()?;
481 let basename = |word: &str| word.rsplit(['/', '\\']).next().map(str::to_owned);
482 let mut words = line.split_ascii_whitespace();
483 let mut name = basename(words.next()?)?;
484 if name == "env" {
485 name = basename(words.find(|w| !w.starts_with('-'))?)?;
486 }
487 (!name.is_empty()).then_some(name)
488}
489
490fn strip_verbatim_prefix_from_path_vars(set: &mut [(OsString, OsString)]) {
496 crate::git_path::strip_verbatim_prefix_from_subprocess_path_vars(set);
497}
498
499fn hook_shell(env: &CommandEnvironment) -> ScriptInterpreter {
504 crate::command_runner::shell_interpreter(env)
505}
506
507#[cfg(windows)]
509fn script_shebang_interpreter(
510 script: &Path,
511 env: &CommandEnvironment,
512) -> Option<ScriptInterpreter> {
513 use std::io::Read;
514 let mut head = [0u8; 256];
515 let mut file = fs::File::open(script).ok()?;
516 let n = file.read(&mut head).ok()?;
517 let name = shebang_interpreter(&head[..n])?;
518 Some(crate::command_runner::resolve_script_interpreter(
519 &name,
520 crate::command_runner::child_path_var(env).as_deref(),
521 ))
522}
523
524struct HookSpawnContext<'a> {
526 runner: &'a dyn CommandRunner,
527 cwd: &'a Path,
528 env: &'a CommandEnvironment,
529 stdin_piped: bool,
530 stdout: CommandStdio,
531 stderr: CommandStdio,
532}
533
534impl HookSpawnContext<'_> {
535 fn spec(&self, program: PathBuf, shell: Option<ShellInvocation>, args: &[&str]) -> CommandSpec {
536 CommandSpec {
537 program,
538 args: if shell.is_some() {
539 Vec::new()
540 } else {
541 args.iter().map(OsString::from).collect()
542 },
543 shell,
544 cwd: Some(path_without_verbatim_prefix(self.cwd).into_owned()),
545 env: self.env.clone(),
546 stdin: if self.stdin_piped {
547 CommandStdin::Pipe(Vec::new())
548 } else {
549 CommandStdin::Inherit
550 },
551 stdout: self.stdout,
552 stderr: self.stderr,
553 }
554 }
555
556 fn interpreter_spec(
559 &self,
560 interpreter: ScriptInterpreter,
561 invocation: ShellInvocation,
562 args: &[&str],
563 ) -> CommandSpec {
564 let mut spec = self.spec(interpreter.program, Some(invocation), args);
565 if let Some(root) = &interpreter.git_root {
566 prepend_to_child_path(&mut spec.env, &git_for_windows_tool_dirs(root));
567 }
568 spec
569 }
570
571 fn spawn(&self, spec: &CommandSpec, hook_name: &str) -> Result<RunningCommand, HookError> {
572 self.runner.spawn(spec).map_err(|source| HookError::Spawn {
573 hook_name: hook_name.to_owned(),
574 path: spec.program.clone(),
575 source,
576 output: HookOutput::default(),
577 })
578 }
579
580 #[cfg_attr(not(windows), allow(unused_variables))]
584 fn spawn_traditional(
585 &self,
586 hook_name: &str,
587 script: &Path,
588 argv0: &Path,
589 args: &[&str],
590 ) -> Result<RunningCommand, HookError> {
591 let argv0 = path_without_verbatim_prefix(argv0);
592 let via_shell = |shell: ScriptInterpreter| {
593 let invocation = ShellInvocation::ScriptPath {
594 script_path: argv0.to_path_buf(),
595 args: args.iter().map(OsString::from).collect(),
596 };
597 self.interpreter_spec(shell, invocation, args)
598 };
599 #[cfg(windows)]
600 if let Some(interpreter) = script_shebang_interpreter(script, self.env) {
601 return self.spawn(&via_shell(interpreter), hook_name);
602 }
603 let direct = self.spec(argv0.to_path_buf(), None, args);
604 match self.runner.spawn(&direct) {
605 Err(e) if is_not_an_executable(&e) => {
606 self.spawn(&via_shell(hook_shell(self.env)), hook_name)
607 }
608 Err(source) => Err(HookError::Spawn {
609 hook_name: hook_name.to_owned(),
610 path: direct.program,
611 source,
612 output: HookOutput::default(),
613 }),
614 Ok(child) => Ok(child),
615 }
616 }
617
618 fn spawn_configured(
620 &self,
621 hook_name: &str,
622 command: &str,
623 args: &[&str],
624 ) -> Result<RunningCommand, HookError> {
625 let invocation = ShellInvocation::DashC {
626 script: command.to_owned(),
627 argv0: Some("hook".to_owned()),
628 args: args.iter().map(OsString::from).collect(),
629 };
630 self.spawn(
631 &self.interpreter_spec(hook_shell(self.env), invocation, args),
632 hook_name,
633 )
634 }
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
641#[cfg_attr(feature = "serde-models", serde(rename_all = "kebab-case"))]
642pub enum HookName {
643 PreCommit,
645 PreMergeCommit,
647 PrepareCommitMsg,
650 CommitMsg,
652 PostCommit,
654 PostCheckout,
656 PostMerge,
658 PostRewrite,
660 PreRebase,
663}
664
665impl HookName {
666 #[must_use]
668 pub const fn as_str(self) -> &'static str {
669 match self {
670 Self::PreCommit => "pre-commit",
671 Self::PreMergeCommit => "pre-merge-commit",
672 Self::PrepareCommitMsg => "prepare-commit-msg",
673 Self::PreRebase => "pre-rebase",
674 Self::CommitMsg => "commit-msg",
675 Self::PostCommit => "post-commit",
676 Self::PostCheckout => "post-checkout",
677 Self::PostMerge => "post-merge",
678 Self::PostRewrite => "post-rewrite",
679 }
680 }
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
690pub enum PostOperationHooks {
691 #[default]
693 Run,
694 Skip,
696}
697
698impl PostOperationHooks {
699 #[must_use]
701 pub const fn enabled(self) -> bool {
702 matches!(self, Self::Run)
703 }
704}
705
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
708pub enum RewriteKind {
709 Amend,
711 Rebase,
713}
714
715impl RewriteKind {
716 #[must_use]
718 pub const fn as_str(self) -> &'static str {
719 match self {
720 Self::Amend => "amend",
721 Self::Rebase => "rebase",
722 }
723 }
724}
725
726pub(crate) fn run_advisory_hook(
734 repo: &Repository,
735 hook: HookName,
736 args: &[&str],
737 stdin_data: Option<&[u8]>,
738 commit_env: Option<&CommitHookEnv<'_>>,
739) -> bool {
740 let result = match commit_env {
741 Some(env) => run_commit_hook(repo, hook.as_str(), args, stdin_data, env),
742 None => run_hook(repo, hook.as_str(), args, stdin_data),
743 };
744 match result {
745 Ok(result) => result.was_executed(),
746 Err(err) => {
747 let mut detail = err.to_string();
748 let mut cause = std::error::Error::source(&err);
749 while let Some(c) = cause {
750 detail.push_str(": ");
751 detail.push_str(&c.to_string());
752 cause = c.source();
753 }
754 repo.warn(crate::diagnostics::Warning::HookNotRun {
755 hook_name: hook.as_str().to_owned(),
756 detail,
757 });
758 false
759 }
760 }
761}
762
763pub fn run_post_checkout(
773 repo: &Repository,
774 old: Option<ObjectId>,
775 new: ObjectId,
776 branch_checkout: bool,
777) -> bool {
778 let old_hex = old
779 .unwrap_or_else(|| ObjectId::null(repo.odb().hash_algo()))
780 .to_hex();
781 let new_hex = new.to_hex();
782 let flag = if branch_checkout { "1" } else { "0" };
783 run_advisory_hook(
784 repo,
785 HookName::PostCheckout,
786 &[old_hex.as_str(), new_hex.as_str(), flag],
787 None,
788 None,
789 )
790}
791
792pub fn run_post_merge(repo: &Repository, squash: bool) -> bool {
797 run_advisory_hook(
798 repo,
799 HookName::PostMerge,
800 &[if squash { "1" } else { "0" }],
801 None,
802 None,
803 )
804}
805
806pub fn run_pre_rebase(repo: &Repository, upstream: Option<&str>) -> Result<bool, HookError> {
820 let configured;
821 let upstream_arg = match upstream {
822 Some(arg) => arg,
823 None => {
824 configured = crate::state::resolve_head_for_repository(repo)
825 .ok()
826 .and_then(|head| {
827 head.branch_name().and_then(|branch| {
828 crate::branch_tracking::upstream_tracking_full_ref(repo, branch)
829 })
830 })
831 .unwrap_or_else(|| "@{upstream}".to_owned());
832 configured.as_str()
833 }
834 };
835 let name = HookName::PreRebase.as_str();
836 run_hook(repo, name, &[upstream_arg], None)?.check(name)
837}
838
839#[must_use]
841pub fn post_rewrite_stdin(pairs: &[(ObjectId, ObjectId)]) -> Vec<u8> {
842 let mut out = Vec::with_capacity(pairs.len() * 82);
843 for (old, new) in pairs {
844 out.extend_from_slice(old.to_hex().as_bytes());
845 out.push(b' ');
846 out.extend_from_slice(new.to_hex().as_bytes());
847 out.push(b'\n');
848 }
849 out
850}
851
852pub fn run_post_rewrite(
857 repo: &Repository,
858 kind: RewriteKind,
859 pairs: &[(ObjectId, ObjectId)],
860) -> bool {
861 if pairs.is_empty() {
862 return false;
863 }
864 let stdin = post_rewrite_stdin(pairs);
865 run_advisory_hook(
866 repo,
867 HookName::PostRewrite,
868 &[kind.as_str()],
869 Some(&stdin),
870 None,
871 )
872}
873
874#[derive(Debug, Clone, PartialEq, Eq)]
876pub enum HookResult {
877 Success {
879 output: HookOutput,
881 },
882 NotFound,
884 Failed {
886 status: i32,
888 output: HookOutput,
891 },
892}
893
894impl HookResult {
895 #[must_use]
897 pub fn is_ok(&self) -> bool {
898 matches!(self, HookResult::Success { .. } | HookResult::NotFound)
899 }
900
901 #[must_use]
903 pub fn was_executed(&self) -> bool {
904 !matches!(self, HookResult::NotFound)
905 }
906
907 #[must_use]
909 pub fn status(&self) -> Option<i32> {
910 match self {
911 HookResult::Success { .. } => Some(0),
912 HookResult::NotFound => None,
913 HookResult::Failed { status, .. } => Some(*status),
914 }
915 }
916
917 #[must_use]
919 pub fn output(&self) -> Option<&HookOutput> {
920 match self {
921 HookResult::Success { output } | HookResult::Failed { output, .. } => Some(output),
922 HookResult::NotFound => None,
923 }
924 }
925
926 pub fn check(self, hook_name: &str) -> Result<bool, HookError> {
932 match self {
933 HookResult::Success { .. } => Ok(true),
934 HookResult::NotFound => Ok(false),
935 HookResult::Failed { status, output } => Err(HookError::Failed {
936 hook_name: hook_name.to_owned(),
937 status,
938 output,
939 }),
940 }
941 }
942}
943
944#[derive(Debug, Clone, Default)]
946pub struct RunHookOptions<'a> {
947 pub stdio: HookStdio,
951 pub stdout_to_stderr: bool,
954 pub path_to_stdin: Option<&'a Path>,
956 pub stdin_data: Option<&'a [u8]>,
958 pub env_vars: &'a [(&'a str, &'a str)],
960 pub cwd: Option<&'a Path>,
962 pub commit_env: Option<&'a CommitHookEnv<'a>>,
964}
965
966pub fn run_hook_opts(
981 repo: Option<&Repository>,
982 hook_name: &str,
983 args: &[&str],
984 config: &ConfigSet,
985 opts: RunHookOptions<'_>,
986) -> Result<HookResult, HookError> {
987 let seq = match repo {
988 Some(r) => resolve_hook_sequence(r, hook_name, config)?,
989 None => resolve_configured_hooks_only(hook_name, config)?,
990 };
991 if seq.is_empty() {
992 return Ok(HookResult::NotFound);
993 }
994
995 let work_dir: PathBuf = opts.cwd.map_or_else(
996 || match repo {
997 Some(r) => hook_run_dir(r).to_path_buf(),
998 None => PathBuf::from("."),
999 },
1000 Path::to_path_buf,
1001 );
1002 let work_dir = work_dir.as_path();
1003 let git_dir_for_configured = repo.map(|r| r.git_dir());
1004 let runner: Arc<dyn CommandRunner> = match repo {
1005 Some(r) => r.command_runner(),
1006 None => system_command_runner(),
1007 };
1008
1009 let mut merged_env: Vec<(String, String)> = opts
1010 .env_vars
1011 .iter()
1012 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1013 .collect();
1014 if let Some(r) = repo {
1015 if let Some(ce) = opts.commit_env {
1016 merged_env.extend(build_commit_hook_env(r, work_dir, ce));
1017 }
1018 }
1019
1020 let mut hook_env = match repo {
1021 Some(r) => CommandEnvironment::from_repository_environment(r.environment(), &merged_env),
1022 None => hook_command_env(git_dir_for_configured, &merged_env),
1023 };
1024 if cfg!(windows) {
1025 strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
1026 }
1027
1028 let capture = opts.stdio == HookStdio::Capture;
1029 let (stdout, stderr) = match opts.stdio {
1030 HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
1031 HookStdio::Inherit if opts.stdout_to_stderr => {
1032 (CommandStdio::ParentStderr, CommandStdio::Inherit)
1033 }
1034 HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
1035 };
1036 let mut output = HookOutput::default();
1037
1038 for h in &seq {
1039 let (stdin_piped, stdin_file) = match opts.path_to_stdin {
1040 Some(p) => (true, Some(p.to_path_buf())),
1041 None => (opts.stdin_data.is_some(), None),
1042 };
1043
1044 let ctx = HookSpawnContext {
1045 runner: runner.as_ref(),
1046 cwd: work_dir,
1047 env: &hook_env,
1048 stdin_piped,
1049 stdout,
1050 stderr,
1051 };
1052 let spawned = match h {
1053 ResolvedHook::Traditional { path, argv0 } => {
1054 let Some(r) = repo else {
1055 continue;
1056 };
1057 let effective_argv0 = path
1058 .parent()
1059 .map(|hooks_dir| hook_argv0(r, hooks_dir, hook_name, work_dir))
1060 .unwrap_or_else(|| argv0.clone());
1061 ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
1062 }
1063 ResolvedHook::Configured { command } => ctx.spawn_configured(hook_name, command, args),
1064 };
1065 let mut child = match spawned {
1066 Ok(child) => child,
1067 Err(HookError::Spawn {
1068 hook_name,
1069 path,
1070 source,
1071 ..
1072 }) => {
1073 return Err(HookError::Spawn {
1074 hook_name,
1075 path,
1076 source,
1077 output,
1078 })
1079 }
1080 Err(other) => return Err(other),
1081 };
1082
1083 if let Some(ref path) = stdin_file {
1084 let file = match fs::File::open(path) {
1085 Ok(f) => f,
1086 Err(_e) => {
1087 return Ok(HookResult::Failed { status: 1, output });
1088 }
1089 };
1090 if let Some(stdin) = child.stdin_mut() {
1091 let mut file = file;
1092 let _ = std::io::copy(&mut file, stdin);
1093 }
1094 } else if let Some(data) = opts.stdin_data {
1095 if let Some(stdin) = child.stdin_mut() {
1096 let _ = stdin.write_all(data);
1097 }
1098 }
1099 if stdin_piped {
1100 child.close_stdin();
1101 }
1102
1103 let waited = if capture {
1104 child.wait_with_output().map(|o| {
1105 output.stdout.extend_from_slice(&o.stdout);
1106 output.stderr.extend_from_slice(&o.stderr);
1107 o.status
1108 })
1109 } else {
1110 child.wait()
1111 };
1112 let exit = match waited {
1113 Ok(exit) => exit,
1114 Err(_) => return Ok(HookResult::Failed { status: 1, output }),
1115 };
1116
1117 if !exit.success {
1118 if let Some(r) = repo {
1119 let _ = r.reload_config();
1120 }
1121 return Ok(HookResult::Failed {
1122 status: exit.code.unwrap_or(1),
1123 output,
1124 });
1125 }
1126 }
1127
1128 if let Some(r) = repo {
1129 let _ = r.reload_config();
1130 }
1131
1132 Ok(HookResult::Success { output })
1133}
1134
1135pub fn run_commit_hook_checked(
1142 repo: &Repository,
1143 hook_name: &str,
1144 args: &[&str],
1145 stdin_data: Option<&[u8]>,
1146 commit_env: &CommitHookEnv<'_>,
1147) -> Result<(), HookError> {
1148 run_commit_hook(repo, hook_name, args, stdin_data, commit_env)?
1149 .check(hook_name)
1150 .map(drop)
1151}
1152
1153pub fn run_commit_hook(
1162 repo: &Repository,
1163 hook_name: &str,
1164 args: &[&str],
1165 stdin_data: Option<&[u8]>,
1166 commit_env: &CommitHookEnv<'_>,
1167) -> Result<HookResult, HookError> {
1168 let config = repo
1169 .config()
1170 .map_err(|e| HookError::Internal(e.to_string()))?;
1171 run_hook_opts(
1172 Some(repo),
1173 hook_name,
1174 args,
1175 config.as_ref(),
1176 RunHookOptions {
1177 stdin_data,
1178 commit_env: Some(commit_env),
1179 ..repo_hook_options(repo, hook_name)
1180 },
1181 )
1182}
1183
1184fn repo_hook_options<'a>(repo: &Repository, hook_name: &str) -> RunHookOptions<'a> {
1187 RunHookOptions {
1188 stdio: repo.hook_stdio(),
1189 stdout_to_stderr: hook_name != "pre-push",
1190 ..RunHookOptions::default()
1191 }
1192}
1193
1194pub fn run_hook(
1204 repo: &Repository,
1205 hook_name: &str,
1206 args: &[&str],
1207 stdin_data: Option<&[u8]>,
1208) -> Result<HookResult, HookError> {
1209 let config = repo
1210 .config()
1211 .map_err(|e| HookError::Internal(e.to_string()))?;
1212 run_hook_opts(
1213 Some(repo),
1214 hook_name,
1215 args,
1216 config.as_ref(),
1217 RunHookOptions {
1218 stdin_data,
1219 ..repo_hook_options(repo, hook_name)
1220 },
1221 )
1222}
1223
1224pub fn run_hook_in_git_dir(
1233 repo: &Repository,
1234 hook_name: &str,
1235 args: &[&str],
1236 stdin_data: Option<&[u8]>,
1237 env_vars: &[(&str, &str)],
1238) -> HookResult {
1239 let declined = || HookResult::Failed {
1240 status: 1,
1241 output: HookOutput::default(),
1242 };
1243 let env_vars: Vec<(&str, &str)> = std::iter::once(("GIT_DIR", "."))
1244 .chain(env_vars.iter().copied())
1245 .collect();
1246 let Some(config) = repo.config().ok() else {
1247 return declined();
1248 };
1249 run_hook_opts(
1250 Some(repo),
1251 hook_name,
1252 args,
1253 config.as_ref(),
1254 RunHookOptions {
1255 stdio: HookStdio::Capture,
1256 stdout_to_stderr: true,
1257 stdin_data,
1258 env_vars: &env_vars,
1259 cwd: Some(repo.git_dir()),
1260 ..RunHookOptions::default()
1261 },
1262 )
1263 .unwrap_or_else(|_| declined())
1264}
1265
1266#[derive(Clone, Debug, PartialEq, Eq)]
1268pub struct PrePushRef {
1269 pub local_ref: Option<String>,
1272 pub local_oid: Option<ObjectId>,
1274 pub remote_ref: String,
1276 pub remote_oid: Option<ObjectId>,
1278}
1279
1280impl PrePushRef {
1281 fn stdin_line(&self, algo: crate::objects::HashAlgo) -> String {
1286 let zero = "0".repeat(algo.hex_len());
1287 let hex = |oid: Option<ObjectId>| oid.map_or_else(|| zero.clone(), |o| o.to_hex());
1288 let local_ref = match (&self.local_ref, self.local_oid) {
1289 (Some(name), _) => name.clone(),
1290 (None, Some(oid)) => oid.to_hex(),
1291 (None, None) => "(delete)".to_owned(),
1292 };
1293 format!(
1294 "{local_ref} {} {} {}\n",
1295 hex(self.local_oid),
1296 self.remote_ref,
1297 hex(self.remote_oid)
1298 )
1299 }
1300}
1301
1302pub fn run_pre_push_hook(
1319 repo: &Repository,
1320 remote: &str,
1321 url: &str,
1322 refs: &[PrePushRef],
1323) -> Result<HookOutput, HookError> {
1324 let config = repo
1325 .config()
1326 .map_err(|e| HookError::Internal(e.to_string()))?;
1327 let algo = repo.odb().hash_algo();
1328 let stdin: String = refs.iter().map(|r| r.stdin_line(algo)).collect();
1329 let result = run_hook_opts(
1330 Some(repo),
1331 "pre-push",
1332 &[remote, url],
1333 config.as_ref(),
1334 RunHookOptions {
1335 stdout_to_stderr: true,
1336 stdin_data: Some(stdin.as_bytes()),
1337 ..repo_hook_options(repo, "pre-push")
1338 },
1339 )?;
1340 let output = match &result {
1341 HookResult::Success { output } => output.clone(),
1342 HookResult::NotFound | HookResult::Failed { .. } => HookOutput::default(),
1343 };
1344 result.check("pre-push")?;
1345 Ok(output)
1346}
1347
1348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1350pub(crate) enum ReferenceTransactionPhase {
1351 Prepared,
1353 Committed,
1355 Aborted,
1357}
1358
1359impl ReferenceTransactionPhase {
1360 fn as_arg(self) -> &'static str {
1361 match self {
1362 Self::Prepared => "prepared",
1363 Self::Committed => "committed",
1364 Self::Aborted => "aborted",
1365 }
1366 }
1367}
1368
1369#[derive(Clone)]
1371pub struct RefHookContext {
1372 git_dir: PathBuf,
1373 work_tree: Option<PathBuf>,
1374 environment: Arc<Environment>,
1375 command_runner: Arc<dyn CommandRunner>,
1376 hook_stdio: HookStdio,
1377 #[cfg_attr(not(unix), allow(dead_code))]
1378 diagnostics: crate::diagnostics::DiagnosticsHandle,
1379 hash_algo: HashAlgo,
1380}
1381
1382impl RefHookContext {
1383 #[allow(clippy::too_many_arguments)]
1385 pub(crate) fn new(
1386 git_dir: PathBuf,
1387 work_tree: Option<PathBuf>,
1388 environment: Arc<Environment>,
1389 command_runner: Arc<dyn CommandRunner>,
1390 hook_stdio: HookStdio,
1391 diagnostics: crate::diagnostics::DiagnosticsHandle,
1392 hash_algo: HashAlgo,
1393 ) -> Self {
1394 Self {
1395 git_dir,
1396 work_tree,
1397 environment,
1398 command_runner,
1399 hook_stdio,
1400 diagnostics,
1401 hash_algo,
1402 }
1403 }
1404
1405 #[must_use]
1407 pub(crate) fn git_dir(&self) -> &Path {
1408 &self.git_dir
1409 }
1410
1411 #[must_use]
1413 pub(crate) fn null_oid(&self) -> ObjectId {
1414 ObjectId::null(self.hash_algo)
1415 }
1416
1417 fn hook_run_dir(&self) -> &Path {
1418 self.work_tree.as_deref().unwrap_or(&self.git_dir)
1419 }
1420
1421 fn load_config(&self) -> Result<Arc<crate::config::ConfigSet>, HookError> {
1422 crate::config::ConfigSet::load(&self.environment, Some(&self.git_dir), true)
1423 .map(Arc::new)
1424 .map_err(|e| HookError::Internal(e.to_string()))
1425 }
1426
1427 pub(crate) fn run_reference_transaction(
1432 &self,
1433 phase: ReferenceTransactionPhase,
1434 stdin: &[u8],
1435 ) -> Result<(), HookError> {
1436 if stdin.is_empty() {
1437 return Ok(());
1438 }
1439 let config = self.load_config()?;
1440 let result = run_hook_opts_for_ref_context(
1441 self,
1442 "reference-transaction",
1443 &[phase.as_arg()],
1444 config.as_ref(),
1445 RunHookOptions {
1446 stdin_data: Some(stdin),
1447 stdout_to_stderr: true,
1448 stdio: self.hook_stdio,
1449 ..RunHookOptions::default()
1450 },
1451 )?;
1452 if phase == ReferenceTransactionPhase::Prepared {
1453 result.check("reference-transaction")?;
1454 }
1455 Ok(())
1456 }
1457}
1458
1459fn resolve_hooks_dir_for_ref(ctx: &RefHookContext, config: &ConfigSet) -> PathBuf {
1460 let dir = match config.get("core.hooksPath") {
1461 Some(hooks_path) => {
1462 let p = PathBuf::from(parse_path(ctx.environment.as_ref(), &hooks_path));
1463 if p.is_absolute() {
1464 p
1465 } else {
1466 ctx.hook_run_dir().join(p)
1467 }
1468 }
1469 None => crate::repo::common_git_dir_for_config(&ctx.git_dir).join("hooks"),
1470 };
1471 path_without_verbatim_prefix(&dir).into_owned()
1472}
1473
1474fn hook_argv0_for_ref(
1475 ctx: &RefHookContext,
1476 hooks_dir: &Path,
1477 hook_name: &str,
1478 cwd: &Path,
1479) -> PathBuf {
1480 let default_hooks_dir = ctx.git_dir.join("hooks");
1481 if same_dir(hooks_dir, &default_hooks_dir) {
1482 if cwd == ctx.git_dir.as_path() {
1483 return PathBuf::from("hooks").join(hook_name);
1484 }
1485 if let Some(work_tree) = &ctx.work_tree {
1486 if cwd == work_tree.as_path() && same_dir(&ctx.git_dir, &work_tree.join(".git")) {
1487 return PathBuf::from(".git").join("hooks").join(hook_name);
1488 }
1489 }
1490 }
1491 hooks_dir.join(hook_name)
1492}
1493
1494fn resolve_hook_sequence_for_ref(
1495 ctx: &RefHookContext,
1496 hook_name: &str,
1497 config: &ConfigSet,
1498) -> Result<Vec<ResolvedHook>, String> {
1499 let tables = HookConfigTables::from_config(config);
1500 let mut seq = Vec::new();
1501 for (_friendly, command) in tables.hooks_for_event(hook_name)? {
1502 seq.push(ResolvedHook::Configured { command });
1503 }
1504 let hooks_dir = resolve_hooks_dir_for_ref(ctx, config);
1505 if let Some(path) = traditional_hook_candidate_for_ref(ctx, &hooks_dir, hook_name) {
1506 let argv0 = hook_argv0_for_ref(ctx, &hooks_dir, hook_name, ctx.hook_run_dir());
1507 seq.push(ResolvedHook::Traditional { path, argv0 });
1508 }
1509 Ok(seq)
1510}
1511
1512#[cfg_attr(not(unix), allow(unused_variables))]
1513fn traditional_hook_candidate_for_ref(
1514 ctx: &RefHookContext,
1515 hooks_dir: &Path,
1516 hook_name: &str,
1517) -> Option<PathBuf> {
1518 let path = hooks_dir.join(hook_name);
1519 if !path.exists() {
1520 return None;
1521 }
1522 let meta = fs::metadata(&path).ok()?;
1523 #[cfg(unix)]
1524 if meta.permissions().mode() & 0o111 == 0 {
1525 let show_warning = ctx
1526 .load_config()
1527 .ok()
1528 .and_then(|c| c.get("advice.ignoredHook"))
1529 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "no" | "off" | "0"))
1530 .unwrap_or(true);
1531 if show_warning {
1532 ctx.diagnostics
1533 .warn(crate::diagnostics::Warning::NonExecutableHookIgnored {
1534 hook_name: hook_name.to_string(),
1535 hook_path: Some(path.clone()),
1536 });
1537 }
1538 return None;
1539 }
1540 Some(path)
1541}
1542
1543fn run_hook_opts_for_ref_context(
1544 ctx: &RefHookContext,
1545 hook_name: &str,
1546 args: &[&str],
1547 config: &ConfigSet,
1548 opts: RunHookOptions<'_>,
1549) -> Result<HookResult, HookError> {
1550 let seq = resolve_hook_sequence_for_ref(ctx, hook_name, config)?;
1551 if seq.is_empty() {
1552 return Ok(HookResult::NotFound);
1553 }
1554
1555 let work_dir = opts
1556 .cwd
1557 .map(Path::to_path_buf)
1558 .unwrap_or_else(|| ctx.hook_run_dir().to_path_buf());
1559 let work_dir = work_dir.as_path();
1560 let runner = ctx.command_runner.as_ref();
1561
1562 let merged_env: Vec<(String, String)> = opts
1563 .env_vars
1564 .iter()
1565 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1566 .collect();
1567
1568 let mut hook_env =
1569 CommandEnvironment::from_repository_environment(ctx.environment.as_ref(), &merged_env);
1570 if cfg!(windows) {
1571 strip_verbatim_prefix_from_path_vars(&mut hook_env.set);
1572 }
1573 hook_env.set.push((
1574 OsString::from("GIT_DIR"),
1575 ctx.git_dir.as_os_str().to_os_string(),
1576 ));
1577
1578 let capture = opts.stdio == HookStdio::Capture;
1579 let (stdout, stderr) = match opts.stdio {
1580 HookStdio::Capture => (CommandStdio::Pipe, CommandStdio::Pipe),
1581 HookStdio::Inherit if opts.stdout_to_stderr => {
1582 (CommandStdio::ParentStderr, CommandStdio::Inherit)
1583 }
1584 HookStdio::Inherit => (CommandStdio::Inherit, CommandStdio::Inherit),
1585 };
1586 let mut output = HookOutput::default();
1587
1588 for h in &seq {
1589 let (stdin_piped, stdin_file) = match opts.path_to_stdin {
1590 Some(p) => (true, Some(p.to_path_buf())),
1591 None => (opts.stdin_data.is_some(), None),
1592 };
1593
1594 let hook_ctx = HookSpawnContext {
1595 runner,
1596 cwd: work_dir,
1597 env: &hook_env,
1598 stdin_piped,
1599 stdout,
1600 stderr,
1601 };
1602 let spawned = match h {
1603 ResolvedHook::Traditional { path, argv0 } => {
1604 let effective_argv0 = path
1605 .parent()
1606 .map(|hooks_dir| hook_argv0_for_ref(ctx, hooks_dir, hook_name, work_dir))
1607 .unwrap_or_else(|| argv0.clone());
1608 hook_ctx.spawn_traditional(hook_name, path, &effective_argv0, args)
1609 }
1610 ResolvedHook::Configured { command } => {
1611 hook_ctx.spawn_configured(hook_name, command, args)
1612 }
1613 };
1614 let mut child = match spawned {
1615 Ok(child) => child,
1616 Err(HookError::Spawn {
1617 hook_name,
1618 path,
1619 source,
1620 ..
1621 }) => {
1622 return Err(HookError::Spawn {
1623 hook_name,
1624 path,
1625 source,
1626 output,
1627 })
1628 }
1629 Err(other) => return Err(other),
1630 };
1631
1632 if let Some(ref path) = stdin_file {
1633 let file = match fs::File::open(path) {
1634 Ok(f) => f,
1635 Err(_e) => {
1636 return Ok(HookResult::Failed { status: 1, output });
1637 }
1638 };
1639 if let Some(stdin) = child.stdin_mut() {
1640 let mut file = file;
1641 let _ = std::io::copy(&mut file, stdin);
1642 }
1643 } else if let Some(data) = opts.stdin_data {
1644 if let Some(stdin) = child.stdin_mut() {
1645 let _ = stdin.write_all(data);
1646 }
1647 }
1648 if stdin_piped {
1649 child.close_stdin();
1650 }
1651
1652 let waited = if capture {
1653 child.wait_with_output().map(|o| {
1654 output.stdout.extend_from_slice(&o.stdout);
1655 output.stderr.extend_from_slice(&o.stderr);
1656 o.status
1657 })
1658 } else {
1659 child.wait()
1660 };
1661 let exit = match waited {
1662 Ok(exit) => exit,
1663 Err(_) => return Ok(HookResult::Failed { status: 1, output }),
1664 };
1665
1666 if !exit.success {
1667 return Ok(HookResult::Failed {
1668 status: exit.code.unwrap_or(1),
1669 output,
1670 });
1671 }
1672 }
1673
1674 Ok(HookResult::Success { output })
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679 use super::*;
1680
1681 fn touch(path: &Path) {
1682 fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
1683 fs::write(path, b"").expect("write");
1684 }
1685
1686 fn exe(name: &str) -> String {
1687 format!("{name}{}", std::env::consts::EXE_SUFFIX)
1688 }
1689
1690 #[test]
1691 fn hook_result_reports_status_output_and_checks() {
1692 let output = HookOutput {
1693 stdout: b"out".to_vec(),
1694 stderr: b"err".to_vec(),
1695 };
1696 assert!(!output.is_empty());
1697 assert!(HookOutput::default().is_empty());
1698 assert_eq!(HookError::Internal("bad".into()).output(), None);
1699 assert_eq!(
1700 format!("{output:?}"),
1701 r#"HookOutput { stdout: "out", stderr: "err" }"#
1702 );
1703
1704 let success = HookResult::Success {
1705 output: output.clone(),
1706 };
1707 assert!(success.is_ok() && success.was_executed());
1708 assert_eq!(success.status(), Some(0));
1709 assert_eq!(success.output(), Some(&output));
1710 assert!(success.check("pre-commit").expect("success"));
1711
1712 let missing = HookResult::NotFound;
1713 assert!(missing.is_ok() && !missing.was_executed());
1714 assert_eq!((missing.status(), missing.output()), (None, None));
1715 assert!(!missing.check("pre-commit").expect("not found"));
1716
1717 let failed = HookResult::Failed {
1718 status: 3,
1719 output: output.clone(),
1720 };
1721 assert!(!failed.is_ok() && failed.was_executed());
1722 assert_eq!(failed.status(), Some(3));
1723 match failed.check("commit-msg") {
1724 Err(err @ HookError::Failed { .. }) => {
1725 assert_eq!(err.to_string(), "hook commit-msg failed with exit status 3");
1726 let HookError::Failed {
1727 hook_name,
1728 status,
1729 output: got,
1730 } = err
1731 else {
1732 unreachable!()
1733 };
1734 assert_eq!((hook_name.as_str(), status), ("commit-msg", 3));
1735 assert_eq!(got, output);
1736 }
1737 other => panic!("expected HookError::Failed, got {other:?}"),
1738 }
1739 }
1740
1741 #[test]
1742 fn post_rewrite_stdin_is_one_old_new_line_per_pair() {
1743 let a = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("a");
1744 let b = ObjectId::from_hex("2222222222222222222222222222222222222222").expect("b");
1745 assert_eq!(post_rewrite_stdin(&[]), b"");
1746 assert_eq!(
1747 post_rewrite_stdin(&[(a, b), (b, a)]),
1748 format!(
1749 "{} {}\n{} {}\n",
1750 a.to_hex(),
1751 b.to_hex(),
1752 b.to_hex(),
1753 a.to_hex()
1754 )
1755 .into_bytes()
1756 );
1757 }
1758
1759 #[test]
1760 fn post_operation_names_and_flags() {
1761 assert_eq!(HookName::PostCheckout.as_str(), "post-checkout");
1762 assert_eq!(HookName::PostMerge.as_str(), "post-merge");
1763 assert_eq!(HookName::PostRewrite.as_str(), "post-rewrite");
1764 assert_eq!(HookName::PreMergeCommit.as_str(), "pre-merge-commit");
1765 assert_eq!(HookName::PrepareCommitMsg.as_str(), "prepare-commit-msg");
1766 assert_eq!(HookName::PreRebase.as_str(), "pre-rebase");
1767 assert_eq!(RewriteKind::Amend.as_str(), "amend");
1768 assert_eq!(RewriteKind::Rebase.as_str(), "rebase");
1769 assert!(PostOperationHooks::default().enabled());
1770 assert!(!PostOperationHooks::Skip.enabled());
1771 }
1772
1773 #[test]
1774 fn shebang_interpreter_takes_the_interpreter_basename() {
1775 assert_eq!(
1776 shebang_interpreter(b"#!/bin/sh\nexit 0\n").as_deref(),
1777 Some("sh")
1778 );
1779 assert_eq!(
1780 shebang_interpreter(b"#!/bin/bash -e\n").as_deref(),
1781 Some("bash")
1782 );
1783 assert_eq!(shebang_interpreter(b"#! /bin/sh\n").as_deref(), Some("sh"));
1784 assert_eq!(
1785 shebang_interpreter(b"#!/bin/sh\r\nexit 0\r\n").as_deref(),
1786 Some("sh")
1787 );
1788 assert_eq!(shebang_interpreter(b"#!/bin/sh").as_deref(), Some("sh"));
1789 assert_eq!(
1790 shebang_interpreter(br"#!C:\tools\perl.exe").as_deref(),
1791 Some("perl.exe")
1792 );
1793 }
1794
1795 #[test]
1796 fn shebang_interpreter_looks_through_env() {
1797 assert_eq!(
1798 shebang_interpreter(b"#!/usr/bin/env bash\n").as_deref(),
1799 Some("bash")
1800 );
1801 assert_eq!(
1802 shebang_interpreter(b"#!/usr/bin/env -S python3 -u\n").as_deref(),
1803 Some("python3")
1804 );
1805 assert_eq!(shebang_interpreter(b"#!/usr/bin/env\n"), None);
1806 }
1807
1808 #[test]
1809 fn shebang_interpreter_rejects_non_scripts() {
1810 assert_eq!(shebang_interpreter(b"exit 0\n"), None);
1811 assert_eq!(shebang_interpreter(b""), None);
1812 assert_eq!(shebang_interpreter(b"#!\n"), None);
1813 assert_eq!(shebang_interpreter(b"#!/\n"), None);
1814 assert_eq!(shebang_interpreter(b"\x7fELF\x02\x01"), None);
1815 }
1816
1817 #[cfg(unix)]
1818 #[test]
1819 fn hook_shell_is_bin_sh_on_unix() {
1820 let mut env = CommandEnvironment::default();
1821 env.set.push(("PATH".into(), "/nonexistent".into()));
1822 assert_eq!(hook_shell(&env), ScriptInterpreter::bare("/bin/sh"));
1823 }
1824
1825 #[test]
1826 fn interpreter_from_git_tree_gets_its_tool_dirs_on_path() {
1827 let tmp = tempfile::tempdir().expect("tempdir");
1828 let root = tmp.path().join("Git");
1829 touch(&root.join("cmd").join(exe("git")));
1830 touch(&root.join("mingw64").join("bin").join(exe("git")));
1831 touch(&root.join("usr").join("bin").join(exe("sh")));
1832 let mut env = CommandEnvironment::default();
1833 env.set
1834 .push(("PATH".into(), root.join("cmd").into_os_string()));
1835 let runner = crate::command_runner::RecordingRunner::always_success();
1836 let ctx = HookSpawnContext {
1837 runner: runner.as_ref(),
1838 cwd: tmp.path(),
1839 env: &env,
1840 stdin_piped: false,
1841 stdout: CommandStdio::Inherit,
1842 stderr: CommandStdio::Inherit,
1843 };
1844 let dash_c = || ShellInvocation::DashC {
1845 script: "true".into(),
1846 argv0: Some("hook".into()),
1847 args: Vec::new(),
1848 };
1849 let interpreter = crate::command_runner::resolve_script_interpreter(
1850 "sh",
1851 Some(root.join("cmd").as_os_str()),
1852 );
1853 let spec = ctx.interpreter_spec(interpreter, dash_c(), &[]);
1854 assert_eq!(spec.program, root.join("usr").join("bin").join(exe("sh")));
1855 let expected = std::env::join_paths([
1856 root.join("mingw64").join("bin"),
1857 root.join("usr").join("bin"),
1858 root.join("cmd"),
1859 ])
1860 .expect("join");
1861 assert_eq!(
1862 crate::command_runner::child_path_var(&spec.env),
1863 Some(expected)
1864 );
1865
1866 let bare = ctx.interpreter_spec(ScriptInterpreter::bare("/bin/sh"), dash_c(), &[]);
1867 assert_eq!(
1868 crate::command_runner::child_path_var(&bare.env),
1869 Some(root.join("cmd").into_os_string()),
1870 "interpreters outside a Git tree keep PATH as is"
1871 );
1872 }
1873
1874 #[test]
1875 fn verbatim_prefixes_are_stripped_from_path_vars() {
1876 let mut set: Vec<(OsString, OsString)> = vec![
1877 ("GIT_INDEX_FILE".into(), r"\\?\C:\repo\.git\index".into()),
1878 ("GIT_DIR".into(), r"\\?\UNC\server\share\repo\.git".into()),
1879 ("GIT_WORK_TREE".into(), r"C:\repo".into()),
1880 (
1881 "GIT_OBJECT_DIRECTORY".into(),
1882 r"\\?\C:\repo\.git\objects".into(),
1883 ),
1884 ("GIT_PREFIX".into(), r"\\?\kept".into()),
1885 ];
1886 strip_verbatim_prefix_from_path_vars(&mut set);
1887 let value = |key: &str| {
1888 set.iter()
1889 .find(|(k, _)| k == key)
1890 .map(|(_, v)| v.clone())
1891 .expect("present")
1892 };
1893 assert_eq!(
1894 value("GIT_INDEX_FILE"),
1895 OsString::from(r"C:\repo\.git\index")
1896 );
1897 assert_eq!(
1898 value("GIT_DIR"),
1899 OsString::from(r"\\server\share\repo\.git")
1900 );
1901 assert_eq!(value("GIT_WORK_TREE"), OsString::from(r"C:\repo"));
1902 assert_eq!(
1903 value("GIT_OBJECT_DIRECTORY"),
1904 OsString::from(r"C:\repo\.git\objects")
1905 );
1906 assert_eq!(value("GIT_PREFIX"), OsString::from(r"\\?\kept"));
1907 }
1908
1909 #[test]
1910 fn not_an_executable_matches_only_the_platform_code() {
1911 #[cfg(unix)]
1912 assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(8)));
1913 #[cfg(windows)]
1914 assert!(is_not_an_executable(&std::io::Error::from_raw_os_error(
1915 193
1916 )));
1917 assert!(!is_not_an_executable(&std::io::Error::from(
1918 std::io::ErrorKind::NotFound
1919 )));
1920 }
1921}