Skip to main content

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;