Skip to main content

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