git_policy_cli/config_snapshot.rs
1//! What:
2//! One read of the whole Git configuration (`git config --list -z --show-scope`), parsed here,
3//! and the lookups the commit path makes against it instead of starting `git config` for each key.
4//! Why:
5//! Every `git config` start costs a process, and a wrapped commit asked Git the same configuration
6//! several times (`core.hooksPath`, the hook switches, the maintenance keys, the ref storage).
7//! Git lists entries in precedence order (system, global, local, worktree, then `GIT_CONFIG_COUNT`,
8//! `GIT_CONFIG_PARAMETERS` and `-c` values, all in the `command` scope), so the last entry of a key is the
9//! value `git config --get` prints.
10//! A lookup answers only when it can reproduce Git's own answer exactly;
11//! any value it does not model (`~` and `%(prefix)` paths, unit suffixes, hexadecimal, a bad boolean) is
12//! [`Lookup::Unhandled`], and the caller starts Git for that key as before.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const snapshot = parseConfigList(await git(['config', '--list', '-z', '--show-scope']));
17//! const hooksPath = snapshot.pathValue('core.hookspath'); // 'unhandled' means: ask Git
18//! ```
19
20/// Debug logging of a snapshot that could not be used.
21use super::diagnostic_log::debug;
22/// One required Git start with the caller's context.
23use super::transaction_git::{GitContext, GitRequest, run_git};
24/// Ordered map of the four maintenance keys to the text Git prints for them.
25use std::collections::BTreeMap;
26/// `Path` is a borrowed filesystem path.
27use std::path::Path;
28
29/// Largest decimal integer text a lookup models; longer digit strings fall back to Git.
30const DECIMAL_DIGITS_LIMIT: usize = 9;
31
32/// Keys the maintenance decision reads, spelled as `git config --list` prints them.
33pub const MAINTENANCE_KEYS: [&str; 4] = ["maintenance.auto", "maintenance.autodetach", "gc.auto", "gc.autodetach"];
34
35/// What:
36/// One configuration entry in listing order.
37/// Why:
38/// The scope is kept so a consumer can tell `-c` values from file values, and `None` is a valueless key,
39/// which Git reads as `true` for booleans and refuses for paths.
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// type ConfigEntry = { scope: string; key: string; value: Uint8Array | undefined };
44/// ```
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct ConfigEntry {
47 /// `system`, `global`, `local`, `worktree` or `command`.
48 pub scope: String,
49 /// Canonical key: lowercase section and variable, subsection kept as written.
50 pub key: String,
51 /// Value bytes, or `None` for a key written without `=`.
52 pub value: Option<Vec<u8>>,
53}
54
55/// What:
56/// The answer of one lookup.
57/// Why:
58/// "Not set" and "set to this" are Git's answers; "unhandled" is this module declining to guess,
59/// so the caller falls back to starting Git, which is never a failure.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// type Lookup<T> = { kind: 'unset' } | { kind: 'found'; value: T } | { kind: 'unhandled' };
64/// ```
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub enum Lookup<Value> {
67 /// No entry has the key, so `git config --get` exits 1.
68 Unset,
69 /// The last entry of the key, converted exactly as Git converts it.
70 Found(Value),
71 /// Some entry of the key has a shape this module does not reproduce; ask Git.
72 Unhandled,
73}
74
75/// What:
76/// The parsed listing of one Git configuration read.
77/// Why:
78/// It is read once per commit with the caller's global options, and reused by the consumers that would
79/// otherwise each start `git config`.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// type ConfigSnapshot = { entries: ConfigEntry[]; readWithoutGlobalOptions: boolean };
84/// ```
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct ConfigSnapshot {
87 /// Entries in the order Git listed them, which is precedence order.
88 entries: Vec<ConfigEntry>,
89 /// Whether the read carried no global options, so it equals a read made without them.
90 without_global_options: bool,
91}
92
93/// What:
94/// Whether the bytes of one value are a plain decimal integer the lookups model, and its numeric value.
95/// Why:
96/// Git parses integers with `strtoimax` base 0 and unit suffixes, so only `0` and digit strings that start with
97/// `1` to `9` are decided here; leading zeros (octal), signs, `0x`, spaces and suffixes fall back to Git.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// /^(0|[1-9][0-9]{0,8})$/.test(text) ? Number(text) : undefined;
102/// ```
103fn plain_decimal(text: &str) -> Option<u32> {
104 if text == "0" {
105 return Some(0);
106 }
107 let first: u8 = *text.as_bytes().first()?;
108 if !(b'1'..=b'9').contains(&first) || text.len() > DECIMAL_DIGITS_LIMIT {
109 return None;
110 }
111 if !text.bytes().all(|byte| { return byte.is_ascii_digit(); }) {
112 return None;
113 }
114 return text.parse::<u32>().ok();
115}
116
117/// What:
118/// Git's boolean word list, case-insensitively: `Some(true)` for true, yes and on, `Some(false)` for false, no
119/// and off, `None` for any other text.
120/// Why:
121/// `git_parse_maybe_bool_text` compares with `strcasecmp`, which is ASCII case folding.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// ({ true: true, yes: true, on: true, false: false, no: false, off: false })[text.toLowerCase()];
126/// ```
127fn boolean_word(text: &str) -> Option<bool> {
128 for word in ["true", "yes", "on"] {
129 if text.eq_ignore_ascii_case(word) {
130 return Some(true);
131 }
132 }
133 for word in ["false", "no", "off"] {
134 if text.eq_ignore_ascii_case(word) {
135 return Some(false);
136 }
137 }
138 return None;
139}
140
141/// What:
142/// `--type=bool` of one value: a valueless key is true, empty text is false, a word from the list is itself,
143/// and a plain decimal integer is whether it is not zero.
144/// Why:
145/// Anything else (a bad boolean that Git refuses, a unit suffix, hexadecimal, octal) is not modelled.
146///
147/// In TS you'd write (pseudocode):
148/// ```ts
149/// canonicalBool(value); // undefined means: ask Git
150/// ```
151pub fn canonical_bool(value: Option<&[u8]>) -> Option<bool> {
152 let Some(bytes) = value else {
153 return Some(true);
154 };
155 let text: &str = std::str::from_utf8(bytes).ok()?;
156 if text.is_empty() {
157 return Some(false);
158 }
159 if let Some(word) = boolean_word(text) {
160 return Some(word);
161 }
162 return plain_decimal(text).map(|number| { return number != 0; });
163}
164
165/// What:
166/// `--type=bool-or-int` of one value, as the text Git prints.
167/// Why:
168/// Git prints `true` or `false` for a boolean word, valueless key or empty text, and the decimal number for an
169/// integer; the plain decimal subset prints unchanged, and anything else is not modelled.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// canonicalBoolOrInt(value); // undefined means: ask Git
174/// ```
175pub fn canonical_bool_or_int(value: Option<&[u8]>) -> Option<String> {
176 let Some(bytes) = value else {
177 return Some(String::from("true"));
178 };
179 let text: &str = std::str::from_utf8(bytes).ok()?;
180 if text.is_empty() {
181 return Some(String::from("false"));
182 }
183 if let Some(word) = boolean_word(text) {
184 return Some(String::from(if word { "true" } else { "false" }));
185 }
186 return plain_decimal(text).map(|number| { return number.to_string(); });
187}
188
189/// What:
190/// `--type=path` of one value, when Git returns the text unchanged.
191/// Why:
192/// Git expands a leading `~`, `~user/` and `%(prefix)/`, treats `:(optional)` specially, and refuses a valueless
193/// key; those, an empty value and non-UTF-8 bytes are not modelled.
194///
195/// In TS you'd write (pseudocode):
196/// ```ts
197/// plainPath(value); // undefined means: ask Git
198/// ```
199pub fn plain_path(value: Option<&[u8]>) -> Option<String> {
200 let text: &str = std::str::from_utf8(value?).ok()?;
201 if text.is_empty() || text.starts_with('~') || text.starts_with("%(") || text.starts_with(":(") {
202 return None;
203 }
204 return Some(String::from(text));
205}
206
207/// What:
208/// Split one listing record into its key and value.
209/// Why:
210/// With `-z` a record is `key\nvalue`, or just `key` when the key has no value; the value itself may hold
211/// newlines, so only the first one separates the two.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// const at = record.indexOf(0x0a); at < 0 ? [record, undefined] : [record.slice(0, at), record.slice(at + 1)];
216/// ```
217fn split_record(record: &[u8]) -> (&[u8], Option<&[u8]>) {
218 return match record.iter().position(|byte| { return *byte == b'\n'; }) {
219 Some(newline) => (&record[..newline], Some(&record[newline + 1..])),
220 None => (record, None),
221 };
222}
223
224impl ConfigSnapshot {
225 /// What:
226 /// Parse the bytes of `git config --list -z --show-scope`.
227 /// Why:
228 /// The listing is `scope NUL key [LF value] NUL` repeated; a listing in any other shape is refused whole,
229 /// so a half-understood configuration is never used.
230 ///
231 /// In TS you'd write (pseudocode):
232 /// ```ts
233 /// parseConfigList(bytes, { withoutGlobalOptions });
234 /// ```
235 pub fn parse(listing: &[u8], without_global_options: bool) -> Option<Self> {
236 let mut entries: Vec<ConfigEntry> = Vec::new();
237 if listing.is_empty() {
238 return Some(Self { entries, without_global_options });
239 }
240 let body: &[u8] = listing.strip_suffix(&[0])?;
241 let mut fields = body.split(|byte| { return *byte == 0; });
242 while let Some(scope) = fields.next() {
243 let record: &[u8] = fields.next()?;
244 let (key, value): (&[u8], Option<&[u8]>) = split_record(record);
245 let key_text: &str = std::str::from_utf8(key).ok()?;
246 if key_text.is_empty() {
247 return None;
248 }
249 entries.push(ConfigEntry {
250 scope: String::from(std::str::from_utf8(scope).ok()?),
251 key: String::from(key_text),
252 value: value.map(<[u8]>::to_vec),
253 });
254 }
255 return Some(Self { entries, without_global_options });
256 }
257
258 /// What:
259 /// The entries in listing order.
260 /// Why:
261 /// Consumers that scan for a family of keys (hook definitions) read them directly.
262 ///
263 /// In TS you'd write (pseudocode):
264 /// ```ts
265 /// snapshot.entries
266 /// ```
267 pub fn entries(&self) -> &[ConfigEntry] {
268 return self.entries.as_slice();
269 }
270
271 /// What:
272 /// Whether the read carried no global options, so a consumer that runs Git without them may reuse it.
273 /// Why:
274 /// A `-c`, `-C` or `--git-dir` in front of the read changes what Git lists, and a consumer that ignores the
275 /// caller's options must not see their effect.
276 ///
277 /// In TS you'd write (pseudocode):
278 /// ```ts
279 /// snapshot.withoutGlobalOptions
280 /// ```
281 pub fn without_global_options(&self) -> bool {
282 return self.without_global_options;
283 }
284
285 /// What:
286 /// The last entry of a key converted by `convert`, or why no answer is given.
287 /// Why:
288 /// `git config --get` formats every entry of the key and fails when any is bad, so one unconvertible entry
289 /// makes the whole lookup unhandled even when it is not the last.
290 ///
291 /// In TS you'd write (pseudocode):
292 /// ```ts
293 /// lastConverted(entries.filter((entry) => entry.key === key), convert);
294 /// ```
295 fn last_converted<Value>(
296 &self,
297 key: &str,
298 convert: impl Fn(Option<&[u8]>) -> Option<Value>,
299 ) -> Lookup<Value> {
300 let mut last: Lookup<Value> = Lookup::Unset;
301 for entry in self.entries.iter().filter(|candidate| { return candidate.key == key; }) {
302 match convert(entry.value.as_deref()) {
303 Some(converted) => last = Lookup::Found(converted),
304 None => return Lookup::Unhandled,
305 }
306 }
307 return last;
308 }
309
310 /// What:
311 /// `git config --type=path --get <key>` for values Git returns unchanged.
312 /// Why:
313 /// `core.hooksPath` is read this way.
314 ///
315 /// In TS you'd write (pseudocode):
316 /// ```ts
317 /// snapshot.pathValue('core.hookspath');
318 /// ```
319 pub fn path_value(&self, key: &str) -> Lookup<String> {
320 return self.last_converted(key, plain_path);
321 }
322
323 /// What:
324 /// `git config --type=bool --get <key>`.
325 /// Why:
326 /// The hook switches are read this way.
327 ///
328 /// In TS you'd write (pseudocode):
329 /// ```ts
330 /// snapshot.boolValue('hook.pre-commit.enabled');
331 /// ```
332 pub fn bool_value(&self, key: &str) -> Lookup<bool> {
333 return self.last_converted(key, canonical_bool);
334 }
335
336 /// What:
337 /// The maintenance keys as `git config --type=bool-or-int --null --get-regexp` prints them: each set key
338 /// with the text of its last value.
339 /// Why:
340 /// The maintenance decision parses that listing; `None` when any maintenance entry is not modelled.
341 ///
342 /// In TS you'd write (pseudocode):
343 /// ```ts
344 /// snapshot.maintenanceRecords();
345 /// ```
346 pub fn maintenance_records(&self) -> Option<BTreeMap<String, String>> {
347 let mut records: BTreeMap<String, String> = BTreeMap::new();
348 for key in MAINTENANCE_KEYS {
349 match self.last_converted(key, canonical_bool_or_int) {
350 Lookup::Unset => {}
351 Lookup::Found(text) => {
352 records.insert(String::from(key), text);
353 }
354 Lookup::Unhandled => return None,
355 }
356 }
357 return Some(records);
358 }
359
360 /// What:
361 /// Whether the ref storage is proven to be the files backend.
362 /// Why:
363 /// `git rev-parse --show-ref-format` prints `reftable` only when `extensions.refStorage` is set, so a
364 /// listing without that key, read without global options, proves `files`; any listing that names the key
365 /// (in any scope, from any file) is left to Git, which also applies the repository-format rules.
366 ///
367 /// In TS you'd write (pseudocode):
368 /// ```ts
369 /// snapshot.withoutGlobalOptions && !snapshot.entries.some((entry) => entry.key === 'extensions.refstorage');
370 /// ```
371 pub fn proves_files_ref_storage(&self) -> bool {
372 return self.without_global_options
373 && !self.entries.iter().any(|entry| { return entry.key == "extensions.refstorage"; });
374 }
375}
376
377/// What:
378/// Read the configuration once with the caller's context.
379/// Why:
380/// A start failure, a non-zero exit (a malformed configuration file) or a listing this module cannot parse
381/// gives `None`, and every consumer then starts Git for its own key, so the result is Git's own answer.
382///
383/// In TS you'd write (pseudocode):
384/// ```ts
385/// const snapshot = await readConfigSnapshot({ context, cwd }); // undefined means: ask Git per key
386/// ```
387pub fn read_config_snapshot(context: &GitContext, cwd: &Path) -> Option<ConfigSnapshot> {
388 let request: GitRequest = GitRequest::new(cwd, &["config", "--list", "-z", "--show-scope"]);
389 let output = match run_git(context, &request) {
390 Ok(finished) => finished,
391 Err(error) => {
392 debug("readConfigSnapshot", format!("git config --list could not start: {error}").as_str());
393 return None;
394 }
395 };
396 if !output.succeeded() {
397 debug(
398 "readConfigSnapshot",
399 format!("git config --list failed: {}", output.error_text()).as_str(),
400 );
401 return None;
402 }
403 let parsed: Option<ConfigSnapshot> =
404 ConfigSnapshot::parse(output.stdout.as_slice(), context.global_prefix.is_empty());
405 if parsed.is_none() {
406 debug("readConfigSnapshot", "the configuration listing has an unexpected shape");
407 }
408 return parsed;
409}
410
411/// Snapshot controls: parsing, each lookup against real Git, and the generated differential cases.
412#[cfg(test)]
413#[path = "config_snapshot_tests.rs"]
414mod tests;