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/// Private exclusive files for standard input.
25use super::private_storage::write_private_file;
26/// Unique temporary names.
27use super::random_id::random_uuid;
28/// `OsStr`/`OsString` are borrowed/owned operating-system text of raw bytes.
29use std::ffi::{OsStr, OsString};
30/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
31use std::path::{Path, PathBuf};
32/// `Command` builds a child process;
33///  `Stdio` configures its streams.
34use std::process::{Command, Stdio};
35
36/// What:
37///  The real Git executable and the variables every Git child receives.
38/// Why:
39///   Transaction code is handed this once and starts every Git through it.
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// type GitContext = { gitPath: string; overlay: [string, string][] };
44/// ```
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct GitContext {
47    /// Absolute real Git executable.
48    pub real_git: PathBuf,
49    /// Variables added to every child:
50    ///  lock PID injection and the forward-target marker.
51    pub overlay: Vec<(OsString, OsString)>,
52    /// Global options of the invocation,
53    ///  kept before every subcommand (`-C`,
54    ///  `--git-dir`,
55    ///  `-c`).
56    pub global_prefix: Vec<OsString>,
57}
58
59/// What:
60///  One Git start:
61///  where it runs,
62///  its arguments after the global prefix,
63///  and its
64///       environment and input adjustments.
65/// Why:
66///   A builder keeps call sites short while every option stays explicit.
67///
68/// In TS you'd write (pseudocode):
69/// ```ts
70/// type GitRequest = { cwd: string; args: string[]; indexPath?: string; objectDirectory?: string; environment?: Record<string, string>; unset?: string[]; input?: Uint8Array };
71/// ```
72#[derive(Clone, Debug, Default)]
73pub struct GitRequest {
74    /// Working directory.
75    pub cwd: PathBuf,
76    /// Arguments after the global prefix.
77    pub arguments: Vec<OsString>,
78    /// Whether the invocation's global prefix is left out (shadow and private-store commands).
79    pub without_prefix: bool,
80    /// Private index,
81    ///  as `GIT_INDEX_FILE`.
82    pub index: Option<PathBuf>,
83    /// Object store receiving every written object,
84    ///  as `GIT_OBJECT_DIRECTORY`.
85    pub object_directory: Option<PathBuf>,
86    /// Variables added after the overlay.
87    pub environment: Vec<(OsString, OsString)>,
88    /// Inherited variables removed before the additions apply.
89    pub unset: Vec<OsString>,
90    /// Bytes on standard input;
91    ///  none means an empty input.
92    pub input: Option<Vec<u8>>,
93}
94
95/// Builder methods for one request.
96impl GitRequest {
97    /// What:
98    ///  A request running `arguments` in `cwd`.
99    /// Why:
100    ///   Most requests need nothing else.
101    ///
102    /// In TS you'd write (pseudocode):
103    /// ```ts
104    /// { cwd, args }
105    /// ```
106    pub fn new<S: AsRef<OsStr>>(cwd: &Path, arguments: &[S]) -> Self {
107        // `mut` allows collecting the arguments one at a time.
108        let mut owned: Vec<OsString> = Vec::with_capacity(arguments.len());
109        for argument in arguments {
110            owned.push(argument.as_ref().to_os_string());
111        }
112        return Self {
113            cwd: cwd.to_path_buf(),
114            arguments: owned,
115            ..Self::default()
116        };
117    }
118}
119
120/// What:
121///  How one Git start ended and what it wrote.
122/// Why:
123///   Callers decide on the exit code and parse standard output as bytes.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// type GitOutput = { stdout: Uint8Array; stderr: string; exitCode: number };
128/// ```
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct GitOutput {
131    /// Exit code;
132    ///  `None` when a signal ended Git.
133    pub code: Option<i32>,
134    /// Standard output bytes.
135    pub stdout: Vec<u8>,
136    /// Standard error bytes.
137    pub stderr: Vec<u8>,
138}
139
140/// Methods over a finished Git start.
141impl GitOutput {
142    /// What:
143    ///  Whether Git exited with status 0.
144    /// Why:
145    ///   Most callers only need success or failure.
146    ///
147    /// In TS you'd write (pseudocode):
148    /// ```ts
149    /// output.exitCode === 0
150    /// ```
151    pub fn succeeded(&self) -> bool {
152        return self.code == Some(0);
153    }
154
155    /// What:
156    ///  Standard error as trimmed,
157    ///  lossily decoded text for diagnostics.
158    /// Why:
159    ///   Failure messages quote what Git said.
160    ///
161    /// In TS you'd write (pseudocode):
162    /// ```ts
163    /// output.stderr.trim()
164    /// ```
165    pub fn error_text(&self) -> String {
166        return String::from_utf8_lossy(self.stderr.as_slice())
167            .trim()
168            .to_string();
169    }
170}
171
172/// What:
173///  Why a Git start failed.
174/// Why:
175///   A start that could not happen and a Git that exited unsuccessfully are reported with
176///       the command named,
177///  as `CommitTransactionGitError`.
178///
179/// In TS you'd write (pseudocode):
180/// ```ts
181/// class CommitTransactionGitError extends Error {}
182/// ```
183#[derive(Clone, Debug, Eq, PartialEq)]
184pub struct GitFailure(pub String);
185
186/// What:
187///  The failure message as display text.
188/// Why:
189///   Diagnostics print it unchanged.
190///
191/// In TS you'd write (pseudocode):
192/// ```ts
193/// error.message
194/// ```
195impl std::fmt::Display for GitFailure {
196    /// Writes the message.
197    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        return formatter.write_str(self.0.as_str());
199    }
200}
201
202/// What:
203///  The arguments of a request as display text,
204///  `git <arguments>`.
205/// Why:
206///   Failure messages name the command.
207///
208/// In TS you'd write (pseudocode):
209/// ```ts
210/// `git ${args.join(' ')}`
211/// ```
212pub fn command_text(arguments: &[OsString]) -> String {
213    let mut text: String = String::from("git");
214    for argument in arguments {
215        text.push(' ');
216        text.push_str(argument.to_string_lossy().as_ref());
217    }
218    return text;
219}
220
221/// What:
222///  An already unlinked private file holding `bytes`,
223///  opened for reading.
224/// Why:
225///   Git may write output before it reads all its input;
226///  feeding input from a file instead
227///       of a pipe can never deadlock and needs no writer thread.
228///  The name is removed at once,
229///       so nothing is left behind even after a crash.
230///
231/// In TS you'd write (pseudocode):
232/// ```ts
233/// // child.stdin.end(input);
234/// ```
235fn input_file(bytes: &[u8]) -> std::io::Result<std::fs::File> {
236    let unique: String = match random_uuid() {
237        Ok(id) => id,
238        Err(error) => return Err(std::io::Error::other(error.0)),
239    };
240    let path: PathBuf = std::env::temp_dir().join(format!("cli-git-input-{unique}"));
241    write_private_file(path.as_path(), bytes)?;
242    let file: std::io::Result<std::fs::File> = std::fs::File::open(&path);
243    // The open handle keeps the bytes readable after the name is gone. If the platform refuses
244    // to remove an open file, the private file stays behind and the error is ignored.
245    let _ = std::fs::remove_file(&path);
246    let opened: std::fs::File = file?;
247    return Ok(opened);
248}
249
250/// What:
251///  Build the child command for one request.
252/// Why:
253///   The prefix,
254///  the overlay,
255///  the removals and the additions apply in a fixed order.
256///
257/// In TS you'd write (pseudocode):
258/// ```ts
259/// spawn(gitPath, args, { cwd, env: { ...withoutUnset(process.env), ...environment, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY } })
260/// ```
261fn build_command(context: &GitContext, request: &GitRequest) -> Command {
262    // `mut` allows assembling the argument list in order.
263    let mut arguments: Vec<OsString> = Vec::new();
264    if !request.without_prefix {
265        arguments.extend(context.global_prefix.iter().cloned());
266    }
267    arguments.extend(request.arguments.iter().cloned());
268    let mut command: Command = git_command(
269        context.real_git.as_path(),
270        arguments.as_slice(),
271        context.overlay.as_slice(),
272    );
273    command.current_dir(request.cwd.as_path());
274    for name in &request.unset {
275        command.env_remove(name);
276    }
277    for (name, value) in &request.environment {
278        command.env(name, value);
279    }
280    if let Some(index) = &request.index {
281        command.env("GIT_INDEX_FILE", index);
282    }
283    if let Some(objects) = &request.object_directory {
284        command.env("GIT_OBJECT_DIRECTORY", objects);
285    }
286    return command;
287}
288
289/// What:
290///  Run one request and capture its output,
291///  whatever its exit code.
292///       `std::io::Result<GitOutput>` is "the outcome,
293///  or why Git could not start".
294/// Why:
295///   Callers that treat a non-zero exit as an answer (a conflict,
296///  a missing ref) read the
297///       code themselves.
298///
299/// In TS you'd write (pseudocode):
300/// ```ts
301/// await runTransactionGit({ ..., allowFailure: true })
302/// ```
303pub fn run_git(context: &GitContext, request: &GitRequest) -> std::io::Result<GitOutput> {
304    let mut command: Command = build_command(context, request);
305    match &request.input {
306        Some(bytes) => {
307            command.stdin(Stdio::from(input_file(bytes.as_slice())?));
308        }
309        None => {
310            command.stdin(Stdio::null());
311        }
312    }
313    let output: std::process::Output = command.output()?;
314    return Ok(GitOutput {
315        code: output.status.code(),
316        stdout: output.stdout,
317        stderr: output.stderr,
318    });
319}
320
321/// What:
322///  Run one request and require success;
323///  a start failure or a non-zero exit is a
324///       failure naming the command and quoting Git's standard error.
325/// Why:
326///   Most transaction steps cannot continue without Git's answer.
327///
328/// In TS you'd write (pseudocode):
329/// ```ts
330/// await runTransactionGit({ gitPath, cwd, args }) // throws CommitTransactionGitError
331/// ```
332pub fn run_git_checked(
333    context: &GitContext,
334    request: &GitRequest,
335) -> Result<GitOutput, GitFailure> {
336    let output: GitOutput = match run_git(context, request) {
337        Ok(finished) => finished,
338        Err(error) => {
339            return Err(GitFailure(format!(
340                "{} could not start: {error}",
341                command_text(request.arguments.as_slice())
342            )));
343        }
344    };
345    if !output.succeeded() {
346        return Err(GitFailure(format!(
347            "{} failed: {}",
348            command_text(request.arguments.as_slice()),
349            output.error_text()
350        )));
351    }
352    return Ok(output);
353}
354
355/// Real-Git runner controls stay out of the release executable.
356#[cfg(test)]
357#[path = "transaction_git_tests.rs"]
358mod tests;