Skip to main content

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 any entry defines a hook by name: a `hook.<name>.event` or `hook.<name>.command` key.
362    /// Why:
363    ///   Git runs such a hook for its event whatever the hooks directory holds, so a repository with one keeps
364    ///  every dispatcher entry.
365    ///
366    /// In TS you'd write (pseudocode):
367    /// ```ts
368    /// entries.some((entry) => /^hook\..*\.(event|command)$/.test(entry.key));
369    /// ```
370    pub fn defines_named_hook(&self) -> bool {
371        return self.entries.iter().any(|entry| {
372            return entry.key.starts_with("hook.")
373                && (entry.key.ends_with(".event") || entry.key.ends_with(".command"));
374        });
375    }
376
377    /// What:
378    ///  Whether the ref storage is proven to be the files backend.
379    /// Why:
380    ///   `git rev-parse --show-ref-format` prints `reftable` only when `extensions.refStorage` is set, so a
381    ///  listing without that key, read without global options, proves `files`; any listing that names the key
382    ///  (in any scope, from any file) is left to Git, which also applies the repository-format rules.
383    ///
384    /// In TS you'd write (pseudocode):
385    /// ```ts
386    /// snapshot.withoutGlobalOptions && !snapshot.entries.some((entry) => entry.key === 'extensions.refstorage');
387    /// ```
388    pub fn proves_files_ref_storage(&self) -> bool {
389        return self.without_global_options
390            && !self.entries.iter().any(|entry| { return entry.key == "extensions.refstorage"; });
391    }
392}
393
394/// What:
395///  Read the configuration once with the caller's context.
396/// Why:
397///   A start failure, a non-zero exit (a malformed configuration file) or a listing this module cannot parse
398///  gives `None`, and every consumer then starts Git for its own key, so the result is Git's own answer.
399///
400/// In TS you'd write (pseudocode):
401/// ```ts
402/// const snapshot = await readConfigSnapshot({ context, cwd }); // undefined means: ask Git per key
403/// ```
404pub fn read_config_snapshot(context: &GitContext, cwd: &Path) -> Option<ConfigSnapshot> {
405    let request: GitRequest = GitRequest::new(cwd, &["config", "--list", "-z", "--show-scope"]);
406    let output = match run_git(context, &request) {
407        Ok(finished) => finished,
408        Err(error) => {
409            debug("readConfigSnapshot", format!("git config --list could not start: {error}").as_str());
410            return None;
411        }
412    };
413    if !output.succeeded() {
414        debug(
415            "readConfigSnapshot",
416            format!("git config --list failed: {}", output.error_text()).as_str(),
417        );
418        return None;
419    }
420    let parsed: Option<ConfigSnapshot> =
421        ConfigSnapshot::parse(output.stdout.as_slice(), context.global_prefix.is_empty());
422    if parsed.is_none() {
423        debug("readConfigSnapshot", "the configuration listing has an unexpected shape");
424    }
425    return parsed;
426}
427
428/// Snapshot controls: parsing, each lookup against real Git, and the generated differential cases.
429#[cfg(test)]
430#[path = "config_snapshot_tests.rs"]
431mod tests;