git_policy_cli/hooks_off.rs
1//! What:
2//! Start the real Git with every hook switched off.
3//! Why:
4//! The wrapper supports no external hook program: not a traditional hook (an executable file in
5//! `<git-common-dir>/hooks` or a `core.hooksPath` directory; `git lfs` writes its four shims there again whenever it
6//! cleans a file) and not a hook defined in the configuration (`hook.<name>.event` with `hook.<name>.command`).
7//! Every real-Git start of the wrapper therefore carries `-c core.hooksPath=<a directory that never exists>`, which
8//! finds no hook file, and `-c hook.<event>.enabled=false` for every hook event Git knows, which switches every
9//! configured hook off whatever its name.
10//! They follow every global option of the caller and precede the subcommand, so no `-c` or `--config-env` of the caller
11//! can switch a hook or the hooks path back on.
12//! The options are part of the argument vector, so they add no system call and no process start.
13//! The wrapper injects no hook of its own, so `pre-push` is switched off like every other event.
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! spawn(realGit, ['-c', `core.hooksPath=${NO_HOOKS_DIRECTORY}`, ...HOOK_EVENTS.flatMap((e) => ['-c', `hook.${e}.enabled=false`]), ...args]);
18//! ```
19
20/// The global-option parser, to find the subcommand behind the caller's global options.
21use super::global_arguments::{GlobalOutcome, global_layout};
22/// Owned operating-system text for the arguments.
23use std::ffi::OsString;
24/// The switch that turns the option on for the process.
25use std::sync::atomic::{AtomicBool, Ordering};
26/// The child that gets the option.
27use std::process::Command;
28
29/// The `core.hooksPath` value of every start: below `/dev/null`, which is a device, so nothing can create it.
30pub const NO_HOOKS_DIRECTORY: &str = "/dev/null/cli-git-no-hooks";
31
32/// Every hook event of Git 2.56.0 (`githooks(5)`), server-side events included.
33/// A configured hook for an event missing here would run, so the control `every_listed_event_is_a_hook_event_of_real_git`
34/// asks the host Git whether each name is one.
35pub const HOOK_EVENTS: &[&str] = &[
36 "applypatch-msg",
37 "pre-applypatch",
38 "post-applypatch",
39 "pre-commit",
40 "pre-merge-commit",
41 "prepare-commit-msg",
42 "commit-msg",
43 "post-commit",
44 "pre-rebase",
45 "post-checkout",
46 "post-merge",
47 "pre-push",
48 "pre-receive",
49 "update",
50 "proc-receive",
51 "post-receive",
52 "post-update",
53 "reference-transaction",
54 "push-to-checkout",
55 "pre-auto-gc",
56 "post-rewrite",
57 "sendemail-validate",
58 "fsmonitor-watchman",
59 "p4-changelist",
60 "p4-prepare-changelist",
61 "p4-post-changelist",
62 "p4-pre-submit",
63 "post-index-change",
64];
65
66/// The events a real-Git start of the wrapper switches off: every hook event Git knows.
67pub fn switched_off_events() -> impl Iterator<Item = &'static str> {
68 return HOOK_EVENTS.iter().copied();
69}
70
71/// What:
72/// Whether `arguments` run `git config`.
73/// Why:
74/// A configuration query reports what the repository configures, and the wrapper itself reads `core.hooksPath` that
75/// way; the injected value must not answer for it.
76/// A query runs no hook, so leaving it out costs no protection.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// layout.outcome === 'command' && args[layout.prefixLen] === 'config'
81/// ```
82fn is_configuration_query(arguments: &[OsString]) -> bool {
83 let layout = global_layout(arguments);
84 return layout.outcome == GlobalOutcome::Command && arguments[layout.prefix_len] == "config";
85}
86
87/// Whether this process starts real Git with hooks off; the executable switches it on before any start.
88static ACTIVE: AtomicBool = AtomicBool::new(false);
89
90/// What:
91/// Switch the hooks-off option on for every real-Git start this process makes from now on.
92/// Why:
93/// The executable's entry calls it first, so no production start misses it.
94/// The library's unit controls, which fake Git with scripts that record the exact arguments they receive, leave it
95/// off, and the binary-level controls prove the option on the built executable.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// hooksOff.active = true;
100/// ```
101pub fn activate() {
102 ACTIVE.store(true, Ordering::SeqCst);
103}
104
105/// What:
106/// The arguments a real-Git start gets, with the hooks-off options in front of the subcommand.
107/// Why:
108/// The options follow every global option of the caller (`-c`, `--config-env`, `--git-dir`), so a caller cannot
109/// switch a hook event or the hooks path back on, and they precede the subcommand, so they apply to it.
110/// A configuration query gets none, because it must report what the repository configures, and a start that names no
111/// subcommand (`--version`, a help query) has no global options to follow, so it gets them first.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// [...args.slice(0, layout.prefixLen), ...hooksOffOptions, ...args.slice(layout.prefixLen)]
116/// ```
117pub fn hooks_off_arguments(arguments: &[OsString]) -> Vec<OsString> {
118 if is_configuration_query(arguments) {
119 return arguments.to_vec();
120 }
121 let layout = global_layout(arguments);
122 let split: usize = if layout.outcome == GlobalOutcome::Command { layout.prefix_len } else { 0 };
123 let mut result: Vec<OsString> = arguments[..split].to_vec();
124 result.push(OsString::from("-c"));
125 result.push(OsString::from(format!("core.hooksPath={NO_HOOKS_DIRECTORY}")));
126 for event in switched_off_events() {
127 result.push(OsString::from("-c"));
128 result.push(OsString::from(format!("hook.{event}.enabled=false")));
129 }
130 result.extend_from_slice(&arguments[split..]);
131 return result;
132}
133
134/// What:
135/// Add `arguments` to a real-Git command, with the hooks-off options once the process has switched them on.
136/// Why:
137/// One function serves both builders of real-Git commands, so no start can miss the options.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// command.args(hooksOff.active ? hooksOffArguments(args) : args);
142/// ```
143pub fn add_arguments(command: &mut Command, arguments: &[OsString]) {
144 if ACTIVE.load(Ordering::SeqCst) {
145 command.args(hooks_off_arguments(arguments));
146 } else {
147 command.args(arguments);
148 }
149}
150
151#[cfg(test)]
152#[path = "hooks_off_tests.rs"]
153mod tests;