Skip to main content

git_policy_cli/push/
bounded_push.rs

1//! What:
2//!  Run the auto-push's real `git push` (and its fetch and Git LFS upload) until it ends, or stops showing any sign of work
3//!  with no connection to a remote open.
4//! Why:
5//!  A push that shows nothing and holds no connection would otherwise hold the service's push of that branch forever,
6//!  and every later push of the repository would wait behind it.
7//!  Such a push is stopped when its stall window passes,
8//!  the commit stays saved locally,
9//!  and the failure is reported like any other failed push.
10//!  A push whose connection to the remote is open is never stopped for time (owner, 2026-11-03: "Wait while connected
11//!  (Recommended)"; pick 5 of `doc/planning/cli-git-shipped-limits-audit.md`, the coordinator's under the owner's delegation and
12//!  open to the owner's veto): it has no ceiling, and the wrapper sets no low-speed abort for it.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const { exitCode, output } = await nanoSpawn(gitPath, pushArgs, { cwd, signal: AbortSignal.timeout(deadlineMs) });
17//! ```
18
19/// The attempt record a finished or stopped push produces.
20use crate::auto_push_single_flight::{PushAttempt, SingleFlightError};
21/// The diagnostic log of the wrapper.
22use crate::diagnostic_log::{debug, warn};
23/// How to allow a push longer, in the words of the program that runs it.
24use crate::push_deadline_advice::extend_clause;
25/// The clock that stops a push on a stall, and the wait it lets pass instead.
26use crate::push_progress::{Clock, WaitReport};
27/// The helpers and hooks a stopped push leaves running.
28use crate::process_tree::{TreeMember, descendants_of, kill_members, member_of, still_running, terminate_members};
29/// The lock files a stopped push leaves, and the ledger that finds them.
30use crate::stopped_git_locks::LockLedger;
31/// Starts that ride out a short shortage of processes.
32/// The anonymous output file of the push, made in the per-user scratch directory.
33use crate::scratch_location::scratch_file;
34use crate::start_retry::RetryStart;
35/// Owned OS strings of the child environment.
36use std::ffi::{OsStr, OsString};
37/// `File` is the shared anonymous output file; `Read`, `Seek` and `SeekFrom` read it back from its start.
38use std::fs::File;
39use std::io::{Read, Seek, SeekFrom};
40/// `Child` is the running push; `Command` describes it; `Stdio` connects its streams.
41use std::process::{Child, Command, ExitStatus, Stdio};
42/// Time spans and the monotonic clock.
43use std::time::{Duration, Instant};
44
45/// Tag used for diagnostics from this module.
46const TAG: &str = "cli-git";
47
48/// What a person is told when a push is ended for showing no progress: the owner's answer of 2026-11-03, "Bound it, keep retrying
49///  (Recommended)", is that the service's retry plan keeps the push and tries it again, and the line says so.
50pub const PUSH_QUEUED_LINE: &str = "The push is queued: the commit service keeps it in its retry plan and tries it again.";
51
52/// Environment variable that sets the push deadline in whole seconds.
53pub const PUSH_DEADLINE_VARIABLE: &str = "CLI_GIT_PUSH_DEADLINE_SECONDS";
54
55/// How long a push with no connection to a remote open may go without any sign of work (the stall window) when the variable is
56///  absent or unusable. Where no progress can be measured, it is how long the push may run in all.
57///  Citation: no measurement or owner decision determines 30 seconds. The owner decided the rule, "Stop only on no progress
58///  (Recommended)", and not the number. The journal of the commit service (read-only, 794 pushes that landed since 2026-10-20) holds
59///  whole-push times, p50 3.5 s, p99 26.4 s, maximum 72.4 s, which include connected time and so are not a measure of silence
60///  without a connection; no silence of a disconnected push is logged anywhere. The number is an open question for the owner
61///  (`doc/planning/cli-git-shipped-limits-audit.md`, "Push stall window, 30 seconds").
62pub const DEFAULT_PUSH_DEADLINE: Duration = Duration::from_secs(30);
63
64/// How long a stopped push gets to end after the polite stop signal before it is killed.
65///  Citation: partial. The helpers of a stopped push were measured on 2026-11-03 to outlive it for 60 seconds, and a `git-lfs` that
66///  never answers for good, and empty tracking-ref locks appeared about 37 seconds after a stop; those justify the kill that
67///  follows the grace and the sweep, not five seconds, and no measurement of how long Git takes to remove its own locks on a
68///  polite stop exists. An open question for the owner.
69pub const STOP_GRACE: Duration = Duration::from_secs(5);
70
71/// The most output bytes kept from one push;
72///  a push that writes more is stopped.
73///  Citation: none yet. The owner decided "Keep all lines" for push output, which this cap limits, and no measurement of the
74///  size of a push's output is logged; the audit lists it as a memory bound to be measured.
75pub const MAX_PUSH_OUTPUT: u64 = 4 * 1024 * 1024;
76
77/// Exit code recorded for a push that this wrapper stopped,
78///  the convention of `timeout(1)`.
79pub const STOPPED_EXIT_CODE: i64 = 124;
80
81/// What the line a push's output gains has after the name of the program, when it waited with its connection open.
82pub const WAIT_NOTE_PHRASE: &str = "is waiting for the server";
83
84/// Pause between two looks at the running push.
85const POLL_PAUSE: Duration = Duration::from_millis(10);
86
87/// What:
88///  Read a positive whole number of seconds from one environment value.
89/// Why:
90///   A zero, negative, fractional, empty or non-text value is no setting at all,
91///  never "no limit",
92///  so a typo cannot remove a bound.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// const seconds = Number(value); return Number.isInteger(seconds) && seconds > 0 ? seconds * 1000 : undefined;
97/// ```
98pub fn positive_seconds(name: &str, value: &OsStr) -> Option<Duration> {
99    let parsed: Option<u64> = match value.to_str() {
100        Some(text) => text.trim().parse::<u64>().ok(),
101        None => None,
102    };
103    return match parsed {
104        Some(count) if count > 0 => Some(Duration::from_secs(count)),
105        Some(_) | None => {
106            debug(
107                TAG,
108                &format!("{name} is not a positive whole number; ignoring it"),
109            );
110            None
111        }
112    };
113}
114
115/// What:
116///  Read a positive whole number of seconds from the child environment.
117/// Why:
118///   The last of repeated entries wins,
119///  as in an environment block.
120///
121/// In TS you'd write (pseudocode):
122/// ```ts
123/// positiveSeconds(env[name])
124/// ```
125fn seconds_variable(environment: &[(OsString, OsString)], name: &str) -> Option<Duration> {
126    let mut found: Option<&OsString> = None;
127    for (key, value) in environment {
128        if key.to_str() == Some(name) {
129            found = Some(value);
130        }
131    }
132    let value: &OsString = found?;
133    return positive_seconds(name, value.as_os_str());
134}
135
136/// What:
137///  The push deadline: the environment's value, or the default.
138/// Why:
139///   A person may need a longer deadline on a slow link or a shorter one on a flaky one.
140///
141/// In TS you'd write (pseudocode):
142/// ```ts
143/// const deadlineMs = secondsOf(env.CLI_GIT_PUSH_DEADLINE_SECONDS) ?? DEFAULT_MS;
144/// ```
145pub fn push_deadline(environment: &[(OsString, OsString)]) -> Duration {
146    return seconds_variable(environment, PUSH_DEADLINE_VARIABLE).unwrap_or(DEFAULT_PUSH_DEADLINE);
147}
148
149/// What:
150///  How long a bounded child may take: a fixed time, or a time without any sign of work and without an open connection.
151/// Why:
152///   A slow push that keeps moving data is working and should finish, and one whose connection to a remote is open is waiting
153///  for the remote and should wait too; one that shows nothing and holds no connection is stuck and should stop fast. A query has
154///  no transfer to measure and keeps a fixed bound.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// type Patience = { kind: 'fixed'; ms: number } | { kind: 'progress'; stallMs: number };
159/// ```
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub enum Patience {
162    /// Stop after this long, whatever the child does.
163    Fixed(Duration),
164    /// Stop after `stall` without data moved, processor time used, or a task at work, by the child and everything it started,
165    ///  unless a process of them holds an open connection to a remote: that is waited for, however long it takes.
166    Progress {
167        /// How long the child may show nothing while no connection is open.
168        stall: Duration,
169    },
170}
171
172/// The patience of a push from the environment: the stall window.
173///
174/// In TS you'd write (pseudocode):
175/// ```ts
176/// ({ kind: 'progress', stallMs: pushDeadline(env) })
177/// ```
178pub fn push_patience(environment: &[(OsString, OsString)]) -> Patience {
179    return Patience::Progress { stall: push_deadline(environment) };
180}
181
182/// Remove exactly one trailing `\n` or `\r\n`, as the captured output of the incumbent does.
183///
184/// In TS you'd write (pseudocode):
185/// ```ts
186/// output.endsWith('\n') ? output.slice(0, output.endsWith('\r\n') ? -2 : -1) : output
187/// ```
188pub fn strip_one_line_break(text: &str) -> &str {
189    let without_lf = match text.strip_suffix('\n') {
190        Some(stripped) => stripped,
191        None => return text,
192    };
193    return without_lf.strip_suffix('\r').unwrap_or(without_lf);
194}
195
196/// Ask a running push to end,
197///  so Git can stop the helper processes it started.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// child.kill('SIGTERM');
202/// ```
203#[cfg(unix)]
204fn ask_to_stop(child: &Child) {
205    let Ok(pid) = libc::pid_t::try_from(child.id()) else {
206        return;
207    };
208    // SAFETY: `kill` sends one signal to the PID of a child this module started and has not reaped;
209    // a failure is ignored because the child may have ended.
210    unsafe { libc::kill(pid, libc::SIGTERM) };
211}
212
213/// Platforms without a polite stop signal go straight to the kill.
214// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
215#[cfg(not(unix))]
216fn ask_to_stop(_child: &Child) {}
217
218/// Whether part of the grace is still left after the stop request.
219///
220/// In TS you'd write (pseudocode):
221/// ```ts
222/// asked < grace
223/// ```
224fn grace_remains(asked: Duration, grace: Duration) -> bool {
225    return asked < grace;
226}
227
228/// Whether an output of this size is a flood: only a size past the cap is.
229///
230/// In TS you'd write (pseudocode):
231/// ```ts
232/// size > MAX_PUSH_OUTPUT
233/// ```
234fn is_flood(size: u64) -> bool {
235    return size > MAX_PUSH_OUTPUT;
236}
237
238/// Wait, bounded by `grace`, until every member of a stopped tree has ended.
239///
240/// In TS you'd write (pseudocode):
241/// ```ts
242/// await Promise.race([allEnded(tree), delay(graceMs)]);
243/// ```
244fn wait_for_tree(tree: &[TreeMember], grace: Duration) {
245    let waited: Instant = Instant::now();
246    while grace_remains(waited.elapsed(), grace) && tree.iter().any(still_running) {
247        std::thread::sleep(POLL_PAUSE);
248    }
249}
250
251/// What:
252///  Collect a killed child that the kernel has not released, and sweep the locks it leaves, on a thread of its own.
253/// Why:
254///   A killed process in an uninterruptible call (a stalled disk or file system) ends only when the kernel lets it go,
255///  and on its way out it can create a lock file that nothing will remove (measured on 2026-11-03: empty tracking-ref
256///  locks born about 37 seconds after the stop). The push is reported at its deadline and does not wait for that, so
257///  the child is collected here, and its locks are swept when it is gone. The thread holds no lock and ends with the child.
258///
259/// In TS you'd write (pseudocode):
260/// ```ts
261/// void child.exited.then(() => locks.sweep(tree));
262/// ```
263pub fn sweep_after_exit(mut child: Child, tree: Vec<TreeMember>, locks: LockLedger) -> Option<std::thread::JoinHandle<()>> {
264    return std::thread::Builder::new()
265        .name(String::from("cli-git-lock-sweep"))
266        .spawn(move || {
267            // The exit status is not reported: the push was reported at its deadline.
268            let _status = child.wait();
269            // Nothing waits for this thread, so it waits for the rest of the tree as long as that runs.
270            while tree.iter().any(still_running) {
271                std::thread::sleep(POLL_PAUSE);
272            }
273            locks.sweep(tree.as_slice());
274        })
275        .inspect_err(|error| {
276            debug(TAG, &format!("could not start the thread that sweeps a stopped push's locks: {error}"));
277        })
278        .ok();
279}
280
281/// What:
282///  Stop a running push: ask politely,
283///  wait the grace,
284///  then kill and collect it,
285///  and judge the reference locks it may have left.
286/// Why:
287///   Git's own signal handling ends the helpers it started and removes its lock files,
288///  and a push that ignores the request cannot outlive the kill.
289///  A killed push leaves its lock files, which fail every later Git command on the same refs, so once the push and
290///  what it started are gone the locks they hold are swept.
291///
292/// In TS you'd write (pseudocode):
293/// ```ts
294/// child.kill('SIGTERM'); await Promise.race([exited, delay(graceMs)]) || child.kill('SIGKILL'); locks.sweep(tree.as_slice());
295/// ```
296pub fn stop_push(mut child: Child, grace: Duration, locks: LockLedger) {
297    // Read before the push is signalled: once it has exited, its helpers and hooks no longer point to it.
298    let below: Vec<TreeMember> = i32::try_from(child.id()).map(descendants_of).unwrap_or_default();
299    let mut tree: Vec<TreeMember> = i32::try_from(child.id()).ok().and_then(member_of).into_iter().collect();
300    tree.extend(below.iter().cloned());
301    ask_to_stop(&child);
302    terminate_members(below.as_slice());
303    let asked: Instant = Instant::now();
304    let mut ended: bool = false;
305    while !ended && grace_remains(asked.elapsed(), grace) {
306        match child.try_wait() {
307            Ok(Some(_)) | Err(_) => ended = true,
308            Ok(None) => std::thread::sleep(POLL_PAUSE),
309        }
310    }
311    let mut collected: bool = ended;
312    if !ended {
313        // The child is being abandoned; its exit is not reported, only the cause.
314        let _killed = child.kill();
315        // A killed process that sits in uninterruptible I/O (a stalled disk or network file system) cannot be
316        //  collected until the kernel releases it, so the wait is bounded by the grace again and the child is then
317        //  left to be collected on a thread of its own, instead of holding the service's push for as long as the stall.
318        let killed_at: Instant = Instant::now();
319        while grace_remains(killed_at.elapsed(), grace) {
320            match child.try_wait() {
321                Ok(Some(_)) | Err(_) => {
322                    collected = true;
323                    break;
324                }
325                Ok(None) => std::thread::sleep(POLL_PAUSE),
326            }
327        }
328    }
329    // What the push started outlives it: its transport helper holds the connection and its hook may never exit
330    // (measured: 60 seconds for a helper, for good for a `git-lfs` that never answers). They were asked to end with
331    // the push, so they get what is left of the grace and are then killed.
332    while grace_remains(asked.elapsed(), grace) && below.iter().any(still_running) {
333        std::thread::sleep(POLL_PAUSE);
334    }
335    kill_members(below.as_slice());
336    let _sweeper = settle_locks(child, collected, tree, locks, grace);
337}
338
339/// What:
340///  Remove the locks of a stopped tree now when it has ended, or on a thread of its own when it has not.
341/// Why:
342///   A killed process ends only when the kernel has run it once more, which a loaded or stalled host may not do within the
343///  grace; a sweep before that finds the tree running and removes nothing, and nothing would try again. The thread waits for
344///  the whole tree and then sweeps. Returns the thread, if one was started.
345///
346/// In TS you'd write (pseudocode):
347/// ```ts
348/// if (collected && allEnded(tree)) { locks.sweep(tree.as_slice()); return undefined; } return sweepAfterExit(child, tree, locks);
349/// ```
350pub fn settle_locks(child: Child, collected: bool, tree: Vec<TreeMember>, locks: LockLedger, grace: Duration) -> Option<std::thread::JoinHandle<()>> {
351    if collected {
352        wait_for_tree(tree.as_slice(), grace);
353        if tree.iter().all(|member| { return !still_running(member); }) {
354            locks.sweep(tree.as_slice());
355            return None;
356        }
357    }
358    return sweep_after_exit(child, tree, locks);
359}
360
361/// Read the shared output file back from its start,
362///  keeping at most the output cap so a flood is never held in memory.
363///
364/// In TS you'd write (pseudocode):
365/// ```ts
366/// await readFile(outputPath, 'utf8')
367/// ```
368fn read_output(file: &mut File) -> Result<String, SingleFlightError> {
369    file.seek(SeekFrom::Start(0))
370        .map_err(SingleFlightError::Start)?;
371    let mut bytes: Vec<u8> = Vec::new();
372    file.take(MAX_PUSH_OUTPUT)
373        .read_to_end(&mut bytes)
374        .map_err(SingleFlightError::Start)?;
375    return Ok(String::from_utf8_lossy(&bytes).into_owned());
376}
377
378/// What a stop reports: the limit that was hit, what the push had moved and how long it had run.
379#[derive(Clone, Copy, Debug, Eq, PartialEq)]
380pub struct StopReport {
381    /// The limit that was hit: the stall window.
382    pub limit: Duration,
383    /// The bytes the push and everything it started had read and written.
384    pub moved: u64,
385    /// How long the push had run.
386    pub elapsed: Duration,
387}
388
389/// Why a push or query was stopped.
390pub enum StopCause {
391    /// The fixed deadline passed.
392    Deadline,
393    /// The push moved no data for the stall window.
394    Stalled(StopReport),
395    /// The output grew past the cap.
396    Flood,
397}
398
399/// What:
400///  Look at a running read-only query until it ends, stalls or floods its output, and say how quiet it got.
401/// Why:
402///   `std` has no timed wait,
403///  so the parent looks with `try_wait`,
404///  which never blocks; the clock reads the signs of work a few times a second. A query that works is waited for however
405///  long it takes: it has no wall-clock bound (owner, 2026-11-03: "Stop only on no progress"; the coordinator's scope addition
406///  to picks 4 and 9 of `doc/planning/cli-git-shipped-limits-audit.md`, open to the owner's veto), and on a system that counts
407///  nothing it is never judged stalled. `quiet` receives the longest quiet gap seen, for the ledger, and nothing where the system counts nothing.
408///
409/// In TS you'd write (pseudocode):
410/// ```ts
411/// await Promise.race([exited, stalled, flood]); // never by elapsed time
412/// ```
413pub fn wait_until_done(
414    child: &mut Child,
415    outputs: &[&File],
416    patience: Patience,
417    quiet: &mut Option<Duration>,
418) -> Result<Result<ExitStatus, StopCause>, SingleFlightError> {
419    let mut waits: Vec<WaitReport> = Vec::new();
420    let clock: Clock = Clock::start(child.id(), patience).without_wall_fallback();
421    return wait_with_clock(child, outputs, clock, "git", &mut waits, quiet);
422}
423
424/// What:
425///  The same wait as `wait_until_done` for a push, handing over the wait of a push that is waiting for its remote with the
426///  connection open.
427/// Why:
428///   The push is not stopped while it waits; the caller says so once, in a line, and the wait ends the way any other does.
429///
430/// In TS you'd write (pseudocode):
431/// ```ts
432/// await Promise.race([exited, stalled, flood]); // waits.push(report) when the push begins to wait for the server
433/// ```
434pub fn wait_until_done_noting(
435    child: &mut Child,
436    outputs: &[&File],
437    patience: Patience,
438    label: &str,
439    waits: &mut Vec<WaitReport>,
440) -> Result<Result<ExitStatus, StopCause>, SingleFlightError> {
441    let clock: Clock = Clock::start(child.id(), patience);
442    let mut unused: Option<Duration> = None;
443    return wait_with_clock(child, outputs, clock, label, waits, &mut unused);
444}
445
446/// The loop of both waits, over a clock the caller made.
447fn wait_with_clock(
448    child: &mut Child,
449    outputs: &[&File],
450    mut clock: Clock,
451    label: &str,
452    waits: &mut Vec<WaitReport>,
453    quiet: &mut Option<Duration>,
454) -> Result<Result<ExitStatus, StopCause>, SingleFlightError> {
455    // Nothing is kept where the clock reads nothing: its figure would be the elapsed time, not a quiet gap.
456    let kept = |watched: &Clock| -> Option<Duration> { return watched.measures().then(|| { return watched.longest_quiet(); }); };
457    loop {
458        if let Some(status) = child.try_wait().map_err(SingleFlightError::Start)? {
459            *quiet = kept(&clock);
460            return Ok(Ok(status));
461        }
462        for output in outputs {
463            let size: u64 = match output.metadata() {
464                Ok(metadata) => metadata.len(),
465                Err(_) => u64::MAX,
466            };
467            if is_flood(size) {
468                *quiet = kept(&clock);
469                return Ok(Err(StopCause::Flood));
470            }
471        }
472        if let Some(cause) = clock.check() {
473            *quiet = kept(&clock);
474            return Ok(Err(cause));
475        }
476        if let Some(report) = clock.take_wait_note() {
477            // Said as the wait begins, so a long wait is read while it lasts (the service's log, or the person's terminal).
478            warn(TAG, &wait_line(label, &report));
479            waits.push(report);
480        }
481        std::thread::sleep(POLL_PAUSE);
482    }
483}
484
485/// What:
486///  The name a run goes by in the lines about it: the program's file name and its first word that is not an option, as in
487///  `git push`, `git fetch` or `git-lfs push`.
488/// Why:
489///   The wait line must say which of the wrapper's network runs is waiting, and the command is the only thing that knows. The
490///  values of Git's `-c` and `-C` options are skipped with them.
491///
492/// In TS you'd write (pseudocode):
493/// ```ts
494/// const [program, ...args] = command; return [basename(program), args.find((word) => !word.startsWith('-'))].join(' ');
495/// ```
496pub fn command_label(command: &Command) -> String {
497    let program: String = command.get_program().to_string_lossy().rsplit('/').next().unwrap_or_default().to_owned();
498    let mut arguments = command.get_args();
499    while let Some(argument) = arguments.next() {
500        let word: String = argument.to_string_lossy().into_owned();
501        // `-c name=value` and `-C directory` take the next word as their value.
502        if word == "-c" || word == "-C" {
503            let _value = arguments.next();
504            continue;
505        }
506        if word.starts_with('-') {
507            continue;
508        }
509        return format!("{program} {word}");
510    }
511    return program;
512}
513
514/// What:
515///  The line that names a run's wait for its remote.
516/// Why:
517///   The person or the journal reads that the run is not stuck: the connection to the remote is open, and what ends the wait.
518///  No time ends it: only the remote's answer, the connection closing or TCP giving up.
519///
520/// In TS you'd write (pseudocode):
521/// ```ts
522/// `cli-git: ${label} ${WAIT_NOTE_PHRASE}: the connection to the remote is open (silent ${silent} seconds)`
523/// ```
524fn wait_line(label: &str, report: &WaitReport) -> String {
525    return format!(
526        "cli-git: {label} {WAIT_NOTE_PHRASE}: the connection to the remote is open, and nothing has moved for {} seconds \
527         (elapsed_seconds={}); the remote may be checking the push or preparing its answer. It is waited for until it answers or \
528         the connection closes; no time limit applies while the connection is open.",
529        report.silent.as_secs(),
530        report.elapsed.as_secs(),
531    );
532}
533
534/// What:
535///  Run a push command to its end or its deadline and capture its interleaved output, after the reference locks that a
536///  killed Git left in its repository have been cleared when their holder is proven gone.
537/// Why:
538///   Every start of the wrapper's own fetch and push, the repetitions of a push included, goes through here
539///  (`push_lock_clearing.rs`).
540///
541/// In TS you'd write (pseudocode):
542/// ```ts
543/// async function runBoundedPush({ command, patience, grace, environment }): Promise<PushAttempt>;
544/// ```
545pub fn run_bounded_push(
546    command: Command,
547    patience: Patience,
548    grace: Duration,
549    environment: &[(OsString, OsString)],
550) -> Result<PushAttempt, SingleFlightError> {
551    return crate::push_lock_clearing::run_clearing_locks(command, patience, grace, environment, &crate::stale_ref_lock_judge::Judge::here());
552}
553
554/// What:
555///  Run a push command to its end or its deadline and capture its interleaved output.
556/// Why:
557///   Both streams share one anonymous file so lines keep the order Git wrote them,
558///  and a file needs no reader thread that a stuck helper could keep alive.
559///  A stopped push becomes a failed attempt that says so,
560///  so the commit stays saved and the failure is recorded for later invocations.
561///
562/// In TS you'd write (pseudocode):
563/// ```ts
564/// async function runBoundedPush({ command, deadlineMs, graceMs }): Promise<PushAttempt>;
565/// ```
566pub fn run_bounded_push_once(
567    mut command: Command,
568    patience: Patience,
569    grace: Duration,
570    environment: &[(OsString, OsString)],
571) -> Result<PushAttempt, SingleFlightError> {
572    let mut output: File = scratch_file(environment).map_err(SingleFlightError::Start)?;
573    let for_stdout: File = output.try_clone().map_err(SingleFlightError::Start)?;
574    let for_stderr: File = output.try_clone().map_err(SingleFlightError::Start)?;
575    command
576        .stdin(Stdio::null())
577        .stdout(Stdio::from(for_stdout))
578        .stderr(Stdio::from(for_stderr));
579    // Taken before the push starts, so a lock born earlier is never taken for the push's.
580    let locks: LockLedger = LockLedger::before_start(&command);
581    let label: String = command_label(&command);
582    let mut child: Child = command.spawn_retrying().map_err(SingleFlightError::Start)?;
583    drop(command);
584    let mut waits: Vec<WaitReport> = Vec::new();
585    let ended: Result<ExitStatus, StopCause> = wait_until_done_noting(&mut child, &[&output], patience, label.as_str(), &mut waits)?;
586    if ended.is_err() {
587        stop_push(child, grace, locks);
588    }
589    let captured: String = read_output(&mut output)?;
590    let mut shown_text: String = strip_one_line_break(captured.as_str()).to_owned();
591    // A wait is said once more, in the push's own output (the journal's `pushed` entry and the person's terminal carry it).
592    for report in &waits {
593        let line: String = wait_line(label.as_str(), report);
594        if !shown_text.is_empty() {
595            shown_text.push('\n');
596        }
597        shown_text.push_str(line.as_str());
598    }
599    let shown: &str = shown_text.as_str();
600    return Ok(match ended {
601        Ok(status) => PushAttempt {
602            exit_code: i64::from(status.code().unwrap_or(1)),
603            output: shown.to_owned(),
604        },
605        Err(cause) => {
606            let reason: String = match cause {
607                StopCause::Deadline => format!(
608                    "cli-git: git push did not finish within {} seconds and was stopped (limit=deadline); \
609                     {}. {PUSH_QUEUED_LINE} Hint: if you were pushing over a slow or \
610                     offline connection, your commit is already saved; push again when the connection works.",
611                    match patience {
612                        Patience::Fixed(limit) => limit.as_secs(),
613                        Patience::Progress { stall } => stall.as_secs(),
614                    },
615                    extend_clause()
616                ),
617                StopCause::Stalled(report) => format!(
618                    "cli-git: git push made no progress for {} seconds and was stopped (limit=stall, bytes_moved={}, \
619                     elapsed_seconds={}); {}. {PUSH_QUEUED_LINE} Hint: if you were pushing over a slow or \
620                     offline connection, your commit is already saved; push again when the connection works.",
621                    report.limit.as_secs(),
622                    report.moved,
623                    report.elapsed.as_secs(),
624                    extend_clause()
625                ),
626                StopCause::Flood => format!(
627                    "cli-git: git push wrote more than {MAX_PUSH_OUTPUT} bytes of output and was stopped. Hint: if a hook or the \
628                     remote prints this much, run `git ls-remote` to see whether your commits arrived, and read the \
629                     hook's own output by running it directly before pushing again."
630                ),
631            };
632            debug(TAG, &reason);
633            let joined: String = if shown.is_empty() {
634                reason
635            } else {
636                format!("{shown}\n{reason}")
637            };
638            PushAttempt {
639                exit_code: STOPPED_EXIT_CODE,
640                output: joined,
641            }
642        }
643    });
644}
645
646/// Bounded push behavior is controlled in the test module.
647#[cfg(test)]
648#[path = "bounded_push_tests.rs"]
649mod tests;