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;
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;