Skip to main content

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;