git_policy_cli/push/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 crate::bounded_push::STOP_GRACE;
14use crate::push_deadline_advice::limit_clause;
15use crate::bounded_query::{QueryOutput, query_window, run_bounded_query};
16use crate::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 showed no sign of work for its quiet window and was stopped.
49 TimedOut {
50 /// Arguments of the query, for the diagnostic.
51 arguments: String,
52 /// The quiet window 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} showed no sign of work for {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 crate::hooks_off::add_arguments(&mut command, arguments);
135 let mut in_place = in_push_environment(command, git);
136 // The push environment is built from nothing, so the marker is added after it: this start says it is Git's too.
137 crate::started_as_git::mark_real_git_start(&mut in_place, crate::started_as_git::StartMode::Child);
138 return in_place;
139}
140
141/// What:
142/// Give `command` the push's working directory and its exact environment.
143/// Why:
144/// Both builders of push Git commands run in the same place with the same environment.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// { ...command, cwd: git.cwd, env: git.environment }
149/// ```
150fn in_push_environment(mut command: Command, git: &PushGit) -> Command {
151 command
152 .current_dir(&git.cwd)
153 .env_clear()
154 .envs(git.environment.iter().map(|pair| {
155 return (&pair.0, &pair.1);
156 }));
157 return command;
158}
159
160/// The arguments as one display string, for diagnostics.
161///
162/// In TS you'd write (pseudocode):
163/// ```ts
164/// args.join(' ')
165/// ```
166fn describe_arguments(arguments: &[OsString]) -> String {
167 return arguments
168 .iter()
169 .map(|argument| {
170 return argument.to_string_lossy().into_owned();
171 })
172 .collect::<Vec<String>>()
173 .join(" ");
174}
175
176/// What:
177/// Run a read-only Git query and return its trimmed standard output.
178/// `Result<A, B>` is either the output or a failure.
179/// Why:
180/// Any failed query is a coordination failure; it is never read as an answer.
181///
182/// In TS you'd write (pseudocode):
183/// ```ts
184/// (await nanoSpawn(gitPath, args, { cwd })).stdout.trim();
185/// ```
186pub fn git_output(git: &PushGit, arguments: &[OsString]) -> Result<String, SingleFlightError> {
187 let output = run_query(git, arguments)?;
188 if output.code != Some(0) {
189 return Err(SingleFlightError::Git {
190 arguments: describe_arguments(arguments),
191 code: output.code,
192 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
193 });
194 }
195 let text = String::from_utf8_lossy(&output.stdout);
196 return Ok(trim_javascript(&text).to_owned());
197}
198
199/// What:
200/// Run a read-only Git query that is ended only when it shows no sign of work, and capture its streams separately.
201/// Why:
202/// A query that never returns would hold the caller, and the service's push behind it, for good.
203///
204/// In TS you'd write (pseudocode):
205/// ```ts
206/// await nanoSpawn(gitPath, args, { cwd, signal: quietFor(windowMs) })
207/// ```
208pub fn run_query(git: &PushGit, arguments: &[OsString]) -> Result<QueryOutput, SingleFlightError> {
209 // An exact read-only command line the in-process readers know costs no Git process.
210 if let Some((code, stdout, stderr)) = crate::repo_read_hook::try_complete(arguments, git.cwd.as_path(), crate::git_system_config::with_real_git(git.environment.as_slice(), git.real_git.as_path()).as_slice()) {
211 return Ok(QueryOutput { code, stdout, stderr });
212 }
213 return run_bounded_query(
214 git_command(git, arguments),
215 describe_arguments(arguments).as_str(),
216 query_window(&git.environment),
217 STOP_GRACE,
218 git.environment.as_slice(),
219 );
220}
221
222/// Query behavior is controlled in the test module.
223#[cfg(test)]
224#[path = "auto_push_single_flight_tests.rs"]
225mod tests;