Skip to main content

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