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;