Skip to main content

git_policy_cli/
manual_push_lifecycle.rs

1//! What:
2//!  The manual-push candidate scope of one `git push`: the commits and trees the destination lacks.
3//! Why:
4//!  The manual-push policies (`final-newline`, `forbidden-strings`) scan the content a push publishes.
5//!  The incumbent (`src/policy-engine/manual-push-lifecycle.ts`) discovers the updates, bounds each by what its
6//!  destination already has, and hands the scan to the policy engine; a failure stops the push with exit `2`.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! const updates = await probeManualPushUpdates({ gitPath, cwd, args });
11//! ```
12
13/// The probe, the published set and the range, and the candidate request they feed.
14use super::auto_push_single_flight::PushGit;
15use super::candidate_object::parse_object_id;
16use super::candidate_prediction::PushedScope;
17use super::manual_push_probe::{PushUpdate, probe_manual_push_updates};
18use super::manual_push_range::{
19    ManualPushRangeError, UpdateScope, peel_commits, published_tips, tracking_commits, update_scope,
20};
21/// Maps keyed by remote name and by object name.
22use std::collections::BTreeMap;
23/// Owned OS strings for the replayed global options and the post-subcommand arguments.
24use std::ffi::OsString;
25
26/// What:
27///  Why a push's scope could not be established.
28/// Why:
29///   The lifecycle reports every such failure as a content-unavailable engine failure and stops the push.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// class ManualPushProbeError extends Error {}
34/// ```
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct ManualPushFailure {
37    /// Diagnostic naming the failed step.
38    pub message: String,
39}
40
41impl std::fmt::Display for ManualPushFailure {
42    /// Writes the diagnostic text.
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        return formatter.write_str(&self.message);
45    }
46}
47
48impl std::error::Error for ManualPushFailure {}
49
50/// Converts a probe failure into the lifecycle failure.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// caughtValueText(error)
55/// ```
56fn from_probe(error: &super::manual_push_probe::ManualPushProbeError) -> ManualPushFailure {
57    return ManualPushFailure {
58        message: error.message.clone(),
59    };
60}
61
62/// Converts a range failure into the lifecycle failure.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// caughtValueText(error)
67/// ```
68fn from_range(error: &ManualPushRangeError) -> ManualPushFailure {
69    return ManualPushFailure {
70        message: error.message.clone(),
71    };
72}
73
74/// What:
75///  The scope of every update of one push, in the order Git negotiated the updates.
76/// Why:
77///   Each update is bounded by the published tips of its own destination remote only.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// async function manualPushScopes({ gitPath, cwd, args }): Promise<UpdateScope[]>;
82/// ```
83pub fn scopes_of_updates(
84    git: &PushGit,
85    updates: &[PushUpdate],
86) -> Result<Vec<UpdateScope>, ManualPushFailure> {
87    let remote_names: Vec<String> = updates.iter().fold(Vec::new(), |mut names, update| {
88        if !names.contains(&update.remote_name) {
89            names.push(update.remote_name.clone());
90        }
91        return names;
92    });
93    let tracking: BTreeMap<String, Vec<String>> =
94        tracking_commits(git, &remote_names).map_err(|error| {
95            return from_range(&error);
96        })?;
97    let prior_oids: Vec<String> = updates
98        .iter()
99        .filter_map(|update| {
100            return update.remote_oid.clone();
101        })
102        .collect();
103    let prior: BTreeMap<String, String> = peel_commits(git, &prior_oids).map_err(|error| {
104        return from_range(&error);
105    })?;
106    let tips: BTreeMap<String, Vec<String>> = published_tips(updates, &prior, &tracking);
107    let mut scopes: Vec<UpdateScope> = Vec::new();
108    for update in updates {
109        let published: Vec<String> = tips.get(&update.remote_name).cloned().unwrap_or_default();
110        scopes.push(update_scope(git, update, &published).map_err(|error| {
111            return from_range(&error);
112        })?);
113    }
114    return Ok(scopes);
115}
116
117/// What:
118///  Collect the commits and trees the candidate layer must list for a set of scopes.
119/// Why:
120///   Each commit is listed once even when several updates publish it, so the scan never repeats a delta.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// function candidateTargets(scopes: UpdateScope[]): { commits: string[]; trees: string[] };
125/// ```
126pub fn pushed_scope(scopes: &[UpdateScope]) -> Result<PushedScope, ManualPushFailure> {
127    let mut commits: Vec<super::candidate_object::ObjectId> = Vec::new();
128    let mut trees: Vec<super::candidate_object::ObjectId> = Vec::new();
129    for scope in scopes {
130        match scope {
131            UpdateScope::None => {}
132            UpdateScope::Commits(names) => {
133                for name in names {
134                    let oid = parse_object_id(name.as_bytes()).ok_or_else(|| {
135                        return ManualPushFailure {
136                            message: format!(
137                                "Git named a commit that is not an object name: {name}"
138                            ),
139                        };
140                    })?;
141                    if !commits.contains(&oid) {
142                        commits.push(oid);
143                    }
144                }
145            }
146            UpdateScope::FinalTree(tip) => {
147                let oid = parse_object_id(tip.as_bytes()).ok_or_else(|| {
148                    return ManualPushFailure {
149                        message: format!("Git named a commit that is not an object name: {tip}"),
150                    };
151                })?;
152                if !trees.contains(&oid) {
153                    trees.push(oid);
154                }
155            }
156        }
157    }
158    return Ok(PushedScope { commits, trees });
159}
160
161/// What:
162///  Discover the updates of one push and the candidate scope they publish.
163/// Why:
164///   The whole chain runs before any policy reads content, so a failure in any step refuses the push
165///  with its diagnostic and no partial scan.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// const updates = await probeManualPushUpdates({ gitPath, cwd, args });
170/// const scopes = await manualPushScopes(updates);
171/// ```
172pub fn manual_push_scope(
173    git: &PushGit,
174    global_prefix: &[OsString],
175    post_subcommand: &[OsString],
176) -> Result<PushedScope, ManualPushFailure> {
177    let updates =
178        probe_manual_push_updates(git, global_prefix, post_subcommand).map_err(|error| {
179            return from_probe(&error);
180        })?;
181    let scopes = scopes_of_updates(git, &updates)?;
182    return pushed_scope(&scopes);
183}
184
185/// The lifecycle's scope is controlled in the test module.
186#[cfg(test)]
187#[path = "manual_push_lifecycle_tests.rs"]
188mod tests;