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