Skip to main content

git_policy_cli/
commit_convergence.rs

1//! What:
2//!  The commit path's bounded policy convergence over the private commit index:
3//!       each changed pass writes its corrections into the index,
4//!       then the whole ordered pass restarts until it proposes nothing.
5//! Why:
6//!  The commit path must end with the corrections the policies settled on,
7//!       and must stop when they repeat a state or do not settle.
8//!  The loop is the shared `converge`;
9//!       this module supplies the commit path's three operations,
10//!       as the direct fix does for the worktree.
11//!  Exact prior states are compared in memory; the incumbent also streams them to snapshot files,
12//!       which only serve the same comparison.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const convergence = await convergeCommitPolicies({ args, gitPath, cwd, workspace, policyOptions, baseRevision, ... });
17//! ```
18
19use super::diagnostics::EngineFailureCode;
20use super::direct_fix::convergence_pass;
21use super::direct_fix_install::InstallChange;
22use super::policy_checks::ShippedChecks;
23use super::policy_content::Correction;
24use super::policy_convergence::{Convergence, FIX_PASS_LIMIT_MESSAGE, FixPasses, PassResult as ConvergencePass, converge};
25use super::policy_engine::{StageEnd, StageRequest};
26use super::policy_events::PolicyEvent;
27use super::policy_pass::{PassResult, run_policy_pass};
28use super::policy_trigger::Trigger;
29use super::private_index_stage::{PrivateStage, stage_correction};
30use super::repository_facts::RepositoryFacts;
31use std::collections::BTreeMap;
32use std::ffi::OsString;
33
34/// What:
35///  The message of a commit-path loop that repeated an exact prior state.
36/// Why:
37///   The wording is the incumbent's, so the two paths report the same failure.
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// const message = 'Policy patches repeated an exact prior candidate state.';
42/// ```
43pub const COMMIT_CYCLE_MESSAGE: &str = "Policy patches repeated an exact prior candidate state.";
44
45/// What:
46///  Everything one commit-path convergence reads and writes.
47/// Why:
48///   The request, the shipped checks and the private index travel together, so no caller can pair them wrongly.
49///
50/// In TS you'd write (pseudocode):
51/// ```ts
52/// type ConvergenceInput = { request; checks; stage };
53/// ```
54pub struct CommitConvergenceInput<'a, F: RepositoryFacts> {
55    /// The pre-forward stage request the passes run under.
56    pub request: &'a StageRequest,
57    /// The shipped checks whose facts read the private index.
58    pub checks: &'a mut ShippedChecks<F>,
59    /// The private index and object store the corrections are written to.
60    pub stage: PrivateStage<'a>,
61}
62
63/// What:
64///  The settled or stopped result of one commit-path convergence.
65/// Why:
66///   The caller needs the outcome, the events that explain it, and the paths the loop corrected.
67///
68/// In TS you'd write (pseudocode):
69/// ```ts
70/// type CommitConvergence = { kind: 'settled' | 'blocked'; pass; changedPaths };
71/// ```
72pub struct CommitConvergence {
73    /// How the bounded loop ended.
74    pub convergence: Convergence,
75    /// The pass whose events describe the outcome.
76    pub pass: PassResult,
77    /// Paths whose private-index entry a correction wrote, in first-write order.
78    pub changed_paths: Vec<String>,
79}
80
81/// What:
82///  The commit path's three loop operations over the private index.
83/// Why:
84///   The loop calls these in order; holding the state here keeps each pass's proposals with its own writes.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// class CommitPasses { runPass(); apply(); snapshotsEqual(); }
89/// ```
90struct CommitPasses<'a, F: RepositoryFacts> {
91    /// The pre-forward request.
92    request: &'a StageRequest,
93    /// The shipped checks.
94    checks: &'a mut ShippedChecks<F>,
95    /// The private index and object store.
96    stage: PrivateStage<'a>,
97    /// The most recent pass.
98    last: Option<PassResult>,
99    /// The pass that failed to write its corrections, when that is why the loop stopped.
100    blocked: Option<PassResult>,
101    /// Exact corrected state after each changed pass, starting from the uncorrected state.
102    states: Vec<BTreeMap<Vec<u8>, InstallChange>>,
103    /// Paths written so far, in first-write order.
104    changed_paths: Vec<String>,
105}
106
107impl<'a, F: RepositoryFacts> CommitPasses<'a, F> {
108    /// What:
109    ///  Start the loop with the uncorrected state stored as state zero.
110    /// Why:
111    ///   Snapshot zero is the state every later change is compared against.
112    ///
113    /// In TS you'd write (pseudocode):
114    /// ```ts
115    /// const visited = [firstSnapshot];
116    /// ```
117    fn new(input: CommitConvergenceInput<'a, F>) -> Self {
118        return CommitPasses {
119            request: input.request,
120            checks: input.checks,
121            stage: input.stage,
122            last: None,
123            blocked: None,
124            states: vec![BTreeMap::new()],
125            changed_paths: Vec::new(),
126        };
127    }
128}
129
130impl<F: RepositoryFacts> FixPasses for CommitPasses<'_, F> {
131    fn run_pass(&mut self, _candidate_version: usize) -> ConvergencePass {
132        // What: `let _ = ...` drops proposals no `apply` took.
133        // Why:  Only a pass that ends proposing is followed by `apply`.
134        //
135        // In TS you'd write (pseudocode):
136        // ```ts
137        // content.takeProposals();
138        // ```
139        let _ = self.checks.content.take_proposals();
140        let pass: PassResult = run_policy_pass(self.request, self.checks);
141        let result: ConvergencePass = convergence_pass(pass.end);
142        self.last = Some(pass);
143        return result;
144    }
145
146    fn apply(&mut self, _snapshot: usize) -> bool {
147        let proposals: Vec<Correction> = self.checks.content.take_proposals();
148        let arguments: Vec<OsString> = match &self.last {
149            Some(pass) => pass.arguments.clone(),
150            None => unreachable!("apply follows a pass that proposed corrections"),
151        };
152        for correction in &proposals {
153            if let Err(message) = stage_correction(&self.stage, correction) {
154                let path: String = String::from_utf8_lossy(correction.path.as_slice()).into_owned();
155                self.blocked = Some(patch_conflict_pass(arguments.as_slice(), path.as_str(), message));
156                return false;
157            }
158            record_path(&mut self.changed_paths, correction);
159        }
160        self.checks.content.apply(proposals);
161        self.states.push(self.checks.content.corrected_state());
162        return true;
163    }
164
165    fn snapshots_equal(&mut self, left: usize, right: usize) -> bool {
166        return self.states[left] == self.states[right];
167    }
168}
169
170/// What:
171///  Add a correction's path to the written list once, keeping first-write order.
172/// Why:
173///   The summary names each path once, however many passes correct it.
174///
175/// In TS you'd write (pseudocode):
176/// ```ts
177/// changedPaths.add(path);
178/// ```
179fn record_path(changed: &mut Vec<String>, correction: &Correction) {
180    let path: String = String::from_utf8_lossy(correction.path.as_slice()).into_owned();
181    if !changed.contains(&path) {
182        changed.push(path);
183    }
184}
185
186/// What:
187///  The pass that stops the commit path because a correction did not write to the private index.
188/// Why:
189///   The incumbent maps a failed patch application to `patch-conflict`, naming the path.
190///
191/// In TS you'd write (pseudocode):
192/// ```ts
193/// patchApplicationFailure({ path, message });
194/// ```
195fn patch_conflict_pass(arguments: &[OsString], path: &str, message: String) -> PassResult {
196    return PassResult {
197        arguments: arguments.to_vec(),
198        events: vec![PolicyEvent::EngineFailure {
199            code: EngineFailureCode::PatchConflict,
200            message: format!("Policy patch for {path} does not apply to the private index: {message}"),
201            trigger: Some(Trigger::PreForward),
202            policy: None,
203            path: Some(super::event_path::EventPath::from_git_bytes(path.as_bytes())),
204        }],
205        end: StageEnd::Failed,
206    };
207}
208
209/// What:
210///  A pass that stops the commit path with an engine failure code and message.
211/// Why:
212///   Cycle and pass-limit failures are engine failures, not policy findings.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// fixCycleFailure({ previous, trigger: 'pre-forward', message });
217/// ```
218fn engine_failure_pass(arguments: &[OsString], code: EngineFailureCode, message: &str) -> PassResult {
219    return PassResult {
220        arguments: arguments.to_vec(),
221        events: vec![PolicyEvent::EngineFailure {
222            code,
223            message: String::from(message),
224            trigger: Some(Trigger::PreForward),
225            policy: None,
226            path: None,
227        }],
228        end: StageEnd::Failed,
229    };
230}
231
232/// What:
233///  Run the bounded commit-path convergence and return the pass that describes its outcome.
234/// Why:
235///   Settled passes keep their own events; each stop reports its cause as the incumbent does.
236///
237/// In TS you'd write (pseudocode):
238/// ```ts
239/// return await convergeCommitPolicies({ ... });
240/// ```
241pub fn converge_commit_policies<F: RepositoryFacts>(input: CommitConvergenceInput<'_, F>) -> CommitConvergence {
242    input.checks.can_apply_patches = true;
243    let mut passes: CommitPasses<'_, F> = CommitPasses::new(input);
244    let convergence: Convergence = converge(&mut passes);
245    let Some(last) = passes.last.take() else {
246        unreachable!("the bounded loop runs at least one pass");
247    };
248    let pass: PassResult = finish(convergence, last, passes.blocked.take());
249    return CommitConvergence {
250        convergence,
251        pass,
252        changed_paths: passes.changed_paths,
253    };
254}
255
256/// What:
257///  Choose the pass that describes a convergence outcome.
258/// Why:
259///   A blocked loop reports the write that failed when there was one, otherwise the pass that blocked it.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// switch (convergence.kind) { case 'blocked': return blocked ?? last; }
264/// ```
265fn finish(convergence: Convergence, last: PassResult, blocked: Option<PassResult>) -> PassResult {
266    match convergence {
267        Convergence::Settled { .. } => return last,
268        Convergence::Blocked => match blocked {
269            Some(found) => return found,
270            None => return last,
271        },
272        Convergence::FixCycle => {
273            return engine_failure_pass(last.arguments.as_slice(), EngineFailureCode::FixCycle, COMMIT_CYCLE_MESSAGE);
274        }
275        Convergence::FixPassLimit => {
276            return engine_failure_pass(last.arguments.as_slice(), EngineFailureCode::FixPassLimit, FIX_PASS_LIMIT_MESSAGE);
277        }
278    }
279}
280
281#[cfg(test)]
282#[path = "commit_convergence_tests.rs"]
283mod tests;