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