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