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