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;