git_policy_cli/transaction_git.rs
1//! What:
2//! Running real Git for transaction,
3//! recovery and landing work:
4//! captured output,
5//! an
6//! optional private index and object store,
7//! extra or removed variables,
8//! and bytes on
9//! standard input.
10//! Why:
11//! Every transaction step asks Git a question or writes an object;
12//! one runner keeps every
13//! such start an argument array (never a shell string) carrying the wrapper's child
14//! environment overlay (lock PID injection),
15//! as the incumbent's `runTransactionGit` does.
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! const { stdout } = await runTransactionGit({ gitPath, cwd, args, indexPath, input });
20//! ```
21
22/// Building a Git child from an argument array and an overlay.
23use super::forwarding::git_command;
24/// Private exclusive files for standard input.
25use super::private_storage::write_exclusive_scratch;
26/// Starts that ride out a short shortage of processes.
27use super::start_retry::{RetryStart, START_RETRY_BUDGET, retry_refused_start_with};
28/// Unique temporary names.
29use super::random_id::random_uuid;
30/// `OsStr`/`OsString` are borrowed/owned operating-system text of raw bytes.
31use std::ffi::{OsStr, OsString};
32/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
33use std::path::{Path, PathBuf};
34/// `Command` builds a child process;
35/// `Stdio` configures its streams.
36use std::process::{Command, Stdio};
37/// A span of time, for the bounded rerun.
38use std::time::Duration;
39
40/// What:
41/// The real Git executable and the variables every Git child receives.
42/// Why:
43/// Transaction code is handed this once and starts every Git through it.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type GitContext = { gitPath: string; overlay: [string, string][] };
48/// ```
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct GitContext {
51 /// Absolute real Git executable.
52 pub real_git: PathBuf,
53 /// Variables added to every child:
54 /// lock PID injection and the forward-target marker.
55 pub overlay: Vec<(OsString, OsString)>,
56 /// Global options of the invocation,
57 /// kept before every subcommand (`-C`,
58 /// `--git-dir`,
59 /// `-c`).
60 pub global_prefix: Vec<OsString>,
61}
62
63/// What:
64/// One Git start:
65/// where it runs,
66/// its arguments after the global prefix,
67/// and its
68/// environment and input adjustments.
69/// Why:
70/// A builder keeps call sites short while every option stays explicit.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// type GitRequest = { cwd: string; args: string[]; indexPath?: string; objectDirectory?: string; environment?: Record<string, string>; unset?: string[]; input?: Uint8Array };
75/// ```
76#[derive(Clone, Debug, Default)]
77pub struct GitRequest {
78 /// Working directory.
79 pub cwd: PathBuf,
80 /// Arguments after the global prefix.
81 pub arguments: Vec<OsString>,
82 /// Whether the invocation's global prefix is left out (shadow and private-store commands).
83 pub without_prefix: bool,
84 /// Private index,
85 /// as `GIT_INDEX_FILE`.
86 pub index: Option<PathBuf>,
87 /// Object store receiving every written object,
88 /// as `GIT_OBJECT_DIRECTORY`.
89 pub object_directory: Option<PathBuf>,
90 /// Variables added after the overlay.
91 pub environment: Vec<(OsString, OsString)>,
92 /// Inherited variables removed before the additions apply.
93 pub unset: Vec<OsString>,
94 /// Bytes on standard input;
95 /// none means an empty input.
96 pub input: Option<Vec<u8>>,
97}
98
99/// Builder methods for one request.
100impl GitRequest {
101 /// What:
102 /// A request running `arguments` in `cwd`.
103 /// Why:
104 /// Most requests need nothing else.
105 ///
106 /// In TS you'd write (pseudocode):
107 /// ```ts
108 /// { cwd, args }
109 /// ```
110 pub fn new<S: AsRef<OsStr>>(cwd: &Path, arguments: &[S]) -> Self {
111 // `mut` allows collecting the arguments one at a time.
112 let mut owned: Vec<OsString> = Vec::with_capacity(arguments.len());
113 for argument in arguments {
114 owned.push(argument.as_ref().to_os_string());
115 }
116 return Self {
117 cwd: cwd.to_path_buf(),
118 arguments: owned,
119 ..Self::default()
120 };
121 }
122}
123
124/// What:
125/// How one Git start ended and what it wrote.
126/// Why:
127/// Callers decide on the exit code and parse standard output as bytes.
128///
129/// In TS you'd write (pseudocode):
130/// ```ts
131/// type GitOutput = { stdout: Uint8Array; stderr: string; exitCode: number };
132/// ```
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub struct GitOutput {
135 /// Exit code;
136 /// `None` when a signal ended Git.
137 pub code: Option<i32>,
138 /// Standard output bytes.
139 pub stdout: Vec<u8>,
140 /// Standard error bytes.
141 pub stderr: Vec<u8>,
142}
143
144/// Methods over a finished Git start.
145impl GitOutput {
146 /// What:
147 /// Whether Git exited with status 0.
148 /// Why:
149 /// Most callers only need success or failure.
150 ///
151 /// In TS you'd write (pseudocode):
152 /// ```ts
153 /// output.exitCode === 0
154 /// ```
155 pub fn succeeded(&self) -> bool {
156 return self.code == Some(0);
157 }
158
159 /// What:
160 /// Standard error as trimmed,
161 /// lossily decoded text for diagnostics.
162 /// Why:
163 /// Failure messages quote what Git said.
164 ///
165 /// In TS you'd write (pseudocode):
166 /// ```ts
167 /// output.stderr.trim()
168 /// ```
169 pub fn error_text(&self) -> String {
170 return String::from_utf8_lossy(self.stderr.as_slice())
171 .trim()
172 .to_string();
173 }
174}
175
176/// What:
177/// Why a Git start failed.
178/// Why:
179/// A start that could not happen and a Git that exited unsuccessfully are reported with
180/// the command named,
181/// as `CommitTransactionGitError`.
182///
183/// In TS you'd write (pseudocode):
184/// ```ts
185/// class CommitTransactionGitError extends Error {}
186/// ```
187#[derive(Clone, Debug, Eq, PartialEq)]
188pub struct GitFailure(pub String);
189
190/// What:
191/// The failure message as display text.
192/// Why:
193/// Diagnostics print it unchanged.
194///
195/// In TS you'd write (pseudocode):
196/// ```ts
197/// error.message
198/// ```
199impl std::fmt::Display for GitFailure {
200 /// Writes the message.
201 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 return formatter.write_str(self.0.as_str());
203 }
204}
205
206/// What:
207/// The arguments of a request as display text,
208/// `git <arguments>`.
209/// Why:
210/// Failure messages name the command.
211///
212/// In TS you'd write (pseudocode):
213/// ```ts
214/// `git ${args.join(' ')}`
215/// ```
216pub fn command_text(arguments: &[OsString]) -> String {
217 let mut text: String = String::from("git");
218 for argument in arguments {
219 text.push(' ');
220 text.push_str(argument.to_string_lossy().as_ref());
221 }
222 return text;
223}
224
225/// What:
226/// An already unlinked private file holding `bytes`,
227/// opened for reading.
228/// Why:
229/// Git may write output before it reads all its input;
230/// feeding input from a file instead
231/// of a pipe can never deadlock and needs no writer thread.
232/// The name is removed at once,
233/// so nothing is left behind even after a crash.
234///
235/// In TS you'd write (pseudocode):
236/// ```ts
237/// // child.stdin.end(input);
238/// ```
239fn input_file(bytes: &[u8]) -> std::io::Result<std::fs::File> {
240 let unique: String = match random_uuid() {
241 Ok(id) => id,
242 Err(error) => return Err(std::io::Error::other(error.0)),
243 };
244 let path: PathBuf = std::env::temp_dir().join(format!("cli-git-input-{unique}"));
245 write_exclusive_scratch(path.as_path(), bytes)?;
246 let file: std::io::Result<std::fs::File> = std::fs::File::open(&path);
247 // The open handle keeps the bytes readable after the name is gone. If the platform refuses
248 // to remove an open file, the private file stays behind and the error is ignored.
249 let _ = std::fs::remove_file(&path);
250 let opened: std::fs::File = file?;
251 return Ok(opened);
252}
253
254/// What:
255/// Build the child command for one request.
256/// Why:
257/// The prefix,
258/// the overlay,
259/// the removals and the additions apply in a fixed order.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// spawn(gitPath, args, { cwd, env: { ...withoutUnset(process.env), ...environment, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY } })
264/// ```
265fn build_command(context: &GitContext, request: &GitRequest) -> Command {
266 // `mut` allows assembling the argument list in order.
267 let mut arguments: Vec<OsString> = Vec::new();
268 if !request.without_prefix {
269 arguments.extend(context.global_prefix.iter().cloned());
270 }
271 arguments.extend(request.arguments.iter().cloned());
272 let mut command: Command = git_command(
273 context.real_git.as_path(),
274 arguments.as_slice(),
275 context.overlay.as_slice(),
276 );
277 command.current_dir(request.cwd.as_path());
278 for name in &request.unset {
279 command.env_remove(name);
280 }
281 for (name, value) in &request.environment {
282 command.env(name, value);
283 }
284 if let Some(index) = &request.index {
285 command.env("GIT_INDEX_FILE", index);
286 }
287 if let Some(objects) = &request.object_directory {
288 command.env("GIT_OBJECT_DIRECTORY", objects);
289 }
290 return command;
291}
292
293/// What:
294/// Run one request and capture its output,
295/// whatever its exit code.
296/// `std::io::Result<GitOutput>` is "the outcome,
297/// or why Git could not start".
298/// Why:
299/// Callers that treat a non-zero exit as an answer (a conflict,
300/// a missing ref) read the
301/// code themselves.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// await runTransactionGit({ ..., allowFailure: true })
306/// ```
307pub fn run_git(context: &GitContext, request: &GitRequest) -> std::io::Result<GitOutput> {
308 return run_git_pausing(context, request, START_RETRY_BUDGET, &mut |wait: Duration| { std::thread::sleep(wait); });
309}
310
311/// Commands whose second run after a refusal is harmless: they read, or they write content-addressed objects,
312/// or they set the same value again.
313const RERUNNABLE: [&str; 17] = [
314 "rev-parse",
315 "symbolic-ref",
316 "for-each-ref",
317 "ls-files",
318 "diff",
319 "diff-index",
320 "diff-tree",
321 "cat-file",
322 "show",
323 "hash-object",
324 "write-tree",
325 "ls-remote",
326 "merge-tree",
327 "init",
328 "pack-objects",
329 "index-pack",
330 "version",
331];
332
333/// What:
334/// The subcommand a request runs and where it sits among the arguments,
335/// past the global options (`-c <pair>`, `-C <directory>` and the `--option` forms).
336/// Why:
337/// The rerun allow-list is decided by the subcommand and, for a few, by its options.
338///
339/// In TS you'd write (pseudocode):
340/// ```ts
341/// const index = args.findIndex(...); // first token that is not a global option or its value
342/// ```
343fn subcommand_of(arguments: &[OsString]) -> Option<(usize, String)> {
344 let mut index: usize = 0;
345 while index < arguments.len() {
346 let text: String = arguments[index].to_string_lossy().into_owned();
347 if text == "-c" || text == "-C" {
348 index += 2;
349 continue;
350 }
351 if text.starts_with('-') {
352 index += 1;
353 continue;
354 }
355 return Some((index, text));
356 }
357 return None;
358}
359
360/// What:
361/// Whether running the request a second time after the system refused it is harmless.
362/// Why:
363/// Git reports a refused process or thread as a failure after it started,
364/// so only commands that read, write content-addressed objects, or change a private index the same way twice
365/// may be run again;
366/// reference updates, commits and every other writer are not,
367/// and recovery already completes or rolls those back.
368///
369/// In TS you'd write (pseudocode):
370/// ```ts
371/// RERUNNABLE.includes(subcommand) || (subcommand === 'config' && reads(args)) || ...
372/// ```
373fn may_be_rerun(request: &GitRequest) -> bool {
374 let Some((position, name)) = subcommand_of(request.arguments.as_slice()) else {
375 return false;
376 };
377 let rest: Vec<String> = request.arguments[position + 1..]
378 .iter()
379 .map(|argument| { return argument.to_string_lossy().into_owned(); })
380 .collect();
381 match name.as_str() {
382 "config" => return rest.iter().any(|option| {
383 return matches!(option.as_str(), "--get" | "--get-all" | "--get-regexp" | "--list" | "-l");
384 }),
385 "remote" => return rest.first().is_some_and(|word| { return word == "get-url"; }),
386 "add" | "reset" | "read-tree" | "update-index" => return request.index.is_some(),
387 other => return RERUNNABLE.contains(&other),
388 }
389}
390
391/// What:
392/// Whether Git failed because the system would not give it a process or a thread.
393/// Why:
394/// Git words these failures with the system's own text (`unable to create thread: Resource temporarily
395/// unavailable`, `cannot fork() for ...`), and no other failure of a rerunnable command carries it.
396///
397/// In TS you'd write (pseudocode):
398/// ```ts
399/// output.exitCode !== 0 && output.stderr.includes('Resource temporarily unavailable')
400/// ```
401fn refused_by_the_system(output: &GitOutput) -> bool {
402 return output.code != Some(0)
403 && String::from_utf8_lossy(output.stderr.as_slice()).contains("Resource temporarily unavailable");
404}
405
406/// What:
407/// Run one request, running it again within the budget when Git reports a refused process or thread
408/// and the command is harmless to run twice.
409/// After the budget the last output is returned as it was, so the caller reports Git's own words.
410/// Why:
411/// The command is rebuilt for each run, so its input file is fresh.
412/// A start that already waited out its own budget is not waited for again.
413///
414/// In TS you'd write (pseudocode):
415/// ```ts
416/// await retryWhile(() => runGitOnce(request), (output) => refusedBySystem(output) && mayBeRerun(request));
417/// ```
418fn run_git_pausing(
419 context: &GitContext,
420 request: &GitRequest,
421 budget: Duration,
422 pause: &mut dyn FnMut(Duration),
423) -> std::io::Result<GitOutput> {
424 if !may_be_rerun(request) {
425 return run_git_once(context, request);
426 }
427 let what: String = command_text(&request.arguments);
428 let mut refused: Option<GitOutput> = None;
429 let result: std::io::Result<GitOutput> = retry_refused_start_with(what.as_str(), budget, pause, &mut || {
430 let output: GitOutput = match run_git_once(context, request) {
431 Ok(finished) => finished,
432 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Err(std::io::Error::other(error)),
433 Err(error) => return Err(error),
434 };
435 if refused_by_the_system(&output) {
436 refused = Some(output);
437 return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock));
438 }
439 return Ok(output);
440 });
441 match (result, refused.take()) {
442 (Ok(finished), _) => return Ok(finished),
443 (Err(error), Some(last)) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(last),
444 (Err(error), _) => return Err(error),
445 }
446}
447
448/// What:
449/// Run one request once and capture its output, whatever its exit code.
450/// Why:
451/// The command and its input file are built here, so every run starts from the request alone.
452///
453/// In TS you'd write (pseudocode):
454/// ```ts
455/// await runTransactionGitOnce({ gitPath, cwd, args })
456/// ```
457fn run_git_once(context: &GitContext, request: &GitRequest) -> std::io::Result<GitOutput> {
458 let mut command: Command = build_command(context, request);
459 match &request.input {
460 Some(bytes) => {
461 command.stdin(Stdio::from(input_file(bytes.as_slice())?));
462 }
463 None => {
464 command.stdin(Stdio::null());
465 }
466 }
467 let output: std::process::Output = command.output_retrying()?;
468 return Ok(GitOutput {
469 code: output.status.code(),
470 stdout: output.stdout,
471 stderr: output.stderr,
472 });
473}
474
475/// What:
476/// Run one request and require success;
477/// a start failure or a non-zero exit is a
478/// failure naming the command and quoting Git's standard error.
479/// Why:
480/// Most transaction steps cannot continue without Git's answer.
481///
482/// In TS you'd write (pseudocode):
483/// ```ts
484/// await runTransactionGit({ gitPath, cwd, args }) // throws CommitTransactionGitError
485/// ```
486pub fn run_git_checked(
487 context: &GitContext,
488 request: &GitRequest,
489) -> Result<GitOutput, GitFailure> {
490 let output: GitOutput = match run_git(context, request) {
491 Ok(finished) => finished,
492 Err(error) => {
493 return Err(GitFailure(format!(
494 "{} could not start: {error}",
495 command_text(request.arguments.as_slice())
496 )));
497 }
498 };
499 if !output.succeeded() {
500 return Err(GitFailure(format!(
501 "{} failed: {}",
502 command_text(request.arguments.as_slice()),
503 output.error_text()
504 )));
505 }
506 return Ok(output);
507}
508
509/// Real-Git runner controls stay out of the release executable.
510#[cfg(test)]
511#[path = "transaction_git_tests.rs"]
512mod tests;