Skip to main content

git_policy_cli/
policy_dependent_version.rs

1//! What:
2//!  The `mono/dependent-version-bump` check:
3//!  the native planner over the lifecycle's
4//!       prepared candidate state,
5//!  its findings,
6//!  and the corrections a direct fix applies.
7//! Why:
8//!  The installed wrapper's policy (`dependent-version-bump-policy.ts`) reports one
9//!      finding per publishable dependent of a raised manifest,
10//!  each with a full-content
11//!      patch,
12//!  on `direct-check` and `direct-fix`;
13//!  on `pre-forward` it plans only for a
14//!      forwarded `commit`.
15//!  A real `git commit` runs through the commit transaction instead of being forwarded,
16//!  so `pre-forward` reaches this check only
17//!      for `git add`,
18//!  where the policy finds nothing.
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! // const findings = await findDependentBumps(context); // patches applied by a direct fix
23//! ```
24
25/// The candidate's mode,
26///  which a correction keeps.
27use super::candidate_object::CandidateMode;
28/// The candidate's change kinds.
29use super::candidate_record::CandidateChange as RecordChange;
30/// The candidate and version types.
31use super::candidate_version::{Candidate as LayerCandidate, CandidateVersion};
32/// The planner's failures,
33///  modes and messages.
34use super::dependent_version_content::{
35    ContentUnavailable, PlanError, TrackedMode, display_path, plan_error_message,
36};
37/// The planner's view of the candidate state.
38use super::dependent_version_lifecycle::{LifecycleWorkspace, lifecycle_workspace};
39/// The planner's bump of one manifest.
40use super::dependent_version_plan::ManifestBump;
41/// The planner's request,
42///  decision and findings.
43use super::dependent_version_policy::{
44    Candidate, CandidateChange, DependentFinding, DependentRequest, find_dependent_bumps,
45    should_plan,
46};
47/// The engine's failure codes.
48/// The event pathname type the outcome and finding carry.
49use super::event_path::EventPath;
50use super::diagnostics::EngineFailureCode;
51/// The pathname conversion every worktree read uses.
52use super::git_metadata::path_from_git_bytes;
53/// The precondition for a file the fix did not select.
54use super::policy_added_path::{
55    ADDED_PATH_STAGED_REASON, added_path_message, head_mode, worktree_refusal,
56};
57/// The lifecycle's candidates and the correction a fix applies.
58use super::policy_content::{ContentState, Correction, LifecycleContent};
59/// The finding and outcome types of a check.
60use super::policy_engine::{PolicyFinding, PolicyOutcome};
61/// The lifecycle points.
62use super::policy_trigger::Trigger;
63/// The facts interface that prepares candidates and locates the worktree.
64use super::repository_facts::RepositoryFacts;
65/// The worktree's top level.
66use super::worktree_identity::worktree_root;
67/// Borrowed and owned filesystem paths.
68use std::path::{Path, PathBuf};
69/// `Rc<T>` is a shared,
70///  read-only handle.
71use std::rc::Rc;
72
73/// What:
74///  The outcome "nothing found".
75/// Why:
76///   Most lifecycles end this way.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// const clean = (): PolicyOutcome => ({ kind: 'findings', findings: [] });
81/// ```
82fn clean() -> PolicyOutcome {
83    return PolicyOutcome::Findings(Vec::new());
84}
85
86/// What:
87///  The outcome of a check that could not finish,
88///  with its code.
89/// Why:
90///   Unreadable content and a refused correction each have their own code.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// const failed = (code, message): PolicyOutcome => ({ kind: 'failed', code, message });
95/// ```
96fn failed(code: EngineFailureCode, message: String) -> PolicyOutcome {
97    return PolicyOutcome::Failed {
98        code,
99        message,
100        path: None,
101    };
102}
103
104/// What:
105///  The planner's candidate for a layer candidate.
106/// Why:
107///   The planner names only the path and the change.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// const plannerCandidate = (c) => ({ path: c.path, change: c.change });
112/// ```
113fn planner_candidate(candidate: &LayerCandidate) -> Candidate {
114    let change: CandidateChange = match candidate.change {
115        RecordChange::Added => CandidateChange::Added,
116        RecordChange::Modified => CandidateChange::Modified,
117        RecordChange::Deleted => CandidateChange::Deleted,
118    };
119    return Candidate {
120        path: candidate.path.clone(),
121        change,
122    };
123}
124
125/// What:
126///  The outcome for a planner failure,
127///  coded by its cause.
128/// Why:
129///   Content that could not be read is `content-unavailable`;
130///  content the planner
131///       cannot use is `policy-incomplete`.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// const planFailure = (error: PlanError): PolicyOutcome => ({ kind: 'failed', code: codeOf(error), message: planErrorMessage(error) });
136/// ```
137fn plan_failure(error: &PlanError) -> PolicyOutcome {
138    let code: EngineFailureCode = match error {
139        PlanError::ContentUnavailable(_) => EngineFailureCode::ContentUnavailable,
140        PlanError::PolicyIncomplete(_) => EngineFailureCode::PolicyIncomplete,
141    };
142    return failed(code, plan_error_message(error));
143}
144
145/// What:
146///  The engine's finding for a planner finding.
147///  A finding with a patch says a fix is
148///       available on every lifecycle,
149///  as the installed wrapper's stage says.
150/// Why:
151///   The person sees the dependent,
152///  the versions,
153///  and that `git cli-git fix` applies it.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// const policyFinding = (f) => ({ code: f.code, message: f.message, path: f.path, fix: f.patch ? 'available' : 'none' });
158/// ```
159fn policy_finding(finding: &DependentFinding) -> PolicyFinding {
160    return PolicyFinding {
161        code: finding.code,
162        message: finding.message.clone(),
163        path: finding.path.as_deref().map(EventPath::from_git_bytes),
164        location: None,
165        fix_available: finding.patch.is_some(),
166    };
167}
168
169/// What:
170///  The `patch-conflict` outcome for a file the fix did not select.
171/// Why:
172///   The message names the file,
173///  the reason,
174///  and both remedies.
175///
176/// In TS you'd write (pseudocode):
177/// ```ts
178/// const conflict = (path, reason): PolicyOutcome => ({ kind: 'failed', code: 'patch-conflict', message: directFixMessage({ path, reason }) });
179/// ```
180fn conflict(path: &str, reason: &str) -> PolicyOutcome {
181    return failed(
182        EngineFailureCode::PatchConflict,
183        added_path_message(path, reason),
184    );
185}
186
187/// What:
188///  The planner's failure for an unreadable tracked file,
189///  coded as `content-unavailable`.
190/// Why:
191///   The failure names the file the listing could not read,
192///  so the policy reports it instead of guessing.
193fn unreadable_failure(error: ContentUnavailable) -> PolicyOutcome {
194    return plan_failure(&PlanError::ContentUnavailable(error));
195}
196
197/// What:
198///  Whether a candidate changes its path,
199///  so the tracked listing must hold it.
200/// Why:
201///   A deleted path is absent from the index by definition and is not required there.
202fn is_listed_change(candidate: &&Candidate) -> bool {
203    return candidate.change != CandidateChange::Deleted;
204}
205
206/// What:
207///  The path of a candidate,
208///  copied for the tracked listing.
209/// Why:
210///   The listing keeps owned pathnames,
211///  so each candidate's path is copied once.
212fn candidate_path(candidate: &Candidate) -> Vec<u8> {
213    return candidate.path.clone();
214}
215
216/// What:
217///  The correction a direct fix applies for one bump,
218///  after checking a file the fix
219///       did not select.
220///  `Err` is the outcome that ends the check instead.
221/// Why:
222///   A selected file is corrected from its candidate bytes.
223///  An unselected one must hold
224///       `HEAD`'s blob in the index and the worktree;
225///  then its candidate bytes are `HEAD`'s,
226///       and the installer's check that the worktree still holds them repeats the
227///       precondition at install time.
228///
229/// In TS you'd write (pseudocode):
230/// ```ts
231/// async function correctionOf(bump, selected, workspace, root): Promise<Correction>; // throws PolicyOutcome
232/// ```
233fn correction_of(
234    bump: &ManifestBump,
235    selected: bool,
236    workspace: &mut LifecycleWorkspace<'_>,
237    root: Option<&Path>,
238) -> Result<Correction, PolicyOutcome> {
239    let mode: CandidateMode = if bump.mode == TrackedMode::Executable {
240        CandidateMode::Executable
241    } else {
242        CandidateMode::Regular
243    };
244    let correction: Correction = Correction {
245        path: bump.path.clone(),
246        mode,
247        before: Rc::from(bump.original.as_slice()),
248        after: Rc::from(bump.replacement.as_slice()),
249    };
250    let (false, Some(top_level)) = (selected, root) else {
251        return Ok(correction);
252    };
253    let shown: String = display_path(bump.path.as_slice());
254    let Some(index) = workspace.index_entry(&bump.path).map_err(unreadable_failure)? else {
255        return Err(conflict(shown.as_str(), ADDED_PATH_STAGED_REASON));
256    };
257    let head = workspace.head_entry(&bump.path).map_err(unreadable_failure)?;
258    let head_file_mode: CandidateMode = match head_mode(head.as_ref(), &index) {
259        Ok(found) => found,
260        Err(reason) => return Err(conflict(shown.as_str(), reason)),
261    };
262    let Some(relative) = path_from_git_bytes(&bump.path) else {
263        return Err(failed(
264            EngineFailureCode::ContentUnavailable,
265            format!(
266                "mono/dependent-version-bump cannot name the worktree copy of {shown} on this system."
267            ),
268        ));
269    };
270    let file: PathBuf = top_level.join(relative);
271    match worktree_refusal(file.as_path(), head_file_mode, bump.original.as_slice()) {
272        Ok(None) => return Ok(correction),
273        Ok(Some(reason)) => return Err(conflict(shown.as_str(), reason)),
274        Err(error) => {
275            return Err(failed(
276                EngineFailureCode::ContentUnavailable,
277                format!(
278                    "mono/dependent-version-bump could not read the worktree copy of {shown}: {error}."
279                ),
280            ));
281        }
282    }
283}
284
285/// What:
286///  Check the lifecycle's candidate state.
287///  `<F: RepositoryFacts>` accepts any facts
288///       provider;
289///  `&mut ContentState` lends the shared candidates and takes corrections.
290/// Why:
291///   See the module comment.
292///  Corrections are proposed only on `direct-fix`,
293///  the one
294///       lifecycle that applies them;
295///  every proposal of the pass is checked before any
296///       is recorded,
297///  so a refused one leaves the pass without partial corrections.
298///
299/// In TS you'd write (pseudocode):
300/// ```ts
301/// async function checkDependentVersion(content, lifecycle, facts, trigger): Promise<PolicyOutcome>;
302/// ```
303pub fn check_dependent_version<F: RepositoryFacts>(
304    content: &mut ContentState,
305    lifecycle: &LifecycleContent,
306    facts: &mut F,
307    trigger: Trigger,
308) -> PolicyOutcome {
309    if trigger == Trigger::PreForward {
310        return clean();
311    }
312    let version: Rc<CandidateVersion> = match content.version(lifecycle, facts) {
313        Ok(Some(found)) => found,
314        Ok(None) => return clean(),
315        Err(outcome) => return outcome,
316    };
317    let candidates: Vec<Candidate> = version.candidates().iter().map(planner_candidate).collect();
318    let request: DependentRequest<'_> = DependentRequest {
319        trigger,
320        forwards_commit: false,
321        candidates: candidates.as_slice(),
322    };
323    if !should_plan(&request) {
324        return clean();
325    }
326    let root: Option<PathBuf> = if trigger == Trigger::DirectFix {
327        match facts.location() {
328            Ok(location) => worktree_root(&location.identity).map(Path::to_path_buf),
329            Err(message) => return failed(EngineFailureCode::ContentUnavailable, message),
330        }
331    } else {
332        None
333    };
334    let listed: Vec<Vec<u8>> = candidates
335        .iter()
336        .filter(is_listed_change)
337        .map(candidate_path)
338        .collect();
339    let Some((store, corrected)) = content.store_with_corrections() else {
340        return failed(
341            EngineFailureCode::ContentUnavailable,
342            String::from(
343                "mono/dependent-version-bump found no prepared candidates; this is a defect in cli-git.",
344            ),
345        );
346    };
347    let mut workspace: LifecycleWorkspace<'_> =
348        lifecycle_workspace(store, corrected, listed.as_slice());
349    let findings: Vec<DependentFinding> = match find_dependent_bumps(&request, &mut workspace) {
350        Ok(found) => found,
351        Err(error) => return plan_failure(&error),
352    };
353    let mut proposals: Vec<Correction> = Vec::new();
354    for finding in &findings {
355        if let (Trigger::DirectFix, Some(bump)) = (trigger, finding.patch.as_ref()) {
356            let selected: bool = version.candidate_at_path(&bump.path).is_some();
357            match correction_of(bump, selected, &mut workspace, root.as_deref()) {
358                Ok(correction) => proposals.push(correction),
359                Err(outcome) => return outcome,
360            }
361        }
362    }
363    for correction in proposals {
364        content.propose(correction);
365    }
366    return PolicyOutcome::Findings(findings.iter().map(policy_finding).collect());
367}
368
369/// Check controls over fixture repositories stay out of the release executable.
370#[cfg(test)]
371#[path = "policy_dependent_version_tests.rs"]
372mod tests;