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;