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;