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;