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,
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::maintenance_decision::{
18    MAINTENANCE_CONFIG_PATTERN, MaintenanceDecision, MaintenanceError, auto_maintenance_arguments,
19    parse_config_records,
20};
21/// Diagnostics on standard error, switched like the incumbent's console sink.
22use super::diagnostic_log;
23/// The real-Git context and the captured output of one start.
24use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
25/// `Path` is a borrowed filesystem path.
26use std::path::Path;
27
28/// Tag the incumbent's logger prints for this function, so diagnostics match.
29const TAG: &str = "runAutoMaintenance";
30
31/// Exit status `parse_options` uses for an unknown option,
32///  which Git before 2.47 reports for `maintenance run --detach`.
33const USAGE_EXIT_CODE: i32 = 129;
34
35/// Exit status of `git config --get-regexp` when no key matches.
36const CONFIG_UNSET_EXIT_CODE: i32 = 1;
37
38/// What:
39///  What automatic maintenance did.
40/// Why:
41///   Configuration can turn it off, which is not an exit status;
42///  the caller distinguishes the two.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// type Outcome = typeof AUTO_MAINTENANCE_DISABLED | number;
47/// ```
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum AutoMaintenanceOutcome {
50    /// Configuration disabled maintenance; no maintenance Git ran.
51    Disabled,
52    /// Git's exit status for the maintenance run, or for the config read when that read failed.
53    Exited(i32),
54}
55
56/// What:
57///  Git's exit status, where a signal ending Git counts as `1`.
58/// Why:
59///   The incumbent reads a missing exit code as `1` (`exitCode ?? 1`).
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// child.exitCode ?? 1
64/// ```
65fn exit_status(code: Option<i32>) -> i32 {
66    return code.unwrap_or(1);
67}
68
69/// What:
70///  Map a start failure to a maintenance error that names the command.
71/// Why:
72///   The caller logs one line that says which Git start could not run.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// throw new CommitTransactionGitError(`${command} could not start`);
77/// ```
78fn start_failure(command: &str, error: std::io::Error) -> MaintenanceError {
79    return MaintenanceError::Start(format!("{command} could not start: {error}"));
80}
81
82/// What:
83///  Run automatic maintenance in the real worktree with the caller's global options.
84/// Why:
85///   The read and the run happen in the same order the incumbent uses,
86///  so the decision and its effect agree on one configuration snapshot per read.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// // config read, then `git maintenance run --auto --quiet --detach|--no-detach`; retry without the flag on 129
91/// ```
92pub fn run_auto_maintenance(
93    context: &GitContext,
94    cwd: &Path,
95) -> Result<AutoMaintenanceOutcome, MaintenanceError> {
96    // Config lookup; exit 1 means no key is set.
97    let lookup: GitOutput = run_git(
98        context,
99        &GitRequest::new(
100            cwd,
101            &[
102                "config",
103                "--type=bool-or-int",
104                "--null",
105                "--get-regexp",
106                MAINTENANCE_CONFIG_PATTERN,
107            ],
108        ),
109    )
110    .map_err(|error| return start_failure("git config", error))?;
111    let lookup_code: i32 = exit_status(lookup.code);
112    if lookup_code != 0 && lookup_code != CONFIG_UNSET_EXIT_CODE {
113        diagnostic_log::warn(
114            TAG,
115            format!(
116                "skipping automatic maintenance: git config exited {lookup_code}: {}",
117                String::from_utf8_lossy(&lookup.stderr).trim()
118            )
119            .as_str(),
120        );
121        return Ok(AutoMaintenanceOutcome::Exited(lookup_code));
122    }
123    // An unset key prints nothing, so an exit of 1 parses as an empty configuration.
124    let config_bytes: &[u8] = if lookup_code == 0 {
125        lookup.stdout.as_slice()
126    } else {
127        &[]
128    };
129    let config = parse_config_records(config_bytes)?;
130    // Native maintenance arguments, or the disabled outcome.
131    let arguments: Vec<&'static str> = match auto_maintenance_arguments(&config) {
132        MaintenanceDecision::Disabled => {
133            diagnostic_log::debug(
134                TAG,
135                "automatic maintenance disabled by configuration",
136            );
137            return Ok(AutoMaintenanceOutcome::Disabled);
138        }
139        MaintenanceDecision::Run(arguments) => arguments,
140    };
141    diagnostic_log::debug(TAG, format!("running git {}", arguments.join(" ")).as_str());
142    // Maintenance outcome; a detached run returns once the background process is forked.
143    let result: GitOutput = run_git(
144        context,
145        &GitRequest::new(cwd, arguments.as_slice()),
146    )
147    .map_err(|error| return start_failure("git maintenance run", error))?;
148    let result_code: i32 = exit_status(result.code);
149    if result_code != USAGE_EXIT_CODE {
150        diagnostic_log::debug(
151            TAG,
152            format!("automatic maintenance exited {result_code}").as_str(),
153        );
154        return Ok(AutoMaintenanceOutcome::Exited(result_code));
155    }
156    // Git before 2.47 has no detach option; its native form omits it and gc detaches on its own.
157    let flag: &str = arguments.last().copied().unwrap_or("");
158    diagnostic_log::debug(
159        TAG,
160        format!(
161            "git rejected {flag}; retrying in the pre-2.47 form: {}",
162            String::from_utf8_lossy(&result.stderr).trim()
163        )
164        .as_str(),
165    );
166    let fallback_arguments: &[&str] = &arguments[..arguments.len() - 1];
167    let fallback: GitOutput = run_git(
168        context,
169        &GitRequest::new(cwd, fallback_arguments),
170    )
171    .map_err(|error| return start_failure("git maintenance run", error))?;
172    let fallback_code: i32 = exit_status(fallback.code);
173    diagnostic_log::debug(
174        TAG,
175        format!("automatic maintenance exited {fallback_code}").as_str(),
176    );
177    return Ok(AutoMaintenanceOutcome::Exited(fallback_code));
178}
179
180/// Runner controls live beside the runner.
181#[cfg(test)]
182#[path = "maintenance_run_tests.rs"]
183mod tests;