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