Skip to main content

git_policy_cli/push/
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 crate::auto_push_single_flight::PushGit;
15use crate::candidate_object::parse_object_id;
16use crate::candidate_prediction::PushedScope;
17use crate::manual_push_cause::ManualPushCause;
18use crate::manual_push_probe::{GitRefusal, MOST_CONCURRENT_LOCAL_QUERIES, PushUpdate, QUERY_MEMORY, probe_manual_push_updates};
19use crate::parallel_budget::{BudgetRequest, HostResources, worker_budget};
20use crate::parallel_runner::{Event, OrderedRun, run_ordered};
21use crate::manual_push_range::{
22    ManualPushRangeError, UpdateScope, peel_commits, published_tips, tracking_commits, update_scope,
23};
24/// Maps keyed by remote name and by object name.
25use std::collections::BTreeMap;
26/// Owned OS strings for the replayed global options and the post-subcommand arguments.
27use std::ffi::OsString;
28
29/// What:
30///  Why a push's scope could not be established.
31/// Why:
32///   The lifecycle reports every such failure as a content-unavailable engine failure and stops the push.
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// class ManualPushProbeError extends Error {}
37/// ```
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct ManualPushFailure {
40    /// Diagnostic naming the failed step.
41    pub message: String,
42    /// Git's refusal when Git itself ended the dry run: the push then ends as Git ends it.
43    pub refusal: Option<GitRefusal>,
44    /// What the person can do about it, which the engine failure's hint is chosen by.
45    pub cause: ManualPushCause,
46}
47
48impl ManualPushFailure {
49    /// A failure of the wrapper's own step, which is no refusal by Git.
50    pub fn step(cause: ManualPushCause, message: String) -> Self {
51        return Self { message, refusal: None, cause };
52    }
53}
54
55impl std::fmt::Display for ManualPushFailure {
56    /// Writes the diagnostic text.
57    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        return formatter.write_str(&self.message);
59    }
60}
61
62impl std::error::Error for ManualPushFailure {}
63
64/// Converts a probe failure into the lifecycle failure.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// caughtValueText(error)
69/// ```
70fn from_probe(error: &crate::manual_push_probe::ManualPushProbeError) -> ManualPushFailure {
71    return ManualPushFailure {
72        message: error.message.clone(),
73        refusal: error.refusal.clone(),
74        cause: error.cause,
75    };
76}
77
78/// Converts a range failure into the lifecycle failure.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// caughtValueText(error)
83/// ```
84fn from_range(error: &ManualPushRangeError) -> ManualPushFailure {
85    return ManualPushFailure::step(error.cause, error.message.clone());
86}
87
88/// What:
89///  The scope of every update of one push, in the order Git negotiated the updates.
90/// Why:
91///   Each update is bounded by the published tips of its own destination remote only.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// async function manualPushScopes({ gitPath, cwd, args }): Promise<UpdateScope[]>;
96/// ```
97pub fn scopes_of_updates(
98    git: &PushGit,
99    updates: &[PushUpdate],
100) -> Result<Vec<UpdateScope>, ManualPushFailure> {
101    return scopes_of_updates_on(&HostResources::read(), git, updates);
102}
103
104/// `scopes_of_updates` with the host's resources chosen by the caller, so a control states a host of any size.
105pub fn scopes_of_updates_on(
106    host: &HostResources,
107    git: &PushGit,
108    updates: &[PushUpdate],
109) -> Result<Vec<UpdateScope>, ManualPushFailure> {
110    let remote_names: Vec<String> = updates.iter().fold(Vec::new(), |mut names, update| {
111        if !names.contains(&update.remote_name) {
112            names.push(update.remote_name.clone());
113        }
114        return names;
115    });
116    let tracking: BTreeMap<String, Vec<String>> =
117        tracking_commits(git, &remote_names).map_err(|error| {
118            return from_range(&error);
119        })?;
120    let prior_oids: Vec<String> = updates
121        .iter()
122        .filter_map(|update| {
123            return update.remote_oid.clone();
124        })
125        .collect();
126    let prior: BTreeMap<String, String> = peel_commits(git, &prior_oids).map_err(|error| {
127        return from_range(&error);
128    })?;
129    let tips: BTreeMap<String, Vec<String>> = published_tips(updates, &prior, &tracking);
130    // Each update is bounded by its own destination's tips and read by its own `cat-file` and `rev-list`, so the updates are
131    //  independent reads and run side by side, at most `MOST_CONCURRENT_LOCAL_QUERIES` at once; the scopes come back in the order Git
132    //  negotiated the updates, and the first update that fails decides the error.
133    let lanes: Vec<Vec<usize>> = (0..updates.len()).map(|index: usize| { return vec![index]; }).collect();
134    let workers: usize = worker_budget(&BudgetRequest { items: updates.len(), per_item_memory: QUERY_MEMORY, ceiling: Some(MOST_CONCURRENT_LOCAL_QUERIES), cap: None, host });
135    let mut scopes: Vec<UpdateScope> = Vec::new();
136    let mut failure: Option<ManualPushRangeError> = None;
137    run_ordered(OrderedRun {
138        items: updates,
139        lanes: lanes.as_slice(),
140        workers,
141        work: &|_index: usize, update: &PushUpdate, _say: &mut dyn FnMut(&str)| {
142            let published: Vec<String> = tips.get(&update.remote_name).cloned().unwrap_or_default();
143            return update_scope(git, update, &published);
144        },
145        on_event: &mut |event: Event<'_, Result<UpdateScope, ManualPushRangeError>>| {
146            if let Event::End(_index, outcome) = event {
147                match outcome {
148                    Ok(scope) if failure.is_none() => scopes.push(scope),
149                    Err(error) if failure.is_none() => failure = Some(error),
150                    Ok(_) | Err(_) => {}
151                }
152            }
153        },
154    });
155    return match failure {
156        Some(error) => Err(from_range(&error)),
157        None => Ok(scopes),
158    };
159}
160
161/// What:
162///  Collect the commits and trees the candidate layer must list for a set of scopes.
163/// Why:
164///   Each commit is listed once even when several updates publish it, so the scan never repeats a delta.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// function candidateTargets(scopes: UpdateScope[]): { commits: string[]; trees: string[] };
169/// ```
170pub fn pushed_scope(scopes: &[UpdateScope]) -> Result<PushedScope, ManualPushFailure> {
171    let mut commits: Vec<crate::candidate_object::ObjectId> = Vec::new();
172    let mut trees: Vec<crate::candidate_object::ObjectId> = Vec::new();
173    for scope in scopes {
174        match scope {
175            UpdateScope::None => {}
176            UpdateScope::Commits(names) => {
177                for name in names {
178                    let oid = parse_object_id(name.as_bytes()).ok_or_else(|| {
179                        return ManualPushFailure::step(ManualPushCause::UnexpectedOutput, format!(
180                            "Git named a commit that is not an object name: {name}"
181                        ));
182                    })?;
183                    if !commits.contains(&oid) {
184                        commits.push(oid);
185                    }
186                }
187            }
188            UpdateScope::FinalTree(tip) => {
189                let oid = parse_object_id(tip.as_bytes()).ok_or_else(|| {
190                    return ManualPushFailure::step(ManualPushCause::UnexpectedOutput, format!("Git named a commit that is not an object name: {tip}"));
191                })?;
192                if !trees.contains(&oid) {
193                    trees.push(oid);
194                }
195            }
196        }
197    }
198    return Ok(PushedScope { commits, trees });
199}
200
201/// What:
202///  Discover the updates of one push and the candidate scope they publish.
203/// Why:
204///   The whole chain runs before any policy reads content, so a failure in any step refuses the push
205///  with its diagnostic and no partial scan.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// const updates = await probeManualPushUpdates({ gitPath, cwd, args });
210/// const scopes = await manualPushScopes(updates);
211/// ```
212pub fn manual_push_scope(
213    git: &PushGit,
214    global_prefix: &[OsString],
215    post_subcommand: &[OsString],
216) -> Result<PushedScope, ManualPushFailure> {
217    let updates =
218        probe_manual_push_updates(git, global_prefix, post_subcommand).map_err(|error| {
219            return from_probe(&error);
220        })?;
221    let scopes = scopes_of_updates(git, &updates)?;
222    return pushed_scope(&scopes);
223}
224
225/// The lifecycle's scope is controlled in the test module.
226#[cfg(test)]
227#[path = "manual_push_lifecycle_tests.rs"]
228mod tests;
229
230/// The controls for a push's destinations and updates read side by side stay out of the release executable.
231#[cfg(test)]
232#[path = "manual_push_parallel_tests.rs"]
233mod parallel_tests;