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::path_bytes::path_message_text;
29use super::policy_trigger::Trigger;
30use super::private_index_stage::{PrivateStage, stage_correction};
31use super::repository_facts::RepositoryFacts;
32use std::collections::BTreeMap;
33use std::ffi::OsString;
34
35/// What:
36///  The message of a commit-path loop that repeated an exact prior state.
37/// Why:
38///   The wording is the incumbent's, so the two paths report the same failure.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// const message = 'Policy patches repeated an exact prior candidate state.';
43/// ```
44pub const COMMIT_CYCLE_MESSAGE: &str = "Policy patches repeated an exact prior candidate state.";
45
46/// What:
47///  Everything one commit-path convergence reads and writes.
48/// Why:
49///   The request, the shipped checks and the private index travel together, so no caller can pair them wrongly.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// type ConvergenceInput = { request; checks; stage };
54/// ```
55pub struct CommitConvergenceInput<'a, F: RepositoryFacts> {
56    /// The pre-forward stage request the passes run under.
57    pub request: &'a StageRequest,
58    /// The shipped checks whose facts read the private index.
59    pub checks: &'a mut ShippedChecks<F>,
60    /// The private index and object store the corrections are written to.
61    pub stage: PrivateStage<'a>,
62}
63
64/// What:
65///  The settled or stopped result of one commit-path convergence.
66/// Why:
67///   The caller needs the outcome, the events that explain it, and the paths the loop corrected.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// type CommitConvergence = { kind: 'settled' | 'blocked'; pass; changedPaths };
72/// ```
73pub struct CommitConvergence {
74    /// How the bounded loop ended.
75    pub convergence: Convergence,
76    /// The pass whose events describe the outcome.
77    pub pass: PassResult,
78    /// Paths whose private-index entry a correction wrote, in first-write order.
79    pub changed_paths: Vec<Vec<u8>>,
80}
81
82/// What:
83///  The commit path's three loop operations over the private index.
84/// Why:
85///   The loop calls these in order; holding the state here keeps each pass's proposals with its own writes.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// class CommitPasses { runPass(); apply(); snapshotsEqual(); }
90/// ```
91struct CommitPasses<'a, F: RepositoryFacts> {
92    /// The pre-forward request.
93    request: &'a StageRequest,
94    /// The shipped checks.
95    checks: &'a mut ShippedChecks<F>,
96    /// The private index and object store.
97    stage: PrivateStage<'a>,
98    /// The most recent pass.
99    last: Option<PassResult>,
100    /// The pass that failed to write its corrections, when that is why the loop stopped.
101    blocked: Option<PassResult>,
102    /// Exact corrected state after each changed pass, starting from the uncorrected state.
103    states: Vec<BTreeMap<Vec<u8>, InstallChange>>,
104    /// Paths written so far, in first-write order.
105    changed_paths: Vec<Vec<u8>>,
106}
107
108impl<'a, F: RepositoryFacts> CommitPasses<'a, F> {
109    /// What:
110    ///  Start the loop with the uncorrected state stored as state zero.
111    /// Why:
112    ///   Snapshot zero is the state every later change is compared against.
113    ///
114    /// In TS you'd write (pseudocode):
115    /// ```ts
116    /// const visited = [firstSnapshot];
117    /// ```
118    fn new(input: CommitConvergenceInput<'a, F>) -> Self {
119        return CommitPasses {
120            request: input.request,
121            checks: input.checks,
122            stage: input.stage,
123            last: None,
124            blocked: None,
125            states: vec![BTreeMap::new()],
126            changed_paths: Vec::new(),
127        };
128    }
129}
130
131impl<F: RepositoryFacts> FixPasses for CommitPasses<'_, F> {
132    fn run_pass(&mut self, _candidate_version: usize) -> ConvergencePass {
133        // What: `let _ = ...` drops proposals no `apply` took.
134        // Why:  Only a pass that ends proposing is followed by `apply`.
135        //
136        // In TS you'd write (pseudocode):
137        // ```ts
138        // content.takeProposals();
139        // ```
140        let _ = self.checks.content.take_proposals();
141        let pass: PassResult = run_policy_pass(self.request, self.checks);
142        let result: ConvergencePass = convergence_pass(pass.end);
143        self.last = Some(pass);
144        return result;
145    }
146
147    fn apply(&mut self, _snapshot: usize) -> bool {
148        let proposals: Vec<Correction> = self.checks.content.take_proposals();
149        let arguments: Vec<OsString> = match &self.last {
150            Some(pass) => pass.arguments.clone(),
151            None => unreachable!("apply follows a pass that proposed corrections"),
152        };
153        for correction in &proposals {
154            if let Err(message) = stage_correction(&self.stage, correction) {
155                self.blocked = Some(patch_conflict_pass(arguments.as_slice(), correction.path.as_slice(), 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<Vec<u8>>, correction: &Correction) {
180    if !changed.contains(&correction.path) {
181        changed.push(correction.path.clone());
182    }
183}
184
185/// What:
186///  The pass that stops the commit path because a correction did not write to the private index.
187/// Why:
188///   The incumbent maps a failed patch application to `patch-conflict`, naming the path.
189///
190/// In TS you'd write (pseudocode):
191/// ```ts
192/// patchApplicationFailure({ path, message });
193/// ```
194fn patch_conflict_pass(arguments: &[OsString], path: &[u8], message: String) -> PassResult {
195    let shown: String = path_message_text(path);
196    return PassResult {
197        arguments: arguments.to_vec(),
198        events: vec![PolicyEvent::EngineFailure {
199            code: EngineFailureCode::PatchConflict,
200            message: format!("Policy patch for {shown} 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)),
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;