Skip to main content

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