Skip to main content

git_policy_cli/policy/
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 crate::diagnostics::EngineFailureCode;
31/// Import the change list a converged fix installs,
32///  and the installer.
33use crate::direct_fix_install::{InstallChange, install_corrections};
34/// Import the event form of a pathname.
35use crate::event_path::EventPath;
36/// Import the shipped checks,
37///  whose content state carries the corrections.
38use crate::policy_checks::ShippedChecks;
39/// Import one proposed correction.
40use crate::policy_content::Correction;
41/// Import the bounded loop and its interface.
42use crate::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 crate::policy_engine::{StageEnd, StageRequest, pass_exit_code};
48/// Import the events a fix reports.
49use crate::policy_events::PolicyEvent;
50/// Import one policy pass and its result.
51use crate::policy_pass::{PassResult, run_policy_pass};
52/// Import the lifecycle point of a fix.
53use crate::policy_trigger::Trigger;
54/// Import the facts interface.
55use crate::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            hint: crate::policy_events::EngineFailureHint::OfCode,
251            message: String::from(message),
252            trigger: Some(Trigger::DirectFix),
253            // `None`: the fix itself failed, not one policy, and no file is named.
254            policy: None,
255            path: None,
256        }],
257        end: StageEnd::Failed,
258    };
259}
260
261/// What:
262///  The result of a settled fix:
263///  the last pass as it is,
264///  or that pass followed by a
265///       fix summary once its corrections were installed.
266///  `Option<&Path>` is the
267///       worktree's top level or nothing.
268/// Why:
269///   Only a last pass that exits 0 installs anything,
270///  and the summary is reported only
271///       after a file changed.
272///
273/// In TS you'd write (pseudocode):
274/// ```ts
275/// async function settledFix(pass, changedPasses, checks, root): Promise<PassResult>;
276/// ```
277pub fn settled_fix<F: RepositoryFacts>(
278    pass: PassResult,
279    changed_passes: usize,
280    checks: &ShippedChecks<F>,
281    root: Option<&Path>,
282) -> PassResult {
283    if pass_exit_code(pass.events.as_slice(), pass.end) != 0 {
284        return pass;
285    }
286    let changes: Vec<InstallChange> = checks.content.install_changes();
287    if changes.is_empty() {
288        return pass;
289    }
290    let (Some(top_level), Some(real_index)) = (root, checks.content.real_index()) else {
291        return fix_failure(
292            pass.arguments.as_slice(),
293            EngineFailureCode::TransactionFailed,
294            NO_WORKTREE_MESSAGE,
295        );
296    };
297    if let Err(message) = install_corrections(top_level, real_index, changes.as_slice()) {
298        return fix_failure(
299            pass.arguments.as_slice(),
300            EngineFailureCode::TransactionFailed,
301            message.as_str(),
302        );
303    }
304    let mut changed_paths: Vec<EventPath> = Vec::new();
305    for change in &changes {
306        changed_paths.push(EventPath::from_git_bytes(change.path.as_slice()));
307    }
308    let mut events: Vec<PolicyEvent> = pass.events;
309    events.push(PolicyEvent::FixSummary {
310        trigger: Trigger::DirectFix,
311        // `as u64` widens the count to the event's number type.
312        passes: changed_passes as u64,
313        changed_paths,
314    });
315    return PassResult {
316        arguments: pass.arguments,
317        events,
318        end: pass.end,
319    };
320}
321
322/// What:
323///  What a fix reports once the loop ended:
324///  `convergence` says how,
325///  `pass` is the
326///       last pass run.
327/// Why:
328///   A settled fix may install;
329///  a blocked fix reports the pass that could not decide;
330///       a cycle and a pass limit report one engine failure each,
331///  as the installed
332///       wrapper's `fixCycleFailure` and `fixPassLimitFailure` do.
333///
334/// In TS you'd write (pseudocode):
335/// ```ts
336/// async function finishFix(convergence, pass, checks, root): Promise<PassResult>;
337/// ```
338pub fn finish_fix<F: RepositoryFacts>(
339    convergence: Convergence,
340    pass: PassResult,
341    checks: &ShippedChecks<F>,
342    root: Option<&Path>,
343) -> PassResult {
344    match convergence {
345        Convergence::Settled { changed_passes } => {
346            return settled_fix(pass, changed_passes, checks, root);
347        }
348        Convergence::Blocked => return pass,
349        Convergence::FixCycle => {
350            return fix_failure(
351                pass.arguments.as_slice(),
352                EngineFailureCode::FixCycle,
353                DIRECT_FIX_CYCLE_MESSAGE,
354            );
355        }
356        Convergence::FixPassLimit => {
357            return fix_failure(
358                pass.arguments.as_slice(),
359                EngineFailureCode::FixPassLimit,
360                FIX_PASS_LIMIT_MESSAGE,
361            );
362        }
363    }
364}
365
366/// What:
367///  Run a whole direct fix and return what it reports.
368///  `root` is the worktree's top
369///       level,
370///  where corrected files are installed.
371/// Why:
372///   See the module comment.
373///
374/// In TS you'd write (pseudocode):
375/// ```ts
376/// async function runDirectFix(request, checks, root): Promise<PassResult>;
377/// ```
378pub fn run_direct_fix<F: RepositoryFacts>(
379    request: &StageRequest,
380    checks: &mut ShippedChecks<F>,
381    root: Option<&Path>,
382) -> PassResult {
383    let mut passes: DirectFixPasses<'_, F> = DirectFixPasses::new(request, checks);
384    let convergence: Convergence = converge(&mut passes);
385    let Some(pass) = passes.last else {
386        unreachable!("the bounded loop runs at least one pass");
387    };
388    return finish_fix(convergence, pass, checks, root);
389}
390
391/// Pass,
392///  settlement and installation-boundary controls stay out of the release executable.
393#[cfg(test)]
394#[path = "direct_fix_tests.rs"]
395mod tests;