Skip to main content

git_policy_cli/
maintenance_run.rs

1//! What:
2//!  Running automatic maintenance after a landing:
3//!  one `git config` read for the maintenance keys (or the commit's configuration snapshot),
4//!  then `git maintenance run --auto --quiet`, detached or not, in the real worktree.
5//!  Its exit status is returned for the caller to ignore, as native Git does.
6//! Why:
7//!  Every landing migrates its objects into one pack;
8//!  without this step the pack count grows by one per commit and every later Git process searches every pack.
9//!  Failures are logged and never fail the landed commit.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await runAutoMaintenance({ gitPath, cwd, globalArgs });
14//! ```
15
16/// The decision and its error type.
17use super::config_snapshot::ConfigSnapshot;
18use super::maintenance_decision::{
19    MAINTENANCE_CONFIG_PATTERN, MaintenanceDecision, MaintenanceError, auto_maintenance_arguments,
20    parse_config_records,
21};
22/// Diagnostics on standard error, switched like the incumbent's console sink.
23use super::diagnostic_log;
24/// The real-Git context and the captured output of one start.
25use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
26/// Ordered map of the maintenance keys to their canonical text.
27use std::collections::BTreeMap;
28/// `Path` is a borrowed filesystem path.
29use std::path::Path;
30
31/// Tag the incumbent's logger prints for this function, so diagnostics match.
32const TAG: &str = "runAutoMaintenance";
33
34/// Exit status `parse_options` uses for an unknown option,
35///  which Git before 2.47 reports for `maintenance run --detach`.
36const USAGE_EXIT_CODE: i32 = 129;
37
38/// Exit status of `git config --get-regexp` when no key matches.
39const CONFIG_UNSET_EXIT_CODE: i32 = 1;
40
41/// What:
42///  What automatic maintenance did.
43/// Why:
44///   Configuration can turn it off, which is not an exit status;
45///  the caller distinguishes the two.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// type Outcome = typeof AUTO_MAINTENANCE_DISABLED | number;
50/// ```
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum AutoMaintenanceOutcome {
53    /// Configuration disabled maintenance; no maintenance Git ran.
54    Disabled,
55    /// Git's exit status for the maintenance run, or for the config read when that read failed.
56    Exited(i32),
57}
58
59/// What:
60///  Git's exit status, where a signal ending Git counts as `1`.
61/// Why:
62///   The incumbent reads a missing exit code as `1` (`exitCode ?? 1`).
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// child.exitCode ?? 1
67/// ```
68fn exit_status(code: Option<i32>) -> i32 {
69    return code.unwrap_or(1);
70}
71
72/// What:
73///  Map a start failure to a maintenance error that names the command.
74/// Why:
75///   The caller logs one line that says which Git start could not run.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// throw new CommitTransactionGitError(`${command} could not start`);
80/// ```
81fn start_failure(command: &str, error: std::io::Error) -> MaintenanceError {
82    return MaintenanceError::Start(format!("{command} could not start: {error}"));
83}
84
85/// What:
86///  The maintenance keys as the decision reads them, or the status of a config read that failed.
87/// Why:
88///   A failed read ends maintenance with Git's status, which is not an error and not a decision.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// type MaintenanceConfig = { records: Map<string, string> } | { exited: number };
93/// ```
94enum MaintenanceConfig {
95    /// Each set key with the text Git prints for its last value.
96    Records(BTreeMap<String, String>),
97    /// The read failed with this status.
98    Exited(i32),
99}
100
101/// What:
102///  Read the maintenance keys by starting `git config`.
103/// Why:
104///   The incumbent's read, kept for a repository whose snapshot is missing or holds a value it does not model.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// await git(['config', '--type=bool-or-int', '--null', '--get-regexp', PATTERN]);
109/// ```
110fn read_maintenance_config(context: &GitContext, cwd: &Path) -> Result<MaintenanceConfig, MaintenanceError> {
111    // Config lookup; exit 1 means no key is set.
112    let lookup: GitOutput = run_git(
113        context,
114        &GitRequest::new(
115            cwd,
116            &[
117                "config",
118                "--type=bool-or-int",
119                "--null",
120                "--get-regexp",
121                MAINTENANCE_CONFIG_PATTERN,
122            ],
123        ),
124    )
125    .map_err(|error| return start_failure("git config", error))?;
126    let lookup_code: i32 = exit_status(lookup.code);
127    if lookup_code != 0 && lookup_code != CONFIG_UNSET_EXIT_CODE {
128        diagnostic_log::warn(
129            TAG,
130            format!(
131                "skipping automatic maintenance: git config exited {lookup_code}: {}",
132                String::from_utf8_lossy(&lookup.stderr).trim()
133            )
134            .as_str(),
135        );
136        return Ok(MaintenanceConfig::Exited(lookup_code));
137    }
138    // An unset key prints nothing, so an exit of 1 parses as an empty configuration.
139    let config_bytes: &[u8] = if lookup_code == 0 {
140        lookup.stdout.as_slice()
141    } else {
142        &[]
143    };
144    return Ok(MaintenanceConfig::Records(parse_config_records(config_bytes)?));
145}
146
147/// What:
148///  Run automatic maintenance in the real worktree with the caller's global options,
149///  reading the maintenance keys by starting `git config`.
150/// Why:
151///   The read and the run happen in the same order the incumbent uses,
152///  so the decision and its effect agree on one configuration snapshot per read.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// await runAutoMaintenance({ gitPath, cwd, globalArgs });
157/// ```
158pub fn run_auto_maintenance(
159    context: &GitContext,
160    cwd: &Path,
161) -> Result<AutoMaintenanceOutcome, MaintenanceError> {
162    return run_auto_maintenance_with(context, cwd, None);
163}
164
165/// What:
166///  Run automatic maintenance, taking the maintenance keys from the commit's configuration snapshot when it
167///  models them.
168/// Why:
169///   The snapshot was read when the commit began, so a configuration that changed during the commit is not seen
170///  (a hook that sets `maintenance.auto` is the example);
171///  a snapshot read with global options, or one holding a value it does not model, starts `git config` as before.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// // config from the snapshot, else a read; then `git maintenance run --auto --quiet --detach|--no-detach`
176/// ```
177pub fn run_auto_maintenance_with(
178    context: &GitContext,
179    cwd: &Path,
180    snapshot: Option<&ConfigSnapshot>,
181) -> Result<AutoMaintenanceOutcome, MaintenanceError> {
182    let from_snapshot: Option<BTreeMap<String, String>> = snapshot
183        .filter(|candidate| { return candidate.without_global_options(); })
184                .and_then(ConfigSnapshot::maintenance_records);
185    let config: BTreeMap<String, String> = match from_snapshot {
186        Some(records) => records,
187        None => match read_maintenance_config(context, cwd)? {
188            MaintenanceConfig::Records(records) => records,
189            MaintenanceConfig::Exited(code) => return Ok(AutoMaintenanceOutcome::Exited(code)),
190        },
191    };
192    // Native maintenance arguments, or the disabled outcome.
193    let arguments: Vec<&'static str> = match auto_maintenance_arguments(&config) {
194        MaintenanceDecision::Disabled => {
195            diagnostic_log::debug(
196                TAG,
197                "automatic maintenance disabled by configuration",
198            );
199            return Ok(AutoMaintenanceOutcome::Disabled);
200        }
201        MaintenanceDecision::Run(arguments) => arguments,
202    };
203    diagnostic_log::debug(TAG, format!("running git {}", arguments.join(" ")).as_str());
204    // Maintenance outcome; a detached run returns once the background process is forked.
205    let result: GitOutput = run_git(
206        context,
207        &GitRequest::new(cwd, arguments.as_slice()),
208    )
209    .map_err(|error| return start_failure("git maintenance run", error))?;
210    let result_code: i32 = exit_status(result.code);
211    if result_code != USAGE_EXIT_CODE {
212        diagnostic_log::debug(
213            TAG,
214            format!("automatic maintenance exited {result_code}").as_str(),
215        );
216        return Ok(AutoMaintenanceOutcome::Exited(result_code));
217    }
218    // Git before 2.47 has no detach option; its native form omits it and gc detaches on its own.
219    let flag: &str = arguments.last().copied().unwrap_or("");
220    diagnostic_log::debug(
221        TAG,
222        format!(
223            "git rejected {flag}; retrying in the pre-2.47 form: {}",
224            String::from_utf8_lossy(&result.stderr).trim()
225        )
226        .as_str(),
227    );
228    let fallback_arguments: &[&str] = &arguments[..arguments.len() - 1];
229    let fallback: GitOutput = run_git(
230        context,
231        &GitRequest::new(cwd, fallback_arguments),
232    )
233    .map_err(|error| return start_failure("git maintenance run", error))?;
234    let fallback_code: i32 = exit_status(fallback.code);
235    diagnostic_log::debug(
236        TAG,
237        format!("automatic maintenance exited {fallback_code}").as_str(),
238    );
239    return Ok(AutoMaintenanceOutcome::Exited(fallback_code));
240}
241
242/// Runner controls live beside the runner.
243#[cfg(test)]
244#[path = "maintenance_run_tests.rs"]
245mod tests;