Skip to main content

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;