Skip to main content

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;