git_policy_cli/maintenance_decision.rs
1//! What:
2//! The decision behind automatic maintenance after a landing:
3//! parsing `git config --null --get-regexp` records,
4//! and choosing whether `git maintenance run --auto` runs and whether it detaches.
5//! Why:
6//! Native `git commit` runs this step after updating `HEAD`;
7//! a landing replaces that commit with plumbing,
8//! so the pack count grows by one per commit unless this step runs.
9//! The rules mirror Git's `prepare_auto_maintenance` in `run-command.c`,
10//! including JavaScript number semantics for values the incumbent reads with `Number(...)`.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const args = autoMaintenanceArgs(parseConfigRecords(output));
15//! ```
16
17/// `BTreeMap` keeps config keys sorted; its `insert` keeps the last value of a repeated key.
18use std::collections::BTreeMap;
19/// `fmt` formats the error for diagnostics.
20use std::fmt;
21
22/// The config key pattern the decision reads, as `git config --get-regexp` matches it.
23/// Keys are canonicalized as Git prints them.
24///
25/// In TS you'd write (pseudocode):
26/// ```ts
27/// const MAINTENANCE_CONFIG_PATTERN = '^(maintenance\\.auto|maintenance\\.autodetach|gc\\.auto|gc\\.autodetach)$';
28/// ```
29pub const MAINTENANCE_CONFIG_PATTERN: &str =
30 r"^(maintenance\.auto|maintenance\.autodetach|gc\.auto|gc\.autodetach)$";
31
32/// What:
33/// Why automatic maintenance could not be decided or run.
34/// Why:
35/// Each variant is a failure the caller can name;
36/// a start failure is kept as text so the error stays comparable in tests.
37///
38/// In TS you'd write (pseudocode):
39/// ```ts
40/// class AutoMaintenanceError extends Error {}
41/// ```
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub enum MaintenanceError {
44 /// Git's config output for the maintenance keys is not UTF-8.
45 ConfigNotUtf8,
46 /// Real Git could not be started; the text names the command and the operating-system reason.
47 Start(String),
48}
49
50/// Display text for a failed maintenance decision or run.
51impl fmt::Display for MaintenanceError {
52 /// What:
53 /// Human-readable text for the variant.
54 /// Why:
55 /// Diagnostics print the error without a debug format.
56 ///
57 /// In TS you'd write (pseudocode):
58 /// ```ts
59 /// toString(): string
60 /// ```
61 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::ConfigNotUtf8 => {
64 return formatter.write_str("git config output for maintenance is not UTF-8");
65 }
66 Self::Start(reason) => return formatter.write_str(reason.as_str()),
67 }
68 }
69}
70
71/// The error trait lets callers box this error.
72impl std::error::Error for MaintenanceError {}
73
74/// What:
75/// The arguments automatic maintenance runs, or the fact that configuration turned it off.
76/// Why:
77/// The incumbent's disabled marker is a symbol;
78/// an enum keeps the two outcomes distinct without a sentinel value.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// type MaintenanceDecision = typeof AUTO_MAINTENANCE_DISABLED | readonly string[];
83/// ```
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub enum MaintenanceDecision {
86 /// `maintenance.auto`, or a non-positive `gc.auto` when it is unset, turns maintenance off.
87 Disabled,
88 /// Run `git maintenance run --auto --quiet` with the listed arguments.
89 Run(Vec<&'static str>),
90}
91
92/// What:
93/// Parse `git config --type=bool-or-int --null --get-regexp` output into canonical key to value.
94/// A record without a newline is a key with no value, which Git reads as `true`.
95/// Why:
96/// Git prints one NUL-terminated record per key occurrence;
97/// the last occurrence wins, as Git's single-value lookup does.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// function parseConfigRecords(output: string): Record<string, string>;
102/// ```
103pub fn parse_config_records(output: &[u8]) -> Result<BTreeMap<String, String>, MaintenanceError> {
104 // Git's config output is decoded strictly, as the incumbent's `TextDecoder` with `fatal`.
105 let text: &str = std::str::from_utf8(output).map_err(|_| return MaintenanceError::ConfigNotUtf8)?;
106 // `mut` lets each record overwrite an earlier value of the same key.
107 let mut parsed: BTreeMap<String, String> = BTreeMap::new();
108 for record in text.split('\0') {
109 if record.is_empty() {
110 continue;
111 }
112 match record.split_once('\n') {
113 Some((key, value)) => {
114 parsed.insert(String::from(key), String::from(value));
115 }
116 None => {
117 parsed.insert(String::from(record), String::from("true"));
118 }
119 }
120 }
121 return Ok(parsed);
122}
123
124/// What:
125/// Interpret a `bool-or-int` value as Git's boolean:
126/// `true` and `false` by name, otherwise any non-zero number.
127/// Why:
128/// The incumbent's `Number(value) !== 0` reads a value that is neither as true,
129/// because `NaN !== 0`;
130/// the same rule applies here.
131///
132/// In TS you'd write (pseudocode):
133/// ```ts
134/// function boolOrIntTrue(value: string): boolean { return value === 'true' || (value !== 'false' && Number(value) !== 0); }
135/// ```
136fn bool_or_int_true(value: &str) -> bool {
137 if value == "true" {
138 return true;
139 }
140 if value == "false" {
141 return false;
142 }
143 return javascript_number(value) != 0.0;
144}
145
146/// What:
147/// Read a config value as JavaScript's `Number(...)` does:
148/// an empty or blank value is zero, and text that is not a number is `NaN`.
149/// Why:
150/// The incumbent compares `Number(gc.auto)` against `0`,
151/// so `NaN` makes both comparisons false.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// Number(value)
156/// ```
157fn javascript_number(value: &str) -> f64 {
158 let trimmed: &str = value.trim();
159 if trimmed.is_empty() {
160 return 0.0;
161 }
162 return trimmed.parse::<f64>().unwrap_or(f64::NAN);
163}
164
165/// What:
166/// The arguments native `git commit` would give `git maintenance run`,
167/// or `Disabled` when configuration turns automatic maintenance off.
168/// Why:
169/// `maintenance.auto` takes precedence over `gc.auto`;
170/// the detach setting follows the same precedence.
171/// Detaching is the default, as in Git.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// autoMaintenanceArgs({}); // ['maintenance', 'run', '--auto', '--quiet', '--detach']
176/// ```
177pub fn auto_maintenance_arguments(config: &BTreeMap<String, String>) -> MaintenanceDecision {
178 // `get` returns the value of a key that Git printed, or nothing when it was unset.
179 let maintenance_auto: Option<&String> = config.get("maintenance.auto");
180 let gc_auto: Option<&String> = config.get("gc.auto");
181 // Whether maintenance runs: an explicit `maintenance.auto` decides alone, otherwise `gc.auto`.
182 let enabled: bool = match maintenance_auto {
183 Some(value) => bool_or_int_true(value.as_str()),
184 None => match gc_auto {
185 None => true,
186 Some(value) => javascript_number(value.as_str()) > 0.0,
187 },
188 };
189 if !enabled {
190 return MaintenanceDecision::Disabled;
191 }
192 // The first configured detach setting; unset means detach.
193 let detach_setting: Option<&String> = config
194 .get("maintenance.autodetach")
195 .or_else(|| return config.get("gc.autodetach"));
196 let detach: bool = match detach_setting {
197 None => true,
198 Some(value) => bool_or_int_true(value.as_str()),
199 };
200 let flag: &'static str = if detach { "--detach" } else { "--no-detach" };
201 return MaintenanceDecision::Run(vec!["maintenance", "run", "--auto", "--quiet", flag]);
202}
203
204/// Decision controls live beside the decision.
205#[cfg(test)]
206#[path = "maintenance_decision_tests.rs"]
207mod tests;