Skip to main content

git_policy_cli/push/
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 crate::auto_push_single_flight::{PushGit, SingleFlightError, run_query};
19/// The destination Git would pick for a bare push, and the porcelain report's parser.
20use crate::lfs_upload_plan::resolve_push_remote;
21/// Removes userinfo from text that may name a remote, and the cause a failure carries.
22use crate::lfs_output_mask::mask_userinfo_text;
23use crate::manual_push_cause::ManualPushCause;
24use crate::manual_push_porcelain::{PorcelainBlock, PorcelainUpdate, RemoteBefore, parse_porcelain};
25use crate::parallel_budget::{BudgetRequest, HostResources, worker_budget};
26use crate::parallel_runner::{Event, OrderedRun, run_ordered};
27/// The push's own reading of its destination.
28use crate::command_options::{Boundary, DEFAULT_MODE, parse_options};
29use crate::command_push::{PUSH_TABLE, push_shape};
30/// Owned OS strings for the probe's Git arguments.
31use std::ffi::OsString;
32
33/// What:
34///  Git's own refusal of a push: the exit status and the standard error it ended with.
35/// Why:
36///   When the dry run exits with a failure before it reports any destination, Git has refused the push itself (a ref to
37///  delete that the remote lacks, a refspec naming nothing, a remote that does not exist, an option it does not know).
38///  Nothing could be published, so Git's words and status are the answer, and the wrapper adds none of its own.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type GitRefusal = { code: number; stderr: string };
43/// ```
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct GitRefusal {
46    /// The exit status Git ended with; never zero.
47    pub code: i32,
48    /// What Git wrote to standard error, as Git wrote it.
49    pub stderr: String,
50}
51
52/// What:
53///  Why the probe could not establish the updates Git would push.
54/// Why:
55///   Every variant stops the policy lifecycle; none guesses an update. A failure of the wrapper's own reading is a
56///  content-unavailable engine failure, and a refusal by Git carries Git's own report instead.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// class ManualPushProbeError extends Error { refusal?: GitRefusal }
61/// ```
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct ManualPushProbeError {
64    /// Diagnostic naming what could not be established.
65    pub message: String,
66    /// Git's refusal when Git itself ended the dry run before it reported any destination; `None` for a failure of the
67    ///  wrapper's own reading.
68    pub refusal: Option<GitRefusal>,
69    /// What the person can do about it, which the hint is chosen by.
70    pub cause: ManualPushCause,
71}
72
73impl std::fmt::Display for ManualPushProbeError {
74    /// Writes the diagnostic text.
75    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        return formatter.write_str(&self.message);
77    }
78}
79
80impl std::error::Error for ManualPushProbeError {}
81
82/// Build a probe error from text.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// new ManualPushProbeError(cause, message)
87/// ```
88fn probe_error(cause: ManualPushCause, message: String) -> ManualPushProbeError {
89    return ManualPushProbeError { message, refusal: None, cause };
90}
91
92/// What:
93///  The cause of a Git query that could not produce an exit status.
94/// Why:
95///   A query that never started means the Git the wrapper runs is missing; one stopped at its deadline already says in its
96///  message which setting changes the deadline; anything else Git did not explain.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// error instanceof StartError ? 'git-not-started' : error instanceof TimedOut ? 'self-explained' : 'git-silent'
101/// ```
102fn cause_of_query(error: &SingleFlightError) -> ManualPushCause {
103    return match error {
104        SingleFlightError::Start(_) => ManualPushCause::GitNotStarted,
105        SingleFlightError::TimedOut { .. } => ManualPushCause::SelfExplained,
106        SingleFlightError::Git { .. } | SingleFlightError::Io(_) => ManualPushCause::GitSilent,
107    };
108}
109
110/// What:
111///  One confirmed push update, the public fact the policies read.
112/// Why:
113///   An absent value is `None`, which replaces the incumbent's `ABSENT_GIT_VALUE` sentinel.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// type PushUpdate = { localOid: string | typeof ABSENT_GIT_VALUE; remoteOid: string | typeof ABSENT_GIT_VALUE; remoteName; remoteRef };
118/// ```
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct PushUpdate {
121    /// Local object name; `None` for a deletion.
122    pub local_oid: Option<String>,
123    /// Destination object name confirmed by `ls-remote`; `None` when the ref is absent.
124    pub remote_oid: Option<String>,
125    /// Destination remote name.
126    pub remote_name: String,
127    /// Destination ref.
128    pub remote_ref: String,
129}
130
131/// The most per-destination queries that open a connection to a remote and run at once (`git ls-remote` of each destination):
132///  the bound `SPEC.md` gives, four. The remote side (how many connections a server tolerates) was not measured, so the value is
133///  the one the SPEC already gave; it is the coordinator's pick under the owner's delegation, open to the owner's veto
134///  (`doc/planning/cli-git-shipped-limits-audit.md`, "Picked for these follow-ups", concurrent per-update queries, option B).
135pub const MOST_CONCURRENT_REMOTE_QUERIES: usize = 4;
136
137/// The most per-update queries of the local repository that run at once (`git cat-file` and `git rev-list` of each update). Measured
138///  on 16 branch tips of this repository, medians of three runs in alternating orders, other sessions running: `git ls-tree -r`
139///  took 218, 114, 80, 50 and 46 ms at 1, 2, 4, 8 and 16 at once, and `git rev-list` of a whole branch 845, 413, 231, 190 and 194
140///  ms, so eight is where the gain stops. The coordinator's pick under the owner's delegation, open to the owner's veto
141///  (`doc/planning/cli-git-shipped-limits-audit.md`, "Picked for these follow-ups", concurrent per-update queries, option B).
142pub const MOST_CONCURRENT_LOCAL_QUERIES: usize = LOCAL_QUERY_GAIN_STOPS_AT;
143
144/// The number of queries at once at which the measured gain of local queries stopped (see `MOST_CONCURRENT_LOCAL_QUERIES`).
145pub const LOCAL_QUERY_GAIN_STOPS_AT: usize = 8;
146
147/// The bytes one query holds at its peak when it lists the whole history of this repository: the largest resident size of
148///  `git rev-list --all` in three runs (29,164 KiB, 29,032 KiB and 28,848 KiB, so 29,863,936 bytes) plus the 1,413,721 bytes of
149///  its output, which the wrapper reads whole (34,481 commits on 2026-11-03). `git ls-tree -r` of the main tree peaked at 26,080
150///  KiB with 2,459,743 bytes of output.
151pub const MEASURED_WHOLE_HISTORY_QUERY: u64 = 29_863_936 + 1_413_721;
152
153/// The bytes one query holds at its peak: `MEASURED_WHOLE_HISTORY_QUERY` rounded up to a power of two. The coordinator's pick
154///  under the owner's delegation, open to the owner's veto (`doc/planning/cli-git-shipped-limits-audit.md`, "Parallelism caps
155///  and memory estimates"). A history that grows past a few tens of thousands more commits needs a larger figure.
156pub const QUERY_MEMORY: u64 = MEASURED_WHOLE_HISTORY_QUERY.next_power_of_two();
157
158/// What:
159///  The probe's Git arguments: the invocation's global options, the switches that keep every hook and full object
160///  names, then `push` with `--dry-run --porcelain` placed before any `--` or `--end-of-options` terminator.
161/// Why:
162///   The `--` terminator is the user's; the options Git interprets must stay before it. The probe publishes nothing and
163///  must not run the upload hook of the caller's configuration, so configured `pre-push` hooks are off and the
164///  traditional hooks directory is one that never exists.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// probeArgs({ globalPrefix, args });
169/// ```
170pub fn probe_arguments(global_prefix: &[OsString], post_subcommand: &[OsString]) -> Vec<OsString> {
171    // The separator is the one Git reads: a `--` that is the value of an option such as `--receive-pack` is not it. A region
172    //  Git refuses has no reading, and the probe then stops at the first `--`, where Git refuses it as well.
173    let separator = match parse_options(post_subcommand, PUSH_TABLE, DEFAULT_MODE, &[]) {
174        Ok(parsed) => match parsed.boundary {
175            Boundary::DashDash(at) | Boundary::EndOfOptions(at) => Some(at),
176            Boundary::End | Boundary::NonOption(_) => None,
177        },
178        Err(_) => post_subcommand.iter().position(|argument| {
179            return argument.as_os_str() == "--";
180        }),
181    };
182    let insertion = separator.unwrap_or(post_subcommand.len());
183    let mut arguments: Vec<OsString> = global_prefix.to_vec();
184    for setting in [
185        format!("core.hooksPath={}", crate::hooks_off::NO_HOOKS_DIRECTORY),
186        String::from("hook.pre-push.enabled=false"),
187        String::from("core.abbrev=no"),
188    ] {
189        arguments.push(OsString::from("-c"));
190        arguments.push(OsString::from(setting));
191    }
192    arguments.push(OsString::from("push"));
193    arguments.extend(post_subcommand[..insertion].iter().cloned());
194    // A `-q` written by the caller silences the report this probe reads, so it is cancelled here; the caller's own
195    //  push keeps its `-q`.
196    arguments.push(OsString::from("--no-quiet"));
197    arguments.push(OsString::from("--dry-run"));
198    arguments.push(OsString::from("--porcelain"));
199    arguments.extend(post_subcommand[insertion..].iter().cloned());
200    return arguments;
201}
202
203/// Whether text typed as the repository is an URL, an scp-style address or a path, not a remote name.
204fn looks_like_location(typed: &str) -> bool {
205    return typed.contains("://")
206        || typed.starts_with('/')
207        || typed.starts_with("./")
208        || typed.starts_with("../")
209        || typed.split_once(':').is_some_and(|(front, _)| {
210            return !front.contains('/');
211        });
212}
213
214/// One Git query of the probe: its trimmed standard output when it exits with status zero.
215fn answer(git: &PushGit, arguments: &[OsString]) -> Option<String> {
216    let output = run_query(git, arguments).ok()?;
217    if output.code != Some(0) {
218        return None;
219    }
220    return Some(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_owned());
221}
222
223/// The destination locations of a remote name, one per `To` block Git printed: the push URLs of a configured remote,
224///  or the name itself when it is not one (a URL or a path typed in place of a remote).
225fn locations_of(git: &PushGit, global_prefix: &[OsString], remote: &str) -> Vec<String> {
226    let mut arguments: Vec<OsString> = global_prefix.to_vec();
227    arguments.extend(["remote", "get-url", "--push", "--all", remote].map(OsString::from));
228    return match answer(git, arguments.as_slice()) {
229        Some(listing) if !listing.is_empty() => listing.lines().map(str::to_owned).collect(),
230        Some(_) | None => vec![remote.to_owned()],
231    };
232}
233
234/// What:
235///  The object names of local refs, in order, from one `rev-parse`.
236/// Why:
237///   A new ref's report line has no object name; the ref's own value is what the push sends.
238///
239/// In TS you'd write (pseudocode):
240/// ```ts
241/// (await git(['rev-parse', ...names])).split('\n')
242/// ```
243fn local_objects(git: &PushGit, global_prefix: &[OsString], names: &[&str]) -> Result<Vec<String>, ManualPushProbeError> {
244    if names.is_empty() {
245        return Ok(Vec::new());
246    }
247    let mut arguments: Vec<OsString> = global_prefix.to_vec();
248    arguments.push(OsString::from("rev-parse"));
249    arguments.extend(names.iter().map(OsString::from));
250    let listing: String = answer(git, arguments.as_slice())
251        .ok_or_else(|| {
252            return probe_error(ManualPushCause::ContentMissing, format!("the local objects of {names:?} could not be read"));
253        })?;
254    let objects: Vec<String> = listing.lines().map(str::to_owned).collect();
255    if objects.len() != names.len() {
256        return Err(probe_error(ManualPushCause::UnexpectedOutput, format!("rev-parse answered {} names for {}", objects.len(), names.len())));
257    }
258    return Ok(objects);
259}
260
261/// What:
262///  Confirm one destination's values with `git ls-remote --refs` and turn each reported update into a public update.
263/// Why:
264///   A destination that changed after the dry run reported would publish something the scan did not see, so a
265///  mismatch between the report and the confirmation is an error, never a silent repair. A report that did not state the
266///  remote's value (a deletion, a refusal) is taken from the confirmation alone.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// async function resolveRemoteGroup({ gitPath, cwd, remoteLocation, updates }): Promise<PushUpdate[]>;
271/// ```
272pub fn confirm_group(
273    git: &PushGit,
274    remote_location: &str,
275    remote_name: &str,
276    updates: &[(PorcelainUpdate, Option<String>)],
277) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
278    let mut arguments: Vec<OsString> = vec![
279        OsString::from("ls-remote"),
280        OsString::from("--refs"),
281        OsString::from(remote_location),
282    ];
283    arguments.extend(updates.iter().map(|(update, _local)| {
284        return OsString::from(&update.to);
285    }));
286    let output = run_query(git, arguments.as_slice())
287        .map_err(|error| {
288            return probe_error(cause_of_query(&error), mask_userinfo_text(format!("ls-remote of {remote_location} failed: {error}").as_str()));
289        })?;
290    if output.code != Some(0) {
291        return Err(probe_error(
292            ManualPushCause::RemoteUnreachable,
293            mask_userinfo_text(
294                format!("ls-remote of {remote_location} failed: {}", String::from_utf8_lossy(&output.stderr).trim_end()).as_str(),
295            ),
296        ));
297    }
298    let listing: String = String::from_utf8_lossy(&output.stdout).into_owned();
299    let mut confirmed: Vec<PushUpdate> = Vec::new();
300    for (update, local) in updates {
301        let authoritative: Option<String> = listing.lines().find_map(|line| {
302            return line.split_once('\t').filter(|(_oid, name)| {
303                return *name == update.to;
304            }).map(|(oid, _name)| {
305                return oid.to_owned();
306            });
307        });
308        let changed: bool = match &update.before {
309            RemoteBefore::Absent => authoritative.is_some(),
310            RemoteBefore::Known(reported) => authoritative.as_ref() != Some(reported),
311            RemoteBefore::Unknown => false,
312        };
313        if changed {
314            return Err(probe_error(ManualPushCause::RemoteMoved, format!("Remote ref changed during manual-push discovery: {}", update.to)));
315        }
316        confirmed.push(PushUpdate {
317            local_oid: local.clone(),
318            remote_oid: authoritative,
319            remote_name: remote_name.to_owned(),
320            remote_ref: update.to.clone(),
321        });
322    }
323    return Ok(confirmed);
324}
325
326/// What:
327///  Discover the exact updates the push would send, confirmed against each destination.
328/// Why:
329///   The lifecycle's candidates and published-set reads both start from these updates.
330///
331/// In TS you'd write (pseudocode):
332/// ```ts
333/// async function probeManualPushUpdates({ gitPath, cwd, args }): Promise<PushUpdate[]>;
334/// ```
335pub fn probe_manual_push_updates(
336    git: &PushGit,
337    global_prefix: &[OsString],
338    post_subcommand: &[OsString],
339) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
340    return probe_manual_push_updates_on(&HostResources::read(), git, global_prefix, post_subcommand);
341}
342
343/// `probe_manual_push_updates` with the host's resources chosen by the caller, so a control states a host of any size.
344pub fn probe_manual_push_updates_on(
345    host: &HostResources,
346    git: &PushGit,
347    global_prefix: &[OsString],
348    post_subcommand: &[OsString],
349) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
350    let arguments: Vec<OsString> = probe_arguments(global_prefix, post_subcommand);
351    let output = run_query(git, arguments.as_slice()).map_err(|error| {
352        return probe_error(cause_of_query(&error), format!("probe push could not run: {error}"));
353    })?;
354    let report: String = String::from_utf8_lossy(output.stdout.as_slice()).into_owned();
355    let blocks = parse_porcelain(report.as_str()).map_err(|message| {
356        return probe_error(ManualPushCause::UnexpectedOutput, message);
357    })?;
358    if blocks.is_empty() {
359        if output.code == Some(0) {
360            return Ok(Vec::new());
361        }
362        let said: String = String::from_utf8_lossy(&output.stderr).into_owned();
363        let message: String = mask_userinfo_text(format!("Git could not determine push updates: {}", said.trim_end()).as_str());
364        // Git ends `push --dry-run --porcelain` before any destination is reported only when it refuses the push itself
365        //  (`transport_push` returns before it prints a block, and `die` and usage errors end the process); a status
366        //  from a signal, or a refusal without a word, is not Git saying no.
367        return Err(match output.code {
368            Some(code) if !said.trim().is_empty() => {
369                ManualPushProbeError { message, refusal: Some(GitRefusal { code, stderr: said }), cause: ManualPushCause::SelfExplained }
370            }
371            Some(_) | None => probe_error(ManualPushCause::GitSilent, message),
372        });
373    }
374    let named = push_shape(post_subcommand).and_then(|shape| {
375        return shape.repository;
376    });
377    let typed_location: Option<String> = named.as_ref().map(|typed| {
378        return typed.to_string_lossy().into_owned();
379    });
380    let mut asking = |query: &[&str]| -> Result<String, String> {
381        let mut owned: Vec<OsString> = global_prefix.to_vec();
382        owned.extend(query.iter().map(OsString::from));
383        let answered = run_query(git, owned.as_slice()).map_err(|error| {
384            return error.to_string();
385        })?;
386        let text: String = String::from_utf8_lossy(answered.stdout.as_slice()).trim().to_owned();
387        return match answered.code {
388            Some(0) => Ok(text),
389            Some(1) if text.is_empty() => Ok(text),
390            code => Err(format!("exited with {code:?}")),
391        };
392    };
393    let remote_name: String = resolve_push_remote(named, &mut asking)
394        .map_err(|error| {
395            return probe_error(ManualPushCause::SelfExplained, format!("Git could not tell which remote this push goes to: {}", error.0));
396        })?
397        .to_string_lossy()
398        .into_owned();
399    // A destination the caller typed as an URL or a path is its own location, with its credentials; Git's printed `To`
400    //  line has them removed, so it is never used as an address. A remote name costs one query for its push URLs.
401    let locations: Vec<String> = match typed_location {
402        Some(typed) if looks_like_location(typed.as_str()) => vec![typed],
403        _ => locations_of(git, global_prefix, remote_name.as_str()),
404    };
405    if locations.len() != blocks.len() {
406        return Err(probe_error(
407            ManualPushCause::SelfExplained,
408            mask_userinfo_text(
409                format!("Git reported {} destinations but {remote_name} has {} push locations", blocks.len(), locations.len()).as_str(),
410            ),
411        ));
412    }
413    // Each destination is its own `To` block with its own `ls-remote`, so the confirmations are independent reads and run side by
414    //  side, at most `MOST_CONCURRENT_REMOTE_QUERIES` at once; the updates come back in the order Git printed the blocks, and the
415    //  first block that fails decides the error.
416    let destinations: Vec<(&PorcelainBlock, &String)> = blocks.iter().zip(locations.iter()).collect();
417    let lanes: Vec<Vec<usize>> = (0..destinations.len()).map(|index: usize| { return vec![index]; }).collect();
418    let workers: usize = worker_budget(&BudgetRequest { items: destinations.len(), per_item_memory: QUERY_MEMORY, ceiling: Some(MOST_CONCURRENT_REMOTE_QUERIES), cap: None, host });
419    let mut confirmed: Vec<PushUpdate> = Vec::new();
420    let mut failure: Option<ManualPushProbeError> = None;
421    run_ordered(OrderedRun {
422        items: destinations.as_slice(),
423        lanes: lanes.as_slice(),
424        workers,
425        work: &|_index: usize, destination: &(&PorcelainBlock, &String), _say: &mut dyn FnMut(&str)| { return confirm_block(git, global_prefix, remote_name.as_str(), destination.0, destination.1.as_str()); },
426        on_event: &mut |event: Event<'_, Result<Vec<PushUpdate>, ManualPushProbeError>>| {
427            if let Event::End(_index, outcome) = event {
428                match outcome {
429                    Ok(updates) if failure.is_none() => confirmed.extend(updates),
430                    Err(error) if failure.is_none() => failure = Some(error),
431                    Ok(_) | Err(_) => {}
432                }
433            }
434        },
435    });
436    return match failure {
437        Some(error) => Err(error),
438        None => Ok(confirmed),
439    };
440}
441
442/// What:
443///  Resolve the local objects of one destination's block and confirm its values with `ls-remote`.
444/// Why:
445///   A block is everything the probe needs for one destination, so blocks can be confirmed independently.
446///
447/// In TS you'd write (pseudocode):
448/// ```ts
449/// async function confirmBlock({ git, globalPrefix, remoteName, block, location }): Promise<PushUpdate[]>;
450/// ```
451fn confirm_block(git: &PushGit, global_prefix: &[OsString], remote_name: &str, block: &PorcelainBlock, location: &str) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
452    let unnamed: Vec<&str> = block
453        .updates
454        .iter()
455        .filter(|update| {
456            return !update.from.is_empty() && update.after.is_none();
457        })
458        .map(|update| {
459            return update.from.as_str();
460        })
461        .collect();
462    let mut resolved = local_objects(git, global_prefix, unnamed.as_slice())?.into_iter();
463    let group: Vec<(PorcelainUpdate, Option<String>)> = block
464        .updates
465        .iter()
466        .map(|update| {
467            let local: Option<String> = if update.from.is_empty() {
468                None
469            } else {
470                update.after.clone().or_else(|| {
471                    return resolved.next();
472                })
473            };
474            return (update.clone(), local);
475        })
476        .collect();
477    return confirm_group(git, location, remote_name, group.as_slice());
478}
479
480/// The probe's arguments and its confirmation rules are controlled in the test module.
481#[cfg(test)]
482#[path = "manual_push_probe_tests.rs"]
483mod tests;