Skip to main content

git_policy_cli/
manual_push_probe.rs

1//! What:
2//!  The authoritative ref updates a `git push` would send, discovered by a dry-run push whose report Git writes itself.
3//! Why:
4//!  The manual-push policies must scan exactly what Git would publish, so the probe asks Git:
5//!  `git push --dry-run --porcelain` reports each destination and ref update (`manual_push_porcelain`), the local
6//!  objects come from that report or from one `rev-parse`, and `git ls-remote --refs` confirms each destination value.
7//!  A remote ref that changed between negotiation and confirmation stops the probe.
8//!  No program of ours is started for this, and no hook or shell text is written: an earlier version recorded Git's
9//!  update lines with a `#!/bin/sh` hook (`doc/planning/cli-git-no-self-invocation.md`).
10//!  Every Git child here is bounded by the query deadline, so a remote that never answers cannot hold the push.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const updates = await probeManualPushUpdates({ gitPath, cwd, args });
15//! ```
16
17/// The probe's Git runner, its bounded queries and its environment.
18use super::auto_push_single_flight::{PushGit, run_query};
19/// The destination Git would pick for a bare push, and the porcelain report's parser.
20use super::lfs_upload_plan::resolve_push_remote;
21use super::manual_push_porcelain::{PorcelainUpdate, RemoteBefore, parse_porcelain};
22/// The push's own reading of its destination.
23use super::command_push::push_shape;
24/// Owned OS strings for the probe's Git arguments.
25use std::ffi::OsString;
26
27/// What:
28///  Why the probe could not establish the updates Git would push.
29/// Why:
30///   Every variant stops the policy lifecycle with a content-unavailable failure; none guesses an update.
31///
32/// In TS you'd write (pseudocode):
33/// ```ts
34/// class ManualPushProbeError extends Error {}
35/// ```
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct ManualPushProbeError {
38    /// Diagnostic naming what could not be established.
39    pub message: String,
40}
41
42impl std::fmt::Display for ManualPushProbeError {
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 ManualPushProbeError {}
50
51/// Build a probe error from text.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// new ManualPushProbeError(message)
56/// ```
57fn probe_error(message: String) -> ManualPushProbeError {
58    return ManualPushProbeError { message };
59}
60
61/// What:
62///  One confirmed push update, the public fact the policies read.
63/// Why:
64///   An absent value is `None`, which replaces the incumbent's `ABSENT_GIT_VALUE` sentinel.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// type PushUpdate = { localOid: string | typeof ABSENT_GIT_VALUE; remoteOid: string | typeof ABSENT_GIT_VALUE; remoteName; remoteRef };
69/// ```
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct PushUpdate {
72    /// Local object name; `None` for a deletion.
73    pub local_oid: Option<String>,
74    /// Destination object name confirmed by `ls-remote`; `None` when the ref is absent.
75    pub remote_oid: Option<String>,
76    /// Destination remote name.
77    pub remote_name: String,
78    /// Destination ref.
79    pub remote_ref: String,
80}
81
82/// What:
83///  The probe's Git arguments: the invocation's global options, the switches that keep every hook and full object
84///  names, then `push` with `--dry-run --porcelain` placed before any `--` terminator.
85/// Why:
86///   The `--` terminator is the user's; the options Git interprets must stay before it. The probe publishes nothing and
87///  must not run the upload hook of the caller's configuration, so configured `pre-push` hooks are off and the
88///  traditional hooks directory is one that never exists.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// probeArgs({ globalPrefix, args });
93/// ```
94pub fn probe_arguments(global_prefix: &[OsString], post_subcommand: &[OsString]) -> Vec<OsString> {
95    let separator = post_subcommand.iter().position(|argument| {
96        return argument.as_os_str() == "--";
97    });
98    let insertion = separator.unwrap_or(post_subcommand.len());
99    let mut arguments: Vec<OsString> = global_prefix.to_vec();
100    for setting in [
101        format!("core.hooksPath={}", super::hooks_off::NO_HOOKS_DIRECTORY),
102        String::from("hook.pre-push.enabled=false"),
103        String::from("core.abbrev=no"),
104    ] {
105        arguments.push(OsString::from("-c"));
106        arguments.push(OsString::from(setting));
107    }
108    arguments.push(OsString::from("push"));
109    arguments.extend(post_subcommand[..insertion].iter().cloned());
110    // A `-q` written by the caller silences the report this probe reads, so it is cancelled here; the caller's own
111    //  push keeps its `-q`.
112    arguments.push(OsString::from("--no-quiet"));
113    arguments.push(OsString::from("--dry-run"));
114    arguments.push(OsString::from("--porcelain"));
115    arguments.extend(post_subcommand[insertion..].iter().cloned());
116    return arguments;
117}
118
119/// Whether text typed as the repository is an URL, an scp-style address or a path, not a remote name.
120fn looks_like_location(typed: &str) -> bool {
121    return typed.contains("://")
122        || typed.starts_with('/')
123        || typed.starts_with("./")
124        || typed.starts_with("../")
125        || typed.split_once(':').is_some_and(|(front, _)| {
126            return !front.contains('/');
127        });
128}
129
130/// One Git query of the probe: its trimmed standard output when it exits with status zero.
131fn answer(git: &PushGit, arguments: &[OsString]) -> Option<String> {
132    let output = run_query(git, arguments).ok()?;
133    if output.code != Some(0) {
134        return None;
135    }
136    return Some(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_owned());
137}
138
139/// The destination locations of a remote name, one per `To` block Git printed: the push URLs of a configured remote,
140///  or the name itself when it is not one (a URL or a path typed in place of a remote).
141fn locations_of(git: &PushGit, global_prefix: &[OsString], remote: &str) -> Vec<String> {
142    let mut arguments: Vec<OsString> = global_prefix.to_vec();
143    arguments.extend(["remote", "get-url", "--push", "--all", remote].map(OsString::from));
144    return match answer(git, arguments.as_slice()) {
145        Some(listing) if !listing.is_empty() => listing.lines().map(str::to_owned).collect(),
146        Some(_) | None => vec![remote.to_owned()],
147    };
148}
149
150/// What:
151///  The object names of local refs, in order, from one `rev-parse`.
152/// Why:
153///   A new ref's report line has no object name; the ref's own value is what the push sends.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// (await git(['rev-parse', ...names])).split('\n')
158/// ```
159fn local_objects(git: &PushGit, global_prefix: &[OsString], names: &[&str]) -> Result<Vec<String>, ManualPushProbeError> {
160    if names.is_empty() {
161        return Ok(Vec::new());
162    }
163    let mut arguments: Vec<OsString> = global_prefix.to_vec();
164    arguments.push(OsString::from("rev-parse"));
165    arguments.extend(names.iter().map(OsString::from));
166    let listing: String = answer(git, arguments.as_slice())
167        .ok_or_else(|| {
168            return probe_error(format!("the local objects of {names:?} could not be read"));
169        })?;
170    let objects: Vec<String> = listing.lines().map(str::to_owned).collect();
171    if objects.len() != names.len() {
172        return Err(probe_error(format!("rev-parse answered {} names for {}", objects.len(), names.len())));
173    }
174    return Ok(objects);
175}
176
177/// What:
178///  Confirm one destination's values with `git ls-remote --refs` and turn each reported update into a public update.
179/// Why:
180///   A destination that changed after the dry run reported would publish something the scan did not see, so a
181///  mismatch between the report and the confirmation is an error, never a silent repair. A report that did not state the
182///  remote's value (a deletion, a refusal) is taken from the confirmation alone.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// async function resolveRemoteGroup({ gitPath, cwd, remoteLocation, updates }): Promise<PushUpdate[]>;
187/// ```
188pub fn confirm_group(
189    git: &PushGit,
190    remote_location: &str,
191    remote_name: &str,
192    updates: &[(PorcelainUpdate, Option<String>)],
193) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
194    let mut arguments: Vec<OsString> = vec![
195        OsString::from("ls-remote"),
196        OsString::from("--refs"),
197        OsString::from(remote_location),
198    ];
199    arguments.extend(updates.iter().map(|(update, _local)| {
200        return OsString::from(&update.to);
201    }));
202    let output = run_query(git, arguments.as_slice())
203        .map_err(|error| {
204            return probe_error(format!("ls-remote of {remote_location} failed: {error}"));
205        })?;
206    if output.code != Some(0) {
207        return Err(probe_error(format!(
208            "ls-remote of {remote_location} failed: {}",
209            String::from_utf8_lossy(&output.stderr).trim_end()
210        )));
211    }
212    let listing: String = String::from_utf8_lossy(&output.stdout).into_owned();
213    let mut confirmed: Vec<PushUpdate> = Vec::new();
214    for (update, local) in updates {
215        let authoritative: Option<String> = listing.lines().find_map(|line| {
216            return line.split_once('\t').filter(|(_oid, name)| {
217                return *name == update.to;
218            }).map(|(oid, _name)| {
219                return oid.to_owned();
220            });
221        });
222        let changed: bool = match &update.before {
223            RemoteBefore::Absent => authoritative.is_some(),
224            RemoteBefore::Known(reported) => authoritative.as_ref() != Some(reported),
225            RemoteBefore::Unknown => false,
226        };
227        if changed {
228            return Err(probe_error(format!("Remote ref changed during manual-push discovery: {}", update.to)));
229        }
230        confirmed.push(PushUpdate {
231            local_oid: local.clone(),
232            remote_oid: authoritative,
233            remote_name: remote_name.to_owned(),
234            remote_ref: update.to.clone(),
235        });
236    }
237    return Ok(confirmed);
238}
239
240/// What:
241///  Discover the exact updates the push would send, confirmed against each destination.
242/// Why:
243///   The lifecycle's candidates and published-set reads both start from these updates.
244///
245/// In TS you'd write (pseudocode):
246/// ```ts
247/// async function probeManualPushUpdates({ gitPath, cwd, args }): Promise<PushUpdate[]>;
248/// ```
249pub fn probe_manual_push_updates(
250    git: &PushGit,
251    global_prefix: &[OsString],
252    post_subcommand: &[OsString],
253) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
254    let arguments: Vec<OsString> = probe_arguments(global_prefix, post_subcommand);
255    let output = run_query(git, arguments.as_slice()).map_err(|error| {
256        return probe_error(format!("probe push could not run: {error}"));
257    })?;
258    let report: String = String::from_utf8_lossy(output.stdout.as_slice()).into_owned();
259    let blocks = parse_porcelain(report.as_str()).map_err(probe_error)?;
260    if blocks.is_empty() {
261        if output.code == Some(0) {
262            return Ok(Vec::new());
263        }
264        return Err(probe_error(format!(
265            "Git could not determine push updates: {}",
266            String::from_utf8_lossy(&output.stderr).trim_end()
267        )));
268    }
269    let named = push_shape(post_subcommand).and_then(|shape| {
270        return shape.repository;
271    });
272    let typed_location: Option<String> = named.as_ref().map(|typed| {
273        return typed.to_string_lossy().into_owned();
274    });
275    let mut asking = |query: &[&str]| -> Result<String, String> {
276        let mut owned: Vec<OsString> = global_prefix.to_vec();
277        owned.extend(query.iter().map(OsString::from));
278        let answered = run_query(git, owned.as_slice()).map_err(|error| {
279            return error.to_string();
280        })?;
281        let text: String = String::from_utf8_lossy(answered.stdout.as_slice()).trim().to_owned();
282        return match answered.code {
283            Some(0) => Ok(text),
284            Some(1) if text.is_empty() => Ok(text),
285            code => Err(format!("exited with {code:?}")),
286        };
287    };
288    let remote_name: String = resolve_push_remote(named, &mut asking)
289        .map_err(|error| {
290            return probe_error(error.0);
291        })?
292        .to_string_lossy()
293        .into_owned();
294    // A destination the caller typed as an URL or a path is its own location, with its credentials; Git's printed `To`
295    //  line has them removed, so it is never used as an address. A remote name costs one query for its push URLs.
296    let locations: Vec<String> = match typed_location {
297        Some(typed) if looks_like_location(typed.as_str()) => vec![typed],
298        _ => locations_of(git, global_prefix, remote_name.as_str()),
299    };
300    if locations.len() != blocks.len() {
301        return Err(probe_error(format!(
302            "Git reported {} destinations but {remote_name} has {} push locations",
303            blocks.len(),
304            locations.len()
305        )));
306    }
307    let mut confirmed: Vec<PushUpdate> = Vec::new();
308    for (block, location) in blocks.iter().zip(locations.iter()) {
309        let unnamed: Vec<&str> = block
310            .updates
311            .iter()
312            .filter(|update| {
313                return !update.from.is_empty() && update.after.is_none();
314            })
315            .map(|update| {
316                return update.from.as_str();
317            })
318            .collect();
319        let mut resolved = local_objects(git, global_prefix, unnamed.as_slice())?.into_iter();
320        let group: Vec<(PorcelainUpdate, Option<String>)> = block
321            .updates
322            .iter()
323            .map(|update| {
324                let local: Option<String> = if update.from.is_empty() {
325                    None
326                } else {
327                    update.after.clone().or_else(|| {
328                        return resolved.next();
329                    })
330                };
331                return (update.clone(), local);
332            })
333            .collect();
334        confirmed.extend(confirm_group(git, location.as_str(), remote_name.as_str(), group.as_slice())?);
335    }
336    return Ok(confirmed);
337}
338
339/// The probe's arguments and its confirmation rules are controlled in the test module.
340#[cfg(test)]
341#[path = "manual_push_probe_tests.rs"]
342mod tests;