git_policy_cli/direct_fix.rs
1//! What:
2//! `git cli-git fix`:
3//! run policy passes over the selected files,
4//! apply the
5//! corrections they propose in memory until a pass proposes none,
6//! then install the
7//! corrected files into the worktree when that last pass is clean.
8//! Why:
9//! The installed wrapper's `direct-fix.ts` and `direct-fix-convergence.ts` decide the
10//! behaviour:
11//! only the last pass is reported,
12//! a fix summary follows it only when a
13//! file changed,
14//! nothing reaches the worktree unless the last pass exits 0,
15//! and a
16//! fix that cycles or keeps changing ends with one engine failure.
17//! The bounded loop
18//! itself is `policy_convergence::converge`;
19//! this module supplies its passes.
20//! Corrections replace whole file contents in memory instead of being staged into
21//! the private index,
22//! so a pass starts no Git process for them.
23//!
24//! In TS you'd write (pseudocode):
25//! ```ts
26//! // const result = await runDirectFix({ gitGlobalArgs, pathspecs, policyOptions });
27//! ```
28
29/// Import the engine-failure codes.
30use super::diagnostics::EngineFailureCode;
31/// Import the change list a converged fix installs,
32/// and the installer.
33use super::direct_fix_install::{InstallChange, install_corrections};
34/// Import the event form of a pathname.
35use super::event_path::EventPath;
36/// Import the shipped checks,
37/// whose content state carries the corrections.
38use super::policy_checks::ShippedChecks;
39/// Import one proposed correction.
40use super::policy_content::Correction;
41/// Import the bounded loop and its interface.
42use super::policy_convergence::{
43 Convergence, FIX_PASS_LIMIT_MESSAGE, FixPasses, PassResult as ConvergencePass, converge,
44};
45/// Import the stage request,
46/// how a stage ended and the exit code of a pass.
47use super::policy_engine::{StageEnd, StageRequest, pass_exit_code};
48/// Import the events a fix reports.
49use super::policy_events::PolicyEvent;
50/// Import one policy pass and its result.
51use super::policy_pass::{PassResult, run_policy_pass};
52/// Import the lifecycle point of a fix.
53use super::policy_trigger::Trigger;
54/// Import the facts interface.
55use super::repository_facts::RepositoryFacts;
56/// `BTreeMap<K, V>` is a map kept sorted by key.
57use std::collections::BTreeMap;
58/// `Path` is a borrowed filesystem path.
59use std::path::Path;
60
61/// The message of a fix whose corrections returned to an earlier state,
62/// as the installed
63/// wrapper's direct fix words it.
64pub const DIRECT_FIX_CYCLE_MESSAGE: &str =
65 "Policy patches entered a repeated candidate-state cycle.";
66
67/// The message of a fix that has no worktree to install into.
68pub const NO_WORKTREE_MESSAGE: &str =
69 "cli-git fix could not install its corrections: the repository has no worktree.";
70
71/// What:
72/// The passes of one direct fix,
73/// as the bounded loop sees them.
74/// `'a` names how
75/// long the borrowed request and checks last.
76/// Why:
77/// The loop owns the control flow;
78/// this owns running a pass,
79/// applying its
80/// corrections and remembering each changed state.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// class DirectFixPasses implements FixPasses { last?: PassResult; states: Map<string, InstallChange>[] }
85/// ```
86pub struct DirectFixPasses<'a, F: RepositoryFacts> {
87 /// What every pass evaluates.
88 request: &'a StageRequest,
89 /// The checks,
90 /// kept across passes so candidates and rules are prepared once.
91 checks: &'a mut ShippedChecks<F>,
92 /// The most recent pass,
93 /// which is the one reported.
94 last: Option<PassResult>,
95 /// The corrected files of every state:
96 /// the first is the uncorrected one.
97 states: Vec<BTreeMap<Vec<u8>, InstallChange>>,
98}
99
100/// What:
101/// How the loop reads a pass's ending.
102/// Why:
103/// A proposal asks for another pass;
104/// a pass that could not decide blocks the fix;
105/// a completed or stopped pass proposes nothing,
106/// so the content is stable.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// const convergencePass = (end) => end === 'proposed' ? 'proposed' : end === 'failed' || isUnavailable(end) ? 'failed' : 'stable';
111/// ```
112pub fn convergence_pass(end: StageEnd) -> ConvergencePass {
113 match end {
114 StageEnd::Proposed => return ConvergencePass::Proposed,
115 StageEnd::Failed | StageEnd::Unavailable(_) => return ConvergencePass::Failed,
116 StageEnd::Completed | StageEnd::Stopped => return ConvergencePass::Stable,
117 }
118}
119
120/// What:
121/// `impl<F: RepositoryFacts> DirectFixPasses<'_, F> { ... }` attaches the constructor
122/// and the hand-back of what the loop left.
123/// Why:
124/// The states start with the uncorrected one,
125/// which the cycle check compares against.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// class DirectFixPasses { constructor(request, checks) {} }
130/// ```
131impl<'a, F: RepositoryFacts> DirectFixPasses<'a, F> {
132 /// What:
133 /// Passes over `checks` with no pass run yet and the uncorrected state stored.
134 /// Why:
135 /// State number `n` is the state after `n` changes,
136 /// as the loop numbers them.
137 ///
138 /// In TS you'd write (pseudocode):
139 /// ```ts
140 /// constructor(request: StageRequest, checks: ShippedChecks)
141 /// ```
142 pub fn new(request: &'a StageRequest, checks: &'a mut ShippedChecks<F>) -> Self {
143 return DirectFixPasses {
144 request,
145 checks,
146 last: None,
147 states: vec![BTreeMap::new()],
148 };
149 }
150}
151
152/// What:
153/// `impl FixPasses for DirectFixPasses<'_, F>` gives the loop its three operations.
154/// Why:
155/// The loop is tested without Git;
156/// these are the real passes.
157///
158/// In TS you'd write (pseudocode):
159/// ```ts
160/// class DirectFixPasses implements FixPasses {}
161/// ```
162impl<F: RepositoryFacts> FixPasses for DirectFixPasses<'_, F> {
163 /// What:
164 /// Run one whole policy pass on the current content and remember it.
165 /// Why:
166 /// Proposals left from an earlier pass were applied already;
167 /// a pass starts
168 /// with none,
169 /// so it is read only from what this pass proposes.
170 ///
171 /// In TS you'd write (pseudocode):
172 /// ```ts
173 /// async runPass(version: number): Promise<'stable' | 'proposed' | 'failed'>;
174 /// ```
175 fn run_pass(&mut self, _candidate_version: usize) -> ConvergencePass {
176 // What: `let _ = ...` drops proposals no `apply` took.
177 // Why: Only a pass that ends proposing is followed by `apply`; a pass that
178 // proposed and then failed must not carry its proposals into another.
179 //
180 // In TS you'd write (pseudocode):
181 // ```ts
182 // content.takeProposals();
183 // ```
184 let _ = self.checks.content.take_proposals();
185 let pass: PassResult = run_policy_pass(self.request, self.checks);
186 let result: ConvergencePass = convergence_pass(pass.end);
187 // `Some(x)` is the "present" case of `Option`.
188 self.last = Some(pass);
189 return result;
190 }
191
192 /// What:
193 /// Apply the last pass's proposals and store the resulting state.
194 /// Why:
195 /// Applying in memory cannot fail,
196 /// so the fix is never blocked here.
197 ///
198 /// In TS you'd write (pseudocode):
199 /// ```ts
200 /// async apply(snapshot: number): Promise<boolean>;
201 /// ```
202 fn apply(&mut self, _snapshot: usize) -> bool {
203 let proposals: Vec<Correction> = self.checks.content.take_proposals();
204 self.checks.content.apply(proposals);
205 self.states.push(self.checks.content.corrected_state());
206 return true;
207 }
208
209 /// What:
210 /// Whether two stored states hold the same bytes in every file.
211 /// Why:
212 /// Equal corrected maps mean equal content,
213 /// because a file whose bytes return to
214 /// its original is no longer in the map.
215 ///
216 /// In TS you'd write (pseudocode):
217 /// ```ts
218 /// async snapshotsEqual(left: number, right: number): Promise<boolean>;
219 /// ```
220 fn snapshots_equal(&mut self, left: usize, right: usize) -> bool {
221 return self.states[left] == self.states[right];
222 }
223}
224
225/// What:
226/// A fix result made of one engine failure of the fix,
227/// as the installed wrapper
228/// reports a cycle,
229/// a pass limit or an installation failure:
230/// earlier events are
231/// dropped.
232/// `&[std::ffi::OsString]` borrows the command's arguments.
233/// Why:
234/// The person sees why nothing was installed,
235/// and nothing else.
236///
237/// In TS you'd write (pseudocode):
238/// ```ts
239/// const fixFailure = (code, message) => ({ events: [engineFailure(code, message, 'direct-fix')], exitCode: 2 });
240/// ```
241pub fn fix_failure(
242 arguments: &[std::ffi::OsString],
243 code: EngineFailureCode,
244 message: &str,
245) -> PassResult {
246 return PassResult {
247 arguments: arguments.to_vec(),
248 events: vec![PolicyEvent::EngineFailure {
249 code,
250 message: String::from(message),
251 trigger: Some(Trigger::DirectFix),
252 // `None`: the fix itself failed, not one policy, and no file is named.
253 policy: None,
254 path: None,
255 }],
256 end: StageEnd::Failed,
257 };
258}
259
260/// What:
261/// The result of a settled fix:
262/// the last pass as it is,
263/// or that pass followed by a
264/// fix summary once its corrections were installed.
265/// `Option<&Path>` is the
266/// worktree's top level or nothing.
267/// Why:
268/// Only a last pass that exits 0 installs anything,
269/// and the summary is reported only
270/// after a file changed.
271///
272/// In TS you'd write (pseudocode):
273/// ```ts
274/// async function settledFix(pass, changedPasses, checks, root): Promise<PassResult>;
275/// ```
276pub fn settled_fix<F: RepositoryFacts>(
277 pass: PassResult,
278 changed_passes: usize,
279 checks: &ShippedChecks<F>,
280 root: Option<&Path>,
281) -> PassResult {
282 if pass_exit_code(pass.events.as_slice(), pass.end) != 0 {
283 return pass;
284 }
285 let changes: Vec<InstallChange> = checks.content.install_changes();
286 if changes.is_empty() {
287 return pass;
288 }
289 let (Some(top_level), Some(real_index)) = (root, checks.content.real_index()) else {
290 return fix_failure(
291 pass.arguments.as_slice(),
292 EngineFailureCode::TransactionFailed,
293 NO_WORKTREE_MESSAGE,
294 );
295 };
296 if let Err(message) = install_corrections(top_level, real_index, changes.as_slice()) {
297 return fix_failure(
298 pass.arguments.as_slice(),
299 EngineFailureCode::TransactionFailed,
300 message.as_str(),
301 );
302 }
303 let mut changed_paths: Vec<EventPath> = Vec::new();
304 for change in &changes {
305 changed_paths.push(EventPath::from_git_bytes(change.path.as_slice()));
306 }
307 let mut events: Vec<PolicyEvent> = pass.events;
308 events.push(PolicyEvent::FixSummary {
309 trigger: Trigger::DirectFix,
310 // `as u64` widens the count to the event's number type.
311 passes: changed_passes as u64,
312 changed_paths,
313 });
314 return PassResult {
315 arguments: pass.arguments,
316 events,
317 end: pass.end,
318 };
319}
320
321/// What:
322/// What a fix reports once the loop ended:
323/// `convergence` says how,
324/// `pass` is the
325/// last pass run.
326/// Why:
327/// A settled fix may install;
328/// a blocked fix reports the pass that could not decide;
329/// a cycle and a pass limit report one engine failure each,
330/// as the installed
331/// wrapper's `fixCycleFailure` and `fixPassLimitFailure` do.
332///
333/// In TS you'd write (pseudocode):
334/// ```ts
335/// async function finishFix(convergence, pass, checks, root): Promise<PassResult>;
336/// ```
337pub fn finish_fix<F: RepositoryFacts>(
338 convergence: Convergence,
339 pass: PassResult,
340 checks: &ShippedChecks<F>,
341 root: Option<&Path>,
342) -> PassResult {
343 match convergence {
344 Convergence::Settled { changed_passes } => {
345 return settled_fix(pass, changed_passes, checks, root);
346 }
347 Convergence::Blocked => return pass,
348 Convergence::FixCycle => {
349 return fix_failure(
350 pass.arguments.as_slice(),
351 EngineFailureCode::FixCycle,
352 DIRECT_FIX_CYCLE_MESSAGE,
353 );
354 }
355 Convergence::FixPassLimit => {
356 return fix_failure(
357 pass.arguments.as_slice(),
358 EngineFailureCode::FixPassLimit,
359 FIX_PASS_LIMIT_MESSAGE,
360 );
361 }
362 }
363}
364
365/// What:
366/// Run a whole direct fix and return what it reports.
367/// `root` is the worktree's top
368/// level,
369/// where corrected files are installed.
370/// Why:
371/// See the module comment.
372///
373/// In TS you'd write (pseudocode):
374/// ```ts
375/// async function runDirectFix(request, checks, root): Promise<PassResult>;
376/// ```
377pub fn run_direct_fix<F: RepositoryFacts>(
378 request: &StageRequest,
379 checks: &mut ShippedChecks<F>,
380 root: Option<&Path>,
381) -> PassResult {
382 let mut passes: DirectFixPasses<'_, F> = DirectFixPasses::new(request, checks);
383 let convergence: Convergence = converge(&mut passes);
384 let Some(pass) = passes.last else {
385 unreachable!("the bounded loop runs at least one pass");
386 };
387 return finish_fix(convergence, pass, checks, root);
388}
389
390/// Pass,
391/// settlement and installation-boundary controls stay out of the release executable.
392#[cfg(test)]
393#[path = "direct_fix_tests.rs"]
394mod tests;