git_policy_cli/auto_push_single_flight.rs
1//! What:
2//! The service's Git for a push: the real Git, its working directory and environment, the command builder, the
3//! deadline-bound read-only query and the failure types of a push.
4//! Why:
5//! The push, its reconcile and the queries before them all start Git the same way, so none re-enters the wrapper
6//! and every Git child receives the same environment.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! const output = await nanoSpawn(gitPath, args, { cwd, env });
11//! ```
12
13use super::bounded_push::STOP_GRACE;
14use super::push_deadline_advice::limit_clause;
15use super::bounded_query::{QueryOutput, query_deadline, run_bounded_query};
16use super::js_text::trim_javascript;
17/// Owned OS strings for Git arguments and the child environment.
18use std::ffi::OsString;
19/// `PathBuf` is an owned filesystem path.
20use std::path::PathBuf;
21/// `Command` starts real Git.
22use std::process::Command;
23
24/// What:
25/// Why a push or one of its Git queries could not finish.
26/// Why:
27/// Every variant is a failure the service reports, and the commit stays saved.
28///
29/// In TS you'd write (pseudocode):
30/// ```ts
31/// SubprocessError | TypeError | NodeJS.ErrnoException
32/// ```
33#[derive(Debug)]
34pub enum SingleFlightError {
35 /// Git could not be started, or failed in a way the incumbent does not model as an exit status.
36 Start(std::io::Error),
37 /// A Git query exited with a status the caller did not expect.
38 Git {
39 /// Arguments of the query, for the diagnostic.
40 arguments: String,
41 /// Exit code, or nothing when a signal ended the process.
42 code: Option<i32>,
43 /// Complete standard error text of the query.
44 stderr: String,
45 },
46 /// A coordination file could not be read or published.
47 Io(std::io::Error),
48 /// A Git query was still running at its deadline and was stopped.
49 TimedOut {
50 /// Arguments of the query, for the diagnostic.
51 arguments: String,
52 /// The deadline it exceeded, in whole seconds.
53 seconds: u64,
54 },
55}
56
57impl std::fmt::Display for SingleFlightError {
58 /// Writes the diagnostic text of the failure.
59 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 return match self {
61 Self::Start(error) => write!(formatter, "git could not be started: {error}"),
62 Self::Git {
63 arguments,
64 code,
65 stderr,
66 } => write!(
67 formatter,
68 "git {arguments} exited with {code:?}: {}",
69 stderr.trim_end()
70 ),
71 Self::Io(error) => write!(formatter, "{error}"),
72 Self::TimedOut { arguments, seconds } => write!(
73 formatter,
74 "git {arguments} did not finish within {seconds} seconds and was stopped; {}",
75 limit_clause()
76 ),
77 };
78 }
79}
80
81impl std::error::Error for SingleFlightError {}
82
83/// What:
84/// One push this invocation ran itself.
85/// Why:
86/// `output` is Git's interleaved output with one trailing line break removed, as the incumbent's
87/// `nano-spawn` `output` is; a successful push's output is never recorded.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// type PushAttempt = { exitCode: number; output: string };
92/// ```
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct PushAttempt {
95 /// Push exit code, `0` on success.
96 pub exit_code: i64,
97 /// Complete interleaved push output.
98 pub output: String,
99}
100
101/// What:
102/// The real Git the service runs, where, and with which environment.
103/// Why:
104/// The service passes the complete environment in (the invocation's variables with the overlay applied),
105/// and nothing is read from the process environment here.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// type PushGit = { gitPath: string; cwd: string; environment: NodeJS.ProcessEnv };
110/// ```
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct PushGit {
113 /// Absolute real Git executable.
114 pub real_git: PathBuf,
115 /// Directory the commit landed in.
116 pub cwd: PathBuf,
117 /// Complete environment every Git child receives: the invocation's variables with the overlay applied.
118 pub environment: Vec<(OsString, OsString)>,
119 /// Whether the pushes of this coordinator must upload LFS objects first (`lfs_bounded_push`); queries and fetches never do.
120 pub lfs: bool,
121}
122
123/// What:
124/// Build a real Git command with the given arguments, directory and environment.
125/// Why:
126/// Every coordination query and push starts Git this way, so none re-enters the wrapper.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// nanoSpawn(gitPath, args, { cwd })
131/// ```
132pub fn git_command(git: &PushGit, arguments: &[OsString]) -> Command {
133 let mut command = Command::new(&git.real_git);
134 super::hooks_off::add_arguments(&mut command, arguments);
135 return in_push_environment(command, git);
136}
137
138/// What:
139/// Give `command` the push's working directory and its exact environment.
140/// Why:
141/// Both builders of push Git commands run in the same place with the same environment.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// { ...command, cwd: git.cwd, env: git.environment }
146/// ```
147fn in_push_environment(mut command: Command, git: &PushGit) -> Command {
148 command
149 .current_dir(&git.cwd)
150 .env_clear()
151 .envs(git.environment.iter().map(|pair| {
152 return (&pair.0, &pair.1);
153 }));
154 return command;
155}
156
157/// The arguments as one display string, for diagnostics.
158///
159/// In TS you'd write (pseudocode):
160/// ```ts
161/// args.join(' ')
162/// ```
163fn describe_arguments(arguments: &[OsString]) -> String {
164 return arguments
165 .iter()
166 .map(|argument| {
167 return argument.to_string_lossy().into_owned();
168 })
169 .collect::<Vec<String>>()
170 .join(" ");
171}
172
173/// What:
174/// Run a read-only Git query and return its trimmed standard output.
175/// `Result<A, B>` is either the output or a failure.
176/// Why:
177/// Any failed query is a coordination failure; it is never read as an answer.
178///
179/// In TS you'd write (pseudocode):
180/// ```ts
181/// (await nanoSpawn(gitPath, args, { cwd })).stdout.trim();
182/// ```
183pub fn git_output(git: &PushGit, arguments: &[OsString]) -> Result<String, SingleFlightError> {
184 let output = run_query(git, arguments)?;
185 if output.code != Some(0) {
186 return Err(SingleFlightError::Git {
187 arguments: describe_arguments(arguments),
188 code: output.code,
189 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
190 });
191 }
192 let text = String::from_utf8_lossy(&output.stdout);
193 return Ok(trim_javascript(&text).to_owned());
194}
195
196/// What:
197/// Run a read-only Git query with its own deadline and capture its streams separately.
198/// Why:
199/// A query that never returns would hold the caller, and the service's push behind it, for good.
200///
201/// In TS you'd write (pseudocode):
202/// ```ts
203/// await nanoSpawn(gitPath, args, { cwd, signal: AbortSignal.timeout(deadlineMs) })
204/// ```
205pub fn run_query(git: &PushGit, arguments: &[OsString]) -> Result<QueryOutput, SingleFlightError> {
206 // An exact read-only command line the in-process readers know costs no Git process.
207 if let Some((code, stdout, stderr)) = super::repo_read_hook::try_complete(arguments, git.cwd.as_path(), super::git_system_config::with_real_git(git.environment.as_slice(), git.real_git.as_path()).as_slice()) {
208 return Ok(QueryOutput { code, stdout, stderr });
209 }
210 return run_bounded_query(
211 git_command(git, arguments),
212 describe_arguments(arguments).as_str(),
213 query_deadline(&git.environment),
214 STOP_GRACE,
215 git.environment.as_slice(),
216 );
217}
218
219/// Query behavior is controlled in the test module.
220#[cfg(test)]
221#[path = "auto_push_single_flight_tests.rs"]
222mod tests;