Skip to main content

git_policy_cli/push/
manual_push_range.rs

1//! What:
2//!  The commits a push newly publishes, relative to what each destination remote already has.
3//! Why:
4//!  The manual-push policies scan only what becomes public. A pushed commit is already published when a
5//!  remote-tracking ref of the destination, or an authoritative prior value of an update to that remote,
6//!  reaches it (`src/policy-engine/manual-push-published.ts`). The rest of the history is the scan's range.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! const published = publishedTips({ updates, peeled, tracking });
11//! ```
12
13/// The Git runner, the probe's update type, and the object name helpers.
14use crate::auto_push_single_flight::{PushGit, git_command};
15use crate::manual_push_cause::ManualPushCause;
16use crate::manual_push_probe::PushUpdate;
17/// Starts that ride out a short shortage of processes.
18use crate::start_retry::RetryStart;
19/// Maps from remote names and peeled object names.
20use std::collections::BTreeMap;
21/// Owned OS strings for Git arguments and the stdin of batch queries.
22use std::ffi::OsString;
23/// Pipes for the batch queries' stdin and stdout.
24use std::io::Write;
25use std::process::Stdio;
26
27/// Remote-tracking ref namespace Git's default fetch refspec writes.
28const TRACKING_NAMESPACE: &str = "refs/remotes/";
29
30/// What:
31///  Why the range could not be computed.
32/// Why:
33///   Every variant stops the lifecycle with a failure event; a guessed range would hide commits from the scan.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// class ManualPushProbeError extends Error {}
38/// ```
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct ManualPushRangeError {
41    /// Diagnostic naming the failed query.
42    pub message: String,
43    /// What the person can do about it, which the hint is chosen by.
44    pub cause: ManualPushCause,
45}
46
47impl std::fmt::Display for ManualPushRangeError {
48    /// Writes the diagnostic text.
49    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        return formatter.write_str(&self.message);
51    }
52}
53
54impl std::error::Error for ManualPushRangeError {}
55
56/// Build a range error from text.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// new ManualPushProbeError(message)
61/// ```
62fn range_error(cause: ManualPushCause, message: String) -> ManualPushRangeError {
63    return ManualPushRangeError { message, cause };
64}
65
66/// What:
67///  Run a Git query with stdin text and return its exact stdout bytes.
68/// Why:
69///   Batch queries (`cat-file --batch-check`, `rev-list --stdin`) read their requests from stdin,
70///  and any Git failure is an error, never an empty answer.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// await runGitBytes({ gitPath, cwd, args, input });
75/// ```
76pub fn run_git_bytes(
77    git: &PushGit,
78    arguments: &[OsString],
79    input: Option<&str>,
80) -> Result<Vec<u8>, ManualPushRangeError> {
81    let mut command = git_command(git, arguments);
82    command
83        .stdin(if input.is_some() {
84            Stdio::piped()
85        } else {
86            Stdio::null()
87        })
88        .stdout(Stdio::piped())
89        .stderr(Stdio::piped());
90    let mut child = command.spawn_retrying().map_err(|error| {
91        return range_error(ManualPushCause::GitNotStarted, format!("git could not be started: {error}"));
92    })?;
93    if let (Some(text), Some(mut stdin)) = (input, child.stdin.take()) {
94        // A Git exit before reading the whole input surfaces through its status, so the write error is ignored here.
95        let _written = stdin.write_all(text.as_bytes());
96    }
97    let output = child.wait_with_output().map_err(|error| {
98        return range_error(ManualPushCause::GitSilent, format!("git did not finish: {error}"));
99    })?;
100    if !output.status.success() {
101        return Err(range_error(ManualPushCause::ContentMissing, format!(
102            "git {} failed: {}",
103            arguments
104                .iter()
105                .map(|argument| {
106                    return argument.to_string_lossy().into_owned();
107                })
108                .collect::<Vec<String>>()
109                .join(" "),
110            String::from_utf8_lossy(&output.stderr).trim_end()
111        )));
112    }
113    return Ok(output.stdout);
114}
115
116/// Split Git line output into non-empty lines.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// text.split('\n').filter((line) => line.length > 0)
121/// ```
122fn lines_of(bytes: &[u8]) -> Vec<String> {
123    return String::from_utf8_lossy(bytes)
124        .split('\n')
125        .filter(|line| {
126            return !line.is_empty();
127        })
128        .map(str::to_owned)
129        .collect();
130}
131
132/// What:
133///  The commit a prior destination value names, when it exists locally and is a commit.
134///  `Option<T>` is "a value or nothing".
135/// Why:
136///   Only locally present commits can bound the range; an object Git never fetched is unknown, not published.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// peeled.get(oid)?.type === 'commit' ? [oid] : []
141/// ```
142pub fn peel_commits(
143    git: &PushGit,
144    oids: &[String],
145) -> Result<BTreeMap<String, String>, ManualPushRangeError> {
146    let mut unique: Vec<String> = oids.to_vec();
147    unique.sort();
148    unique.dedup();
149    if unique.is_empty() {
150        return Ok(BTreeMap::new());
151    }
152    let input: String = unique
153        .iter()
154        .map(|oid| {
155            return format!("{oid}^{{}}\n");
156        })
157        .collect();
158    let arguments: Vec<OsString> = ["cat-file", "--batch-check=%(objectname) %(objecttype)"]
159        .iter()
160        .map(OsString::from)
161        .collect();
162    let output = run_git_bytes(git, &arguments, Some(&input))?;
163    let records = lines_of(&output);
164    if records.len() != unique.len() {
165        return Err(range_error(
166            ManualPushCause::UnexpectedOutput,
167            "Git object batch-check returned an unexpected record count.".to_owned(),
168        ));
169    }
170    let mut commits: BTreeMap<String, String> = BTreeMap::new();
171    for (oid, record) in unique.iter().zip(records.iter()) {
172        if record == &format!("{oid}^{{}} missing") {
173            continue;
174        }
175        let fields: Vec<&str> = record.split(' ').collect();
176        if fields.len() != 2 {
177            return Err(range_error(ManualPushCause::UnexpectedOutput, format!(
178                "Git object batch-check returned malformed record: {record}"
179            )));
180        }
181        if fields[1] == "commit" {
182            commits.insert(oid.clone(), fields[0].to_owned());
183        }
184    }
185    return Ok(commits);
186}
187
188/// What:
189///  The commits each destination remote is known to have, from its remote-tracking refs.
190/// Why:
191///   The namespace is matched literally, so a remote name is never read as a pattern.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// async function trackingCommits({ gitPath, cwd, remoteNames }): Promise<ReadonlyMap<string, string[]>>;
196/// ```
197pub fn tracking_commits(
198    git: &PushGit,
199    remote_names: &[String],
200) -> Result<BTreeMap<String, Vec<String>>, ManualPushRangeError> {
201    if remote_names.is_empty() {
202        return Ok(BTreeMap::new());
203    }
204    let arguments: Vec<OsString> = [
205        "for-each-ref",
206        "--format=%(objectname) %(objecttype) %(refname)",
207        TRACKING_NAMESPACE,
208    ]
209    .iter()
210    .map(OsString::from)
211    .collect();
212    let refs = lines_of(&run_git_bytes(git, &arguments, None)?);
213    let mut by_remote: BTreeMap<String, Vec<String>> = BTreeMap::new();
214    for remote_name in remote_names {
215        let prefix = format!("{TRACKING_NAMESPACE}{remote_name}/");
216        let mut commits: Vec<String> = Vec::new();
217        for record in &refs {
218            let fields: Vec<&str> = record.split(' ').collect();
219            if fields.len() != 3 {
220                return Err(range_error(ManualPushCause::UnexpectedOutput, format!(
221                    "Git for-each-ref returned malformed record: {record}"
222                )));
223            }
224            if fields[1] == "commit" && fields[2].starts_with(&prefix) {
225                commits.push(fields[0].to_owned());
226            }
227        }
228        by_remote.insert(remote_name.clone(), commits);
229    }
230    return Ok(by_remote);
231}
232
233/// What:
234///  The published tips of each remote: its tracking commits and the commits of its prior destination values.
235/// Why:
236///   Every update to one remote contributes its prior value, so a second branch is bounded by the first's prior state.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// function publishedTips({ updates, peeled, tracking }): ReadonlyMap<string, readonly GitObjectId[]>;
241/// ```
242pub fn published_tips(
243    updates: &[PushUpdate],
244    prior_commits: &BTreeMap<String, String>,
245    tracking: &BTreeMap<String, Vec<String>>,
246) -> BTreeMap<String, Vec<String>> {
247    let mut tips: BTreeMap<String, Vec<String>> = BTreeMap::new();
248    for update in updates {
249        let entry = tips.entry(update.remote_name.clone()).or_insert_with(|| {
250            return tracking
251                .get(&update.remote_name)
252                .cloned()
253                .unwrap_or_default();
254        });
255        if let Some(prior) = update.remote_oid.as_ref()
256            && let Some(commit) = prior_commits.get(prior)
257            && !entry.contains(commit)
258        {
259            entry.push(commit.clone());
260        }
261    }
262    return tips;
263}
264
265/// What:
266///  The scope one pushed update contributes to the scan.
267/// Why:
268///   A deletion publishes nothing; a commit with a known published base contributes its new commits;
269///  a commit with no known base contributes its whole history's tree.
270///
271/// In TS you'd write (pseudocode):
272/// ```ts
273/// type UpdateScope = { kind: 'none' } | { kind: 'commits'; commits: string[] } | { kind: 'final-tree'; tip: string };
274/// ```
275#[derive(Clone, Debug, Eq, PartialEq)]
276pub enum UpdateScope {
277    /// Nothing new is published: a deletion or a commit the remote already has.
278    None,
279    /// The commits not reachable from anything the remote has.
280    Commits(Vec<String>),
281    /// No published base is known: the pushed tip's whole tree is new.
282    FinalTree(String),
283}
284
285/// What:
286///  The scope of one update, from its local object and the published tips of its remote.
287/// Why:
288///   `rev-list` names exactly the commits the remote lacks, so a scan never repeats published history.
289///
290/// In TS you'd write (pseudocode):
291/// ```ts
292/// async function scopeOf(update, publishedTips): Promise<UpdateScope>;
293/// ```
294pub fn update_scope(
295    git: &PushGit,
296    update: &PushUpdate,
297    published: &[String],
298) -> Result<UpdateScope, ManualPushRangeError> {
299    let Some(local) = update.local_oid.as_ref() else {
300        return Ok(UpdateScope::None);
301    };
302    let commit_check = peel_commits(git, std::slice::from_ref(local))?;
303    if !commit_check.contains_key(local) {
304        return Err(range_error(ManualPushCause::UnsupportedObject, format!(
305            "pushed object {local} is not a commit, and the manual-push policies read commits only"
306        )));
307    }
308    if published.is_empty() {
309        return Ok(UpdateScope::FinalTree(local.clone()));
310    }
311    let mut arguments: Vec<OsString> = vec![
312        OsString::from("rev-list"),
313        OsString::from(local),
314        OsString::from("--not"),
315    ];
316    arguments.extend(published.iter().map(OsString::from));
317    let commits = lines_of(&run_git_bytes(git, &arguments, None)?);
318    return Ok(UpdateScope::Commits(commits));
319}
320
321/// The range functions are controlled in the test module.
322#[cfg(test)]
323#[path = "manual_push_range_tests.rs"]
324mod tests;