Skip to main content

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