git_policy_cli/external_hooks.rs
1//! What:
2//! The external hooks a repository configures (`hook.<name>.event` with `hook.<name>.command`), read from the
3//! configuration snapshot, and the loud report that none of them runs.
4//! Why:
5//! The wrapper supports no external hook program at all: every real-Git start of the wrapper switches every hook
6//! event off (`hooks_off.rs`), and the behaviors a hook used to provide are declared natively in
7//! `cli-git.config.jsonc`.
8//! A configured hook that silently never ran would look like a broken check, so a commit prints one
9//! `configuration-warning` event naming the keys and the events, decided from the snapshot the commit already holds
10//! (no process start), and `git cli-git doctor` lists the same hooks.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const hooks = configuredHooks(snapshot); if (hooks.length > 0) stderr.write(renderConfigurationWarning(0, code, message, names));
15//! ```
16
17/// Debug logging of a stream that refused the warning.
18use super::diagnostic_log::debug;
19/// The warning's JSONL rendering, shared with the other configuration warnings.
20use super::diagnostics::render_configuration_warning;
21/// The configuration the command read once.
22use super::config_snapshot::{ConfigSnapshot, Lookup};
23/// A stream the warning is written to.
24use std::io::Write;
25
26/// Code of the configuration warning for configured external hooks.
27pub const EXTERNAL_HOOKS_UNSUPPORTED_CODE: &str = "external-hooks-unsupported";
28
29/// Module tag for diagnostics.
30const TAG: &str = "externalHooks";
31
32/// The key parts around a hook name: `hook.<name>.event` and `hook.<name>.command`.
33const HOOK_PREFIX: &str = "hook.";
34
35/// What:
36/// One configured hook.
37/// Why:
38/// The report names the hook, its events and whether it has a command, so the author knows which keys to remove.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type ConfiguredHook = { name: string; events: string[]; hasCommand: boolean };
43/// ```
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct ConfiguredHook {
46 /// The hook's friendly name (the subsection of the keys).
47 pub name: String,
48 /// The events it asks for, in the order Git reads them; an empty value resets the list, as Git does.
49 pub events: Vec<String>,
50 /// Whether `hook.<name>.command` is set.
51 pub has_command: bool,
52}
53
54/// What:
55/// The text of a value as UTF-8, lossily.
56/// Why:
57/// Event names and hook names are ASCII in every real configuration, and the report only names them.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// new TextDecoder().decode(bytes)
62/// ```
63fn lossy(value: Option<&Vec<u8>>) -> String {
64 return value.map(|bytes| { return String::from_utf8_lossy(bytes.as_slice()).into_owned(); }).unwrap_or_default();
65}
66
67/// What:
68/// Every configured hook that is not switched off by its own `hook.<name>.enabled`, in the order the snapshot lists
69/// them.
70/// Why:
71/// The hooks Git would have run: a name with an event or a command, minus the ones the author already disabled.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// configuredHooks(snapshot); // ConfiguredHook[]
76/// ```
77pub fn configured_hooks(snapshot: &ConfigSnapshot) -> Vec<ConfiguredHook> {
78 let mut hooks: Vec<ConfiguredHook> = Vec::new();
79 for entry in snapshot.entries() {
80 let Some(rest) = entry.key.strip_prefix(HOOK_PREFIX) else {
81 continue;
82 };
83 let (name, event_key): (&str, bool) = if let Some(name) = rest.strip_suffix(".event") {
84 (name, true)
85 } else if let Some(name) = rest.strip_suffix(".command") {
86 (name, false)
87 } else {
88 continue;
89 };
90 if name.is_empty() {
91 continue;
92 }
93 let position: usize = match hooks.iter().position(|known| { return known.name == name; }) {
94 Some(found) => found,
95 None => {
96 hooks.push(ConfiguredHook { name: String::from(name), events: Vec::new(), has_command: false });
97 hooks.len() - 1
98 }
99 };
100 if event_key {
101 let value: String = lossy(entry.value.as_ref());
102 if value.is_empty() {
103 hooks[position].events.clear();
104 } else {
105 hooks[position].events.push(value);
106 }
107 } else {
108 hooks[position].has_command = true;
109 }
110 }
111 hooks.retain(|hook| {
112 return !matches!(snapshot.bool_value(format!("hook.{}.enabled", hook.name).as_str()), Lookup::Found(false));
113 });
114 return hooks;
115}
116
117/// What:
118/// The hook as one phrase: `hook.<name> (events: a, b)`.
119/// Why:
120/// The report lists each hook by the key prefix the author would unset.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// `hook.${name} (events: ${events.join(', ')})`
125/// ```
126pub fn describe_hook(hook: &ConfiguredHook) -> String {
127 if hook.events.is_empty() {
128 return format!("hook.{} (no event)", hook.name);
129 }
130 return format!("hook.{} (events: {})", hook.name, hook.events.join(", "));
131}
132
133/// What:
134/// The sentence that explains what to do about configured hooks.
135/// Why:
136/// The explanation is shared by the commit warning and `git cli-git doctor`, so both say the same ways forward.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// explainExternalHooks(hooks);
141/// ```
142pub fn explain_external_hooks(hooks: &[ConfiguredHook]) -> String {
143 let listed: Vec<String> = hooks.iter().map(describe_hook).collect();
144 return format!(
145 "This repository configures external hooks: {}. cli-git supports no external hook program, so every Git it \
146 starts has every hook event switched off and these never run. Ways forward: remove them with \
147 `git config --unset-all hook.<name>.event` and `git config --unset-all hook.<name>.command` for each name \
148 (the keys may live in a global or system file; `git config --show-origin --get-regexp '^hook[.]'` shows \
149 where), or express what the hook checked as a cli-git policy under policies in cli-git.config.jsonc. cli-git \
150 runs no program named by any configuration, so no setting can bring a hook back. `git cli-git doctor` lists \
151 these hooks.",
152 listed.join("; ")
153 );
154}
155
156/// What:
157/// The warning line for configured external hooks, or nothing when the snapshot defines none.
158/// Why:
159/// The answer comes from the snapshot alone, so a repository without hooks pays no process start and no file
160/// system call.
161///
162/// In TS you'd write (pseudocode):
163/// ```ts
164/// hooks.length === 0 ? undefined : renderConfigurationWarning(0, code, explain(hooks), names);
165/// ```
166pub fn external_hooks_warning(snapshot: Option<&ConfigSnapshot>) -> Option<String> {
167 let hooks: Vec<ConfiguredHook> = configured_hooks(snapshot?);
168 if hooks.is_empty() {
169 return None;
170 }
171 let names: Vec<String> = hooks.iter().map(|hook| { return format!("hook.{}", hook.name); }).collect();
172 return Some(render_configuration_warning(
173 0,
174 EXTERNAL_HOOKS_UNSUPPORTED_CODE,
175 explain_external_hooks(hooks.as_slice()).as_str(),
176 names.join(",").as_str(),
177 ));
178}
179
180/// What:
181/// Write the warning, when there is one, to `stream`.
182/// Why:
183/// The command's exit status already carries its result, so a stream that refuses the line is logged and the
184/// command goes on.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// const line = externalHooksWarning(snapshot); if (line !== undefined) stream.write(line);
189/// ```
190pub fn warn_external_hooks(snapshot: Option<&ConfigSnapshot>, stream: &mut impl Write) {
191 let Some(line) = external_hooks_warning(snapshot) else {
192 return;
193 };
194 if let Err(error) = stream.write_all(line.as_bytes()).and_then(|()| { return stream.flush(); }) {
195 debug(TAG, format!("the warning could not be written: {error}").as_str());
196 }
197}
198
199/// Reading hooks from the snapshot and the warning are controlled in the test module.
200#[cfg(test)]
201#[path = "external_hooks_tests.rs"]
202mod tests;