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