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;