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;