Skip to main content

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;