Skip to main content

git_policy_cli/push/
bounded_query.rs

1//! What:
2//!  Run one read-only Git query that is ended only when it shows no sign of work, with separate output streams.
3//! Why:
4//!  The auto-push asks Git a handful of questions before it pushes,
5//!  and a question that never returns would hold the service's push for good.
6//!  A query that shows no sign of work for the quiet window is stopped and reported, so the commit stays saved. A query that
7//!  works is never stopped for being slow: no total time bounds it (owner, 2026-11-03: "Stop only on no progress"; the
8//!  coordinator's scope addition to picks 4 and 9 of `doc/planning/cli-git-shipped-limits-audit.md`, open to the owner's
9//!  veto). Before this, a query shared the push's wall-clock stop and the journal showed a `git status` stopped at
10//!  2026-11-03T08:28 with `outcome=retrying push_ms=97398`. The longest quiet gap of every query is kept in the ledger.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const { exitCode, stdout, stderr } = await nanoSpawn(gitPath, args, { cwd, signal: quietFor(windowMs) }); // quiet, not slow
15//! ```
16
17/// The failure type of the auto-push coordination.
18use crate::auto_push_single_flight::SingleFlightError;
19/// The deadline runner shared with the push, and its stop causes.
20use crate::bounded_push::{MAX_PUSH_OUTPUT, Patience, StopCause, push_deadline, stop_push, wait_until_done};
21/// The longest quiet gap of each step, kept for the timing records.
22use crate::quiet_ledger;
23/// The lock files a stopped query leaves, and the ledger that finds them.
24use crate::stopped_git_locks::LockLedger;
25/// Starts that ride out a short shortage of processes.
26/// The anonymous output files of the query, made in the per-user scratch directory.
27use crate::scratch_location::scratch_file;
28use crate::start_retry::RetryStart;
29/// Owned OS strings of the child environment.
30use std::ffi::OsString;
31/// The anonymous output files and their reading back.
32use std::fs::File;
33use std::io::{Read, Seek, SeekFrom};
34/// `Command` describes the query; `Stdio` connects its streams.
35use std::process::{Command, ExitStatus, Stdio};
36/// Time spans.
37use std::time::Duration;
38
39/// What:
40///  What a finished query printed and how it ended.
41/// Why:
42///   Callers read an exit code as an answer ("not an ancestor") or a failure, never both.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// type QueryOutput = { code: number | undefined; stdout: Uint8Array; stderr: Uint8Array };
47/// ```
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct QueryOutput {
50    /// Exit code, or nothing when a signal ended the process.
51    pub code: Option<i32>,
52    /// Complete standard output, up to the output cap.
53    pub stdout: Vec<u8>,
54    /// Complete standard error, up to the output cap.
55    pub stderr: Vec<u8>,
56}
57
58/// What:
59///  The quiet window of a query: the push's stall window.
60/// Why:
61///   One setting bounds how long every Git child of the auto-push may show no sign of work. The cap of 120 seconds a query
62///  used to have is gone with the wall-clock stop it belonged to.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// pushStallMs(env)
67/// ```
68pub fn query_window(environment: &[(OsString, OsString)]) -> Duration {
69    return push_deadline(environment);
70}
71
72/// Read one output file back from its start, up to the output cap.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// await readFile(path)
77/// ```
78fn read_back(file: &mut File) -> Result<Vec<u8>, SingleFlightError> {
79    file.seek(SeekFrom::Start(0))
80        .map_err(SingleFlightError::Start)?;
81    let mut bytes: Vec<u8> = Vec::new();
82    file.take(MAX_PUSH_OUTPUT)
83        .read_to_end(&mut bytes)
84        .map_err(SingleFlightError::Start)?;
85    return Ok(bytes);
86}
87
88/// What:
89///  Run a query command to its end, or until it has shown no sign of work for the quiet window.
90/// Why:
91///   Both streams go to anonymous files, so no reader thread can outlive a stopped query.
92///  A query that was stopped for being quiet is a coordination failure, not an answer.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// async function runBoundedQuery({ command, arguments, windowMs, graceMs }): Promise<QueryOutput>;
97/// ```
98pub fn run_bounded_query(
99    mut command: Command,
100    arguments: &str,
101    window: Duration,
102    grace: Duration,
103    environment: &[(OsString, OsString)],
104) -> Result<QueryOutput, SingleFlightError> {
105    let mut stdout: File = scratch_file(environment).map_err(SingleFlightError::Start)?;
106    let mut stderr: File = scratch_file(environment).map_err(SingleFlightError::Start)?;
107    let for_stdout: File = stdout.try_clone().map_err(SingleFlightError::Start)?;
108    let for_stderr: File = stderr.try_clone().map_err(SingleFlightError::Start)?;
109    command
110        .stdin(Stdio::null())
111        .stdout(Stdio::from(for_stdout))
112        .stderr(Stdio::from(for_stderr));
113    // Taken before the query starts, so a lock born earlier is never taken for the query's.
114    let locks = LockLedger::before_start(&command);
115    let mut child = command.spawn_retrying().map_err(SingleFlightError::Start)?;
116    drop(command);
117    let mut quiet: Option<Duration> = None;
118    let ended: Result<ExitStatus, StopCause> =
119        wait_until_done(&mut child, &[&stdout, &stderr], Patience::Progress { stall: window }, &mut quiet)?;
120    let step: String = format!("git {}", arguments.split_whitespace().next().unwrap_or_default());
121    if let Some(gap) = quiet {
122        quiet_ledger::note(step.as_str(), gap, ended.is_err());
123    }
124    return match ended {
125        Ok(status) => Ok(QueryOutput {
126            code: status.code(),
127            stdout: read_back(&mut stdout)?,
128            stderr: read_back(&mut stderr)?,
129        }),
130        Err(cause) => {
131            stop_push(child, grace, locks);
132            Err(match cause {
133                // A query waits on its signs of work, so only a stall reaches here; a deadline is the same wait.
134                StopCause::Deadline | StopCause::Stalled(_) => SingleFlightError::TimedOut {
135                    arguments: arguments.to_owned(),
136                    seconds: window.as_secs(),
137                },
138                StopCause::Flood => SingleFlightError::Git {
139                    arguments: arguments.to_owned(),
140                    code: None,
141                    stderr: format!(
142                        "the query wrote more than {MAX_PUSH_OUTPUT} bytes and was stopped"
143                    ),
144                },
145            })
146        }
147    };
148}
149
150/// Query windows are controlled in the test module.
151#[cfg(test)]
152#[path = "bounded_query_tests.rs"]
153mod tests;