git_policy_cli/transaction/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 crate::forwarding::git_command;
24use crate::repo_read_hook::try_request;
25/// The watched run that announces a step that takes long and stops it at the bound.
26use crate::child_class::{ChildClass, class_of};
27use crate::child_watch::{StepBound, WatchTerms, output_watched_within};
28use crate::wait_ceilings::{OBJECT_STEP_CEILING, PLUMBING_STEP_CEILING};
29/// Private exclusive files for standard input.
30use crate::private_storage::write_exclusive_scratch;
31/// The repository scratch directory the input file is made in.
32use crate::scratch_location::prepare_repository_scratch;
33/// Starts that ride out a short shortage of processes.
34use crate::start_retry::{START_RETRY_BUDGET, retry_refused_start_with};
35/// Unique temporary names.
36use crate::random_id::random_uuid;
37/// `OsStr`/`OsString` are borrowed/owned operating-system text of raw bytes.
38use std::ffi::{OsStr, OsString};
39/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
40use std::path::{Path, PathBuf};
41/// `Command` builds a child process;
42/// `Stdio` configures its streams.
43use std::io::Write;
44use std::process::{Command, Stdio};
45/// A span of time, for the bounded rerun.
46use std::time::Duration;
47
48/// What:
49/// The real Git executable and the variables every Git child receives.
50/// Why:
51/// Transaction code is handed this once and starts every Git through it.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// type GitContext = { gitPath: string; overlay: [string, string][] };
56/// ```
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub struct GitContext {
59 /// Absolute real Git executable.
60 pub real_git: PathBuf,
61 /// Variables added to every child:
62 /// lock PID injection and the forward-target marker.
63 pub overlay: Vec<(OsString, OsString)>,
64 /// Global options of the invocation,
65 /// kept before every subcommand (`-C`,
66 /// `--git-dir`,
67 /// `-c`).
68 pub global_prefix: Vec<OsString>,
69 /// The repository's common Git directory,
70 /// where the input file of a request that carries input is made (`cli-git-scratch` inside it);
71 /// `None` where the repository is not yet known,
72 /// and a request with input then fails naming the defect.
73 pub common_dir: Option<PathBuf>,
74}
75
76/// What:
77/// One Git start:
78/// where it runs,
79/// its arguments after the global prefix,
80/// and its
81/// environment and input adjustments.
82/// Why:
83/// A builder keeps call sites short while every option stays explicit.
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// type GitRequest = { cwd: string; args: string[]; indexPath?: string; objectDirectory?: string; environment?: Record<string, string>; unset?: string[]; input?: Uint8Array };
88/// ```
89#[derive(Clone, Debug, Default)]
90pub struct GitRequest {
91 /// Working directory.
92 pub cwd: PathBuf,
93 /// Arguments after the global prefix.
94 pub arguments: Vec<OsString>,
95 /// Whether the invocation's global prefix is left out (shadow and private-store commands).
96 pub without_prefix: bool,
97 /// Private index,
98 /// as `GIT_INDEX_FILE`.
99 pub index: Option<PathBuf>,
100 /// Object store receiving every written object,
101 /// as `GIT_OBJECT_DIRECTORY`.
102 pub object_directory: Option<PathBuf>,
103 /// Variables added after the overlay.
104 pub environment: Vec<(OsString, OsString)>,
105 /// Inherited variables removed before the additions apply.
106 pub unset: Vec<OsString>,
107 /// Bytes on standard input;
108 /// none means an empty input.
109 pub input: Option<Vec<u8>>,
110}
111
112/// Builder methods for one request.
113impl GitRequest {
114 /// What:
115 /// A request running `arguments` in `cwd`.
116 /// Why:
117 /// Most requests need nothing else.
118 ///
119 /// In TS you'd write (pseudocode):
120 /// ```ts
121 /// { cwd, args }
122 /// ```
123 pub fn new<S: AsRef<OsStr>>(cwd: &Path, arguments: &[S]) -> Self {
124 // `mut` allows collecting the arguments one at a time.
125 let mut owned: Vec<OsString> = Vec::with_capacity(arguments.len());
126 for argument in arguments {
127 owned.push(argument.as_ref().to_os_string());
128 }
129 return Self {
130 cwd: cwd.to_path_buf(),
131 arguments: owned,
132 ..Self::default()
133 };
134 }
135}
136
137/// What:
138/// How one Git start ended and what it wrote.
139/// Why:
140/// Callers decide on the exit code and parse standard output as bytes.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// type GitOutput = { stdout: Uint8Array; stderr: string; exitCode: number };
145/// ```
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct GitOutput {
148 /// Exit code;
149 /// `None` when a signal ended Git.
150 pub code: Option<i32>,
151 /// Standard output bytes.
152 pub stdout: Vec<u8>,
153 /// Standard error bytes.
154 pub stderr: Vec<u8>,
155}
156
157/// Methods over a finished Git start.
158impl GitOutput {
159 /// What:
160 /// Whether Git exited with status 0.
161 /// Why:
162 /// Most callers only need success or failure.
163 ///
164 /// In TS you'd write (pseudocode):
165 /// ```ts
166 /// output.exitCode === 0
167 /// ```
168 pub fn succeeded(&self) -> bool {
169 return self.code == Some(0);
170 }
171
172 /// What:
173 /// Standard error as trimmed,
174 /// lossily decoded text for diagnostics.
175 /// Why:
176 /// Failure messages quote what Git said.
177 ///
178 /// In TS you'd write (pseudocode):
179 /// ```ts
180 /// output.stderr.trim()
181 /// ```
182 pub fn error_text(&self) -> String {
183 return String::from_utf8_lossy(self.stderr.as_slice())
184 .trim()
185 .to_string();
186 }
187}
188
189/// What:
190/// Why a Git start failed.
191/// Why:
192/// A start that could not happen and a Git that exited unsuccessfully are reported with
193/// the command named,
194/// as `CommitTransactionGitError`.
195///
196/// In TS you'd write (pseudocode):
197/// ```ts
198/// class CommitTransactionGitError extends Error {}
199/// ```
200#[derive(Clone, Debug, Eq, PartialEq)]
201pub struct GitFailure(pub String);
202
203/// What:
204/// The failure message as display text.
205/// Why:
206/// Diagnostics print it unchanged.
207///
208/// In TS you'd write (pseudocode):
209/// ```ts
210/// error.message
211/// ```
212impl std::fmt::Display for GitFailure {
213 /// Writes the message.
214 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 return formatter.write_str(self.0.as_str());
216 }
217}
218
219/// What:
220/// The arguments of a request as display text,
221/// `git <arguments>`.
222/// Why:
223/// Failure messages name the command.
224///
225/// In TS you'd write (pseudocode):
226/// ```ts
227/// `git ${args.join(' ')}`
228/// ```
229pub fn command_text(arguments: &[OsString]) -> String {
230 let mut text: String = String::from("git");
231 for argument in arguments {
232 text.push(' ');
233 text.push_str(argument.to_string_lossy().as_ref());
234 }
235 return text;
236}
237
238/// What:
239/// An already unlinked private file holding `bytes`,
240/// opened for reading.
241/// The file is made in the repository scratch directory of `common_dir`,
242/// never in the system temporary directory.
243/// Why:
244/// Git may write output before it reads all its input;
245/// feeding input from a file instead
246/// of a pipe can never deadlock and needs no writer thread.
247/// The name is removed at once,
248/// so nothing is left behind even after a crash,
249/// and a crash inside that moment leaves a file that the next preparation of the directory
250/// sweeps once it is a day old.
251///
252/// In TS you'd write (pseudocode):
253/// ```ts
254/// // child.stdin.end(input);
255/// ```
256fn input_file(bytes: &[u8], common_dir: Option<&Path>) -> std::io::Result<std::fs::File> {
257 let Some(common) = common_dir else {
258 return Err(std::io::Error::other(
259 "cli-git has no repository scratch directory for the input of this Git command: the command was started \
260 before the repository was known, which is a defect in cli-git",
261 ));
262 };
263 let directory: PathBuf = prepare_repository_scratch(common)?;
264 let unique: String = match random_uuid() {
265 Ok(id) => id,
266 Err(error) => return Err(std::io::Error::other(error.0)),
267 };
268 let path: PathBuf = directory.join(format!("cli-git-input-{unique}"));
269 write_exclusive_scratch(path.as_path(), bytes)?;
270 let file: std::io::Result<std::fs::File> = std::fs::File::open(&path);
271 // The open handle keeps the bytes readable after the name is gone. If the platform refuses
272 // to remove an open file, the private file stays behind and the error is ignored.
273 let _ = std::fs::remove_file(&path);
274 let opened: std::fs::File = file?;
275 return Ok(opened);
276}
277
278/// What:
279/// Build the child command for one request.
280/// Why:
281/// The prefix,
282/// the overlay,
283/// the removals and the additions apply in a fixed order.
284///
285/// In TS you'd write (pseudocode):
286/// ```ts
287/// spawn(gitPath, args, { cwd, env: { ...withoutUnset(process.env), ...environment, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY } })
288/// ```
289fn build_command(context: &GitContext, request: &GitRequest) -> Command {
290 // `mut` allows assembling the argument list in order.
291 let mut arguments: Vec<OsString> = Vec::new();
292 if !request.without_prefix {
293 arguments.extend(context.global_prefix.iter().cloned());
294 }
295 arguments.extend(request.arguments.iter().cloned());
296 let mut command: Command = git_command(
297 context.real_git.as_path(),
298 arguments.as_slice(),
299 context.overlay.as_slice(),
300 );
301 command.current_dir(request.cwd.as_path());
302 for name in &request.unset {
303 command.env_remove(name);
304 }
305 for (name, value) in &request.environment {
306 command.env(name, value);
307 }
308 if let Some(index) = &request.index {
309 command.env("GIT_INDEX_FILE", index);
310 }
311 if let Some(objects) = &request.object_directory {
312 command.env("GIT_OBJECT_DIRECTORY", objects);
313 }
314 return command;
315}
316
317/// What:
318/// Run one request and capture its output,
319/// whatever its exit code.
320/// `std::io::Result<GitOutput>` is "the outcome,
321/// or why Git could not start".
322/// Why:
323/// Callers that treat a non-zero exit as an answer (a conflict,
324/// a missing ref) read the
325/// code themselves.
326///
327/// In TS you'd write (pseudocode):
328/// ```ts
329/// await runTransactionGit({ ..., allowFailure: true })
330/// ```
331pub fn run_git(context: &GitContext, request: &GitRequest) -> std::io::Result<GitOutput> {
332 // An exact read-only command line the in-process readers know costs no Git process.
333 if let Some(answered) = try_request(context, request) {
334 return Ok(answered);
335 }
336 let label: String = crate::add_timing::git_step_label(request.arguments.as_slice());
337 return crate::add_timing::step(label.as_str(), || { return run_git_pausing(context, request, START_RETRY_BUDGET, &mut |wait: Duration| { std::thread::sleep(wait); }); });
338}
339
340/// Commands whose second run after a refusal is harmless: they read, or they write content-addressed objects,
341/// or they set the same value again. `symbolic-ref` and `init` are not here: only the forms the wrapper runs may be run again
342/// (`may_be_rerun`).
343const RERUNNABLE: [&str; 15] = [
344 "rev-parse",
345 "for-each-ref",
346 "ls-files",
347 "diff",
348 "diff-index",
349 "diff-tree",
350 "cat-file",
351 "show",
352 "hash-object",
353 "write-tree",
354 "ls-remote",
355 "merge-tree",
356 "pack-objects",
357 "index-pack",
358 "version",
359];
360
361/// What:
362/// The subcommand a request runs and where it sits among the arguments,
363/// past the global options (`-c <pair>`, `-C <directory>` and the `--option` forms).
364/// Why:
365/// The rerun allow-list is decided by the subcommand and, for a few, by its options.
366///
367/// In TS you'd write (pseudocode):
368/// ```ts
369/// const index = args.findIndex(...); // first token that is not a global option or its value
370/// ```
371pub(crate) fn subcommand_of(arguments: &[OsString]) -> Option<(usize, String)> {
372 let mut index: usize = 0;
373 while index < arguments.len() {
374 let text: String = arguments[index].to_string_lossy().into_owned();
375 if text == "-c" || text == "-C" {
376 index += 2;
377 continue;
378 }
379 if text.starts_with('-') {
380 index += 1;
381 continue;
382 }
383 return Some((index, text));
384 }
385 return None;
386}
387
388/// Whether `words` are the one `init` the wrapper runs (`shadow_create.rs`): `--bare --template= --object-format=<format>
389/// --ref-format=reftable <path>`, and nothing else, so no `--separate-git-dir`, template or relocation can stand in it.
390fn is_shadow_creation(words: &[String]) -> bool {
391 return match words {
392 [bare, template, format, refs, path] => {
393 bare == "--bare"
394 && template == "--template="
395 && format.strip_prefix("--object-format=").is_some_and(|name| { return matches!(name, "sha1" | "sha256"); })
396 && refs == "--ref-format=reftable"
397 && !path.starts_with('-')
398 }
399 _ => false,
400 };
401}
402
403/// What:
404/// Whether a `symbolic-ref` is a form the wrapper runs that may be run again: a read (`--quiet`, `--short`, `--no-recurse`, one name),
405/// or, in a shadow repository the wrapper made for one transaction, the write of one name to one target without any option.
406/// Why:
407/// The write forms the wrapper runs are in `shadow_refs.rs` and only on a shadow (`without_prefix`); a write in the person's
408/// repository, a delete and a message (it appends a reflog entry) are branch writes or have effects the experiment did not run.
409///
410/// In TS you'd write (pseudocode):
411/// ```ts
412/// reads(words) || (shadow && words.options.length === 0 && words.names.length === 2)
413/// ```
414fn symbolic_ref_may_be_rerun(words: &Words<'_>, shadow: bool) -> bool {
415 if words.after.is_some() {
416 return false;
417 }
418 let read: bool = words.only_options(&["--quiet", "-q", "--short", "--no-recurse"]) && words.before.len() == 1;
419 let write_in_shadow: bool = shadow && words.options.is_empty() && words.before.len() == 2;
420 return read || write_in_shadow;
421}
422
423/// What:
424/// Whether running the request a second time after the system refused it is harmless.
425/// Why:
426/// Git reports a refused process or thread as a failure after it started,
427/// so only commands that read, write content-addressed objects, or change a private index the same way twice
428/// may be run again;
429/// reference updates, commits and every other writer are not,
430/// and recovery already completes or rolls those back.
431///
432/// In TS you'd write (pseudocode):
433/// ```ts
434/// RERUNNABLE.includes(subcommand) || (subcommand === 'config' && reads(args)) || ...
435/// ```
436fn may_be_rerun(request: &GitRequest) -> bool {
437 let Some((position, name)) = subcommand_of(request.arguments.as_slice()) else {
438 return false;
439 };
440 let rest: Vec<String> = request.arguments[position + 1..]
441 .iter()
442 .map(|argument| { return argument.to_string_lossy().into_owned(); })
443 .collect();
444 match name.as_str() {
445 "config" => return rest.iter().any(|option| {
446 return matches!(option.as_str(), "--get" | "--get-all" | "--get-regexp" | "--list" | "-l");
447 }),
448 "remote" => return rest.first().is_some_and(|word| { return word == "get-url"; }),
449 "add" | "reset" | "read-tree" | "update-index" => return request.index.is_some(),
450 "symbolic-ref" => return symbolic_ref_may_be_rerun(&Words::of(rest.as_slice()), request.without_prefix),
451 "init" => return request.without_prefix && is_shadow_creation(&rest),
452 other => return RERUNNABLE.contains(&other),
453 }
454}
455
456/// What:
457/// Whether Git failed because the system would not give it a process or a thread.
458/// Why:
459/// Git words these failures with the system's own text (`unable to create thread: Resource temporarily
460/// unavailable`, `cannot fork() for ...`), and no other failure of a rerunnable command carries it.
461///
462/// In TS you'd write (pseudocode):
463/// ```ts
464/// output.exitCode !== 0 && output.stderr.includes('Resource temporarily unavailable')
465/// ```
466fn refused_by_the_system(output: &GitOutput) -> bool {
467 return output.code != Some(0)
468 && String::from_utf8_lossy(output.stderr.as_slice()).contains("Resource temporarily unavailable");
469}
470
471/// What:
472/// Run one request, running it again within the budget when Git reports a refused process or thread
473/// and the command is harmless to run twice.
474/// After the budget the last output is returned as it was, so the caller reports Git's own words.
475/// Why:
476/// The command is rebuilt for each run, so its input file is fresh.
477/// A start that already waited out its own budget is not waited for again.
478///
479/// In TS you'd write (pseudocode):
480/// ```ts
481/// await retryWhile(() => runGitOnce(request), (output) => refusedBySystem(output) && mayBeRerun(request));
482/// ```
483fn run_git_pausing(
484 context: &GitContext,
485 request: &GitRequest,
486 budget: Duration,
487 pause: &mut dyn FnMut(Duration),
488) -> std::io::Result<GitOutput> {
489 if !may_be_rerun(request) {
490 return run_git_once(context, request);
491 }
492 let what: String = command_text(&request.arguments);
493 let mut refused: Option<GitOutput> = None;
494 let result: std::io::Result<GitOutput> = retry_refused_start_with(what.as_str(), budget, pause, &mut || {
495 let output: GitOutput = match run_git_once(context, request) {
496 Ok(finished) => finished,
497 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Err(std::io::Error::other(error)),
498 Err(error) => return Err(error),
499 };
500 if refused_by_the_system(&output) {
501 refused = Some(output);
502 return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock));
503 }
504 return Ok(output);
505 });
506 match (result, refused.take()) {
507 (Ok(finished), _) => return Ok(finished),
508 (Err(error), Some(last)) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(last),
509 (Err(error), _) => return Err(error),
510 }
511}
512
513/// What:
514/// Run one request once and capture its output, whatever its exit code.
515/// Why:
516/// The command and its input file are built here, so every run starts from the request alone.
517///
518/// In TS you'd write (pseudocode):
519/// ```ts
520/// await runTransactionGitOnce({ gitPath, cwd, args })
521/// ```
522fn run_git_once(context: &GitContext, request: &GitRequest) -> std::io::Result<GitOutput> {
523 return run_git_once_with(context, request, &|| { return terms_for_request(request); });
524}
525
526/// What:
527/// The watch terms of a request: those of the class its subcommand belongs to.
528/// Why:
529/// A query step and a bulk step are stopped at different bounds, so the choice is one function a test can pin.
530///
531/// In TS you'd write (pseudocode):
532/// ```ts
533/// termsForClass(classOf(request.args))
534/// ```
535pub(crate) fn terms_for_request(request: &GitRequest) -> WatchTerms {
536 return WatchTerms::for_class(class_of(request.arguments.as_slice()));
537}
538
539/// What:
540/// `run_git_once` with the notice cadence, the quiet window and the notice sink of its wait made by `terms`, which is asked
541/// for again at each start.
542/// A step that shows no sign of work for the window is ended and run once more; a step that may not be run twice is only
543/// waited for.
544/// Why:
545/// The production entry reads the terms from the environment;
546/// tests choose short ones and read the notices from a buffer.
547/// The owner's rule: "End Git, hold hooks (Recommended)": a Git child with no progress is ended and its step redone, and a
548/// person's hook is never ended (none runs below a step the wrapper starts, `hooks_off.rs`). Redoing is limited to the steps
549/// that are safe to run twice (`may_be_rerun`); a step that is quiet again in its redo is reported with the sentence of
550/// `watched_timeout_text`. That the redo happens once is the coordinator's reading of "redone" (audit, pick 4), open to veto.
551///
552/// In TS you'd write (pseudocode):
553/// ```ts
554/// try { return await runOnce(terms()); } catch (quiet) { if (!mayBeRerun(request)) throw quiet; return await runOnce(terms()); }
555/// ```
556fn run_git_once_with(
557 context: &GitContext,
558 request: &GitRequest,
559 terms: &dyn Fn() -> WatchTerms,
560) -> std::io::Result<GitOutput> {
561 let first: std::io::Result<GitOutput> = run_git_watched(context, request, terms());
562 return match first {
563 Err(error) if error.kind() == std::io::ErrorKind::TimedOut && may_be_rerun(request) && !crate::child_watch::is_ceiling(&error) => {
564 // Terminal output error: a notice is advisory; a failed write must not stop the commit.
565 let _written = writeln!(
566 std::io::stderr(),
567 "cli-git: {} showed no sign of work and was ended; running it again",
568 step_subject(request)
569 );
570 run_git_watched(context, request, terms())
571 }
572 other => other,
573 };
574}
575
576/// How a request names itself in notices and in the sentence of a step that was ended: the command and the kind of step.
577fn step_subject(request: &GitRequest) -> String {
578 return format!(
579 "{}, {}",
580 command_text(&request.arguments),
581 class_of(request.arguments.as_slice()).label()
582 );
583}
584
585/// Steps that change nothing, or that write only objects nothing refers to, and that `RERUNNABLE` does not name. A second run of an
586/// object writer writes the same bytes and a partial result is a loose object or a temporary pack that no reference names, so
587/// ending one costs nothing. Only the name is judged: none of these steps writes a reference, the index, the worktree or the
588/// configuration whatever its options. `fast-import` is not among them (sol-1 round 2): its input can update branches and
589/// checkpoint them, and the cut experiment ran the wrapper's own stream and found a marks lock file a cut left behind that the
590/// second run could not pass, so it is never ended.
591const READS_AND_OBJECT_WRITERS: [&str; 15] = [
592 "rev-list",
593 "merge-base",
594 "log",
595 "ls-tree",
596 "status",
597 "var",
598 "diff-files",
599 "check-attr",
600 "check-ignore",
601 "stripspace",
602 "fmt-merge-msg",
603 "show-ref",
604 "name-rev",
605 "commit-tree",
606 "mktree",
607];
608
609/// The words of a request after its subcommand, sorted into the options before a `--`, the other words before it, and the words
610/// after it. Only options that a proven form lists are accepted, so no option here takes a value the next word could be mistaken
611/// for.
612struct Words<'a> {
613 /// The words that start with a hyphen, before any `--`.
614 options: Vec<&'a str>,
615 /// The other words before any `--`.
616 before: Vec<&'a str>,
617 /// The words after a `--`, when there was one.
618 after: Option<Vec<&'a str>>,
619}
620
621impl<'a> Words<'a> {
622 /// Sort `words`.
623 fn of(words: &'a [String]) -> Self {
624 let mut sorted: Words<'a> = Words { options: Vec::new(), before: Vec::new(), after: None };
625 for word in words.iter().map(String::as_str) {
626 match sorted.after.as_mut() {
627 Some(after) => after.push(word),
628 None if word == "--" => sorted.after = Some(Vec::new()),
629 None if word.starts_with('-') && word.len() > 1 => sorted.options.push(word),
630 None => sorted.before.push(word),
631 }
632 }
633 return sorted;
634 }
635
636 /// Whether every option is one of `allowed`.
637 fn only_options(&self, allowed: &[&str]) -> bool {
638 return self.options.iter().all(|option| { return allowed.contains(option); });
639 }
640
641 /// Whether the option `wanted` is there.
642 fn has(&self, wanted: &str) -> bool {
643 return self.options.contains(&wanted);
644 }
645}
646
647/// The setting that keeps a reference write from appending to a reflog, which the wrapper puts on the command line of every write of
648/// a side ref it wants ended at a ceiling. The repository's own `core.logAllRefUpdates=always` would append a second entry in a
649/// second run, so the setting is on the command line (it wins over the configuration files) and the classifier requires it.
650pub(crate) const NO_REFLOG_SETTING: &str = "core.logAllRefUpdates=false";
651
652/// What:
653/// Whether the last `-c` setting of the global options before the subcommand that names `setting`'s key gives it the value `setting`
654/// gives, and no other option sets the key another way.
655/// Why:
656/// Git takes the last `-c` for a key, so `-c core.logAllRefUpdates=false -c core.logAllRefUpdates=always` logs the update; and a key's
657/// spelling is not case-sensitive, and `--config-env` sets a key too (sol-1 round 2: the classifier requires the exact argv).
658///
659/// In TS you'd write (pseudocode):
660/// ```ts
661/// const settings = globals.filter(isSettingOf(key)); settings.at(-1) === wanted && !globals.some(isConfigEnvOf(key))
662/// ```
663fn has_global_setting(arguments: &[OsString], setting: &str) -> bool {
664 let Some((key, _)) = setting.split_once('=') else { return false };
665 let mut last: Option<String> = None;
666 let mut index: usize = 0;
667 while index < arguments.len() {
668 let text: String = arguments[index].to_string_lossy().into_owned();
669 if text == "-c" {
670 if let Some(pair) = arguments.get(index + 1).map(|word| { return word.to_string_lossy().into_owned(); })
671 && pair.split_once('=').map_or(pair.as_str(), |(name, _)| { return name; }).eq_ignore_ascii_case(key)
672 {
673 last = Some(pair);
674 }
675 index += 2;
676 continue;
677 }
678 if text == "-C" {
679 index += 2;
680 continue;
681 }
682 if text.starts_with('-') {
683 if text.strip_prefix("--config-env=").is_some_and(|rest| { return rest.split_once('=').map_or(rest, |(name, _)| { return name; }).eq_ignore_ascii_case(key); }) {
684 return false;
685 }
686 index += 1;
687 continue;
688 }
689 break;
690 }
691 return last.as_deref() == Some(setting);
692}
693
694/// Whether `word` names a side ref the wrapper makes for itself: `refs/cli-git/kept/<number>` or
695/// `refs/worktree/cli-git-service/<number>`. The number is all digits, so no `..` and no further path can stand in it. The sequence
696/// number is new at each use, so the ref has no reflog file yet: `core.logAllRefUpdates=false` stops a reflog from being created
697/// but does not stop an append to one that exists, so reusing a number would void the proof. That the ref
698/// is a plain ref and has no reflog is not assumed: the classifier requires `--no-deref` and `NO_REFLOG_SETTING` on the exact
699/// argv (`is_proven_to_run_twice`).
700fn is_side_ref(word: &str) -> bool {
701 return ["refs/cli-git/kept/", "refs/worktree/cli-git-service/"]
702 .iter()
703 .any(|prefix| { return word.strip_prefix(prefix).is_some_and(|number| { return !number.is_empty() && number.bytes().all(|byte| { return byte.is_ascii_digit(); }); }); });
704}
705
706/// Whether `word` is an object name of full length in hexadecimal.
707fn is_object_id(word: &str) -> bool {
708 return matches!(word.len(), 40 | 64) && word.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
709}
710
711/// What:
712/// Whether the request's step is one the cut experiment ran twice to the same state, in the exact form it ran.
713/// Why:
714/// The owner's criterion (2026-11-04, "Never cut a branch write (Recommended)"): "Steps that can safely run twice keep their
715/// ceilings". Decided by experiment and not by reading (`doc/planning/cli-git-no-unbounded-waits.md`, "The cut experiment"): each
716/// form was ended with SIGKILL and with SIGTERM at every mutating system call of its run on a throwaway repository, the lock
717/// files a killed Git leaves were removed as the wrapper's sweep removes them, the step was run again, and the refs, reflogs,
718/// index, worktree, stash list, configuration and every file of the Git directory were compared with an uncut run's.
719/// This is a whitelist of those forms, parsed by their options and words (sol-1: a rule by the name of the step ended
720/// `config --add`, `stash -m list` and `update-ref --create-reflog`, which are not the forms that were run): `update-ref` set or delete
721/// of one of the wrapper's own side refs, `stash list --format=%H`, `clean --force -d`, `read-tree --reset -u <tree>`, `update-index`
722/// with `--refresh`, with `--index-info` (and `-z`) or with `--add --cacheinfo <triple>`, `add --update` or `add --all` with
723/// optional pathspecs, `checkout-index --all --force`, `restore` with `--staged`, `--worktree`, `--source=` and paths after `--`,
724/// `reset [--quiet] <commit> -- <paths>`, and `config [--local] <key> <value>`. The experiment ran these on Git 2.56.0 with
725/// `core.fsync=none`, the files ref backend, no hooks and no concurrent writer.
726/// Any other form of any of them, and any other step, is not proven and has no ceiling.
727///
728/// In TS you'd write (pseudocode):
729/// ```ts
730/// PROVEN_FORMS.some((form) => form.matches(parse(subcommand, words)))
731/// ```
732fn is_proven_to_run_twice(arguments: &[OsString], name: &str, rest: &[String]) -> bool {
733 let words: Words<'_> = Words::of(rest);
734 let no_paths = words.after.is_none();
735 return match name {
736 "update-ref" => {
737 // `--no-deref` and the reflog setting are enforced on the argv; the experiment ran the form with both, with the
738 // configuration saying `always`, and with the ref a symbolic ref to a branch.
739 let plain: bool = words.after.is_none() && words.only_options(&["-d", "--no-deref"]) && words.has("--no-deref") && has_global_setting(arguments, NO_REFLOG_SETTING);
740 let delete: bool = words.has("-d") && words.before.len() == 1 && is_side_ref(words.before[0]);
741 let set: bool = !words.has("-d") && words.before.len() == 2 && is_side_ref(words.before[0]) && is_object_id(words.before[1]);
742 plain && (delete || set)
743 }
744 "stash" => rest.first().is_some_and(|first| { return first == "list"; }) && rest.len() == 2 && rest[1] == "--format=%H",
745 "clean" => no_paths && words.before.is_empty() && words.only_options(&["--force", "-d"]) && words.has("--force") && words.has("-d"),
746 "read-tree" => no_paths && words.before.len() == 1 && words.only_options(&["--reset", "-u"]) && words.has("--reset") && words.has("-u"),
747 "update-index" => {
748 let refresh: bool = no_paths && words.before.is_empty() && words.options == ["--refresh"];
749 let index_info: bool = no_paths && words.before.is_empty() && words.has("--index-info") && words.only_options(&["--index-info", "-z"]);
750 let cache_info: bool = no_paths && words.before.len() == 1 && words.only_options(&["--add", "--cacheinfo"]) && words.has("--add") && words.has("--cacheinfo");
751 refresh || index_info || cache_info
752 }
753 "add" => {
754 let one_of: usize = ["--update", "-u", "--all", "-A"].iter().filter(|option| { return words.has(option); }).count();
755 words.only_options(&["--update", "-u", "--all", "-A"]) && one_of == 1
756 }
757 "checkout-index" => no_paths && words.before.is_empty() && words.only_options(&["--all", "-a", "--force", "-f"]) && (words.has("--all") || words.has("-a")) && (words.has("--force") || words.has("-f")),
758 "restore" => {
759 let options_known: bool = words.options.iter().all(|option| { return matches!(*option, "--staged" | "--worktree" | "--quiet" | "-q") || option.starts_with("--source="); });
760 options_known && words.before.is_empty() && words.after.as_ref().is_some_and(|paths| { return !paths.is_empty(); })
761 }
762 "reset" => words.only_options(&["--quiet", "-q"]) && words.before.len() == 1 && words.after.as_ref().is_some_and(|paths| { return !paths.is_empty(); }),
763 "config" => no_paths && words.only_options(&["--local"]) && words.before.len() == 2,
764 _ => false,
765 };
766}
767
768/// What:
769/// Whether the request's step may be ended at a ceiling: it can be run twice, by the allow-list `may_be_rerun`, or it only reads
770/// or writes unreferenced objects, or it is a proven form.
771/// Why:
772/// Anything else has no ceiling, because data is lost by guessing wrong and a wait is not (the coordinator's reading of the owner's
773/// criterion): the commit service's landing thread waits for it, and any other command stops waiting and leaves it running.
774///
775/// In TS you'd write (pseudocode):
776/// ```ts
777/// mayBeRerun(request) || READS_AND_OBJECT_WRITERS.includes(name) || isProvenToRunTwice(name, words)
778/// ```
779pub(crate) fn may_be_ended(request: &GitRequest) -> bool {
780 if may_be_rerun(request) {
781 return true;
782 }
783 let Some((position, name)) = subcommand_of(request.arguments.as_slice()) else {
784 return false;
785 };
786 if READS_AND_OBJECT_WRITERS.contains(&name.as_str()) {
787 return true;
788 }
789 let rest: Vec<String> = request.arguments[position + 1..].iter().map(|argument| { return argument.to_string_lossy().into_owned(); }).collect();
790 return is_proven_to_run_twice(request.arguments.as_slice(), name.as_str(), rest.as_slice());
791}
792
793/// What:
794/// How long the request's step is waited for, and what happens then.
795/// Why:
796/// A step that can be run twice is ended at the ceiling of its class, whatever it shows. A step that cannot is never ended
797/// (owner, 2026-11-04: "Never cut a branch write (Recommended)"): on the commit service's landing thread it is waited for, and the
798/// clients that wait behind it are told what it is stuck in (`service_busy.rs`); anywhere else the command that started it stops
799/// waiting at the bound of the step's class plus the short extra wait, with an error saying the result is not known, and the step is
800/// left running. In a unit test the bound of the class can be shortened for the thread that runs the control.
801///
802/// In TS you'd write (pseudocode):
803/// ```ts
804/// function stepBound(request): StepBound { return !cannotRunTwice(request) ? endAt(classCeiling) : onLandingThread() ? unbounded : leaveRunning(classCeiling, SHORT_EXTRA_WAIT) }
805/// ```
806fn step_bound(request: &GitRequest) -> StepBound {
807 #[cfg(test)]
808 let shortened: Option<Duration> = STEP_CEILING_FOR_THIS_THREAD.with(std::cell::Cell::get);
809 #[cfg(not(test))]
810 let shortened: Option<Duration> = None;
811 let class_ceiling: Duration = shortened.unwrap_or(match class_of(request.arguments.as_slice()) {
812 ChildClass::Plumbing => PLUMBING_STEP_CEILING,
813 ChildClass::Object => OBJECT_STEP_CEILING,
814 });
815 if may_be_ended(request) {
816 return StepBound::EndAt(class_ceiling);
817 }
818 if crate::service_busy::on_landing_thread() {
819 return StepBound::Unbounded;
820 }
821 #[cfg(test)]
822 let extra: Duration = EXTRA_WAIT_FOR_THIS_THREAD.with(std::cell::Cell::get).unwrap_or(crate::wait_ceilings::SHORT_EXTRA_WAIT);
823 #[cfg(not(test))]
824 let extra: Duration = crate::wait_ceilings::SHORT_EXTRA_WAIT;
825 return StepBound::LeaveRunning { after: class_ceiling, extra };
826}
827
828#[cfg(test)]
829thread_local! {
830 /// A shortened ceiling for the steps the current thread starts, so a control ends a step in a moment.
831 pub(crate) static STEP_CEILING_FOR_THIS_THREAD: std::cell::Cell<Option<Duration>> = const { std::cell::Cell::new(None) };
832 /// A shortened extra wait for the steps the current thread leaves running.
833 pub(crate) static EXTRA_WAIT_FOR_THIS_THREAD: std::cell::Cell<Option<Duration>> = const { std::cell::Cell::new(None) };
834}
835
836/// What:
837/// Start one request once under a watch and capture its output, whatever its exit code.
838/// Why:
839/// The command and its input file are built here, so every run starts from the request alone.
840///
841/// In TS you'd write (pseudocode):
842/// ```ts
843/// await runWatched({ gitPath, cwd, args, terms, stoppable: mayBeRerun(request) })
844/// ```
845fn run_git_watched(context: &GitContext, request: &GitRequest, terms: WatchTerms) -> std::io::Result<GitOutput> {
846 let mut command: Command = build_command(context, request);
847 match &request.input {
848 Some(bytes) => {
849 command.stdin(Stdio::from(input_file(bytes.as_slice(), context.common_dir.as_deref())?));
850 }
851 None => {
852 command.stdin(Stdio::null());
853 }
854 }
855 // The class label travels in the subject, so the notices and the timeout sentence name the kind of step.
856 let subject: String = step_subject(request);
857 let ledger_step: String = crate::add_timing::git_step_label(request.arguments.as_slice());
858 let bound: StepBound = step_bound(request);
859 // A write that is never ended is recorded for the clients that queue behind it (`service_busy.rs`); the record ends with the step.
860 let _recorded = if matches!(bound, StepBound::Unbounded) {
861 crate::service_busy::enter_step(format!("{} in {}", command_text(&request.arguments), request.cwd.display()))
862 } else {
863 None
864 };
865 let output: std::process::Output =
866 output_watched_within(&mut command, subject.as_str(), ledger_step.as_str(), terms, may_be_rerun(request), bound)?;
867 return Ok(GitOutput {
868 code: output.status.code(),
869 stdout: output.stdout,
870 stderr: output.stderr,
871 });
872}
873
874/// What:
875/// Run one request and require success;
876/// a start failure or a non-zero exit is a
877/// failure naming the command and quoting Git's standard error.
878/// Why:
879/// Most transaction steps cannot continue without Git's answer.
880///
881/// In TS you'd write (pseudocode):
882/// ```ts
883/// await runTransactionGit({ gitPath, cwd, args }) // throws CommitTransactionGitError
884/// ```
885pub fn run_git_checked(
886 context: &GitContext,
887 request: &GitRequest,
888) -> Result<GitOutput, GitFailure> {
889 return checked_from(run_git(context, request), request.arguments.as_slice());
890}
891
892/// What:
893/// Turn the outcome of a run into the checked result:
894/// success needs a start and a zero exit, and each failure names the command.
895/// Why:
896/// A step stopped at its bound did start, and its own text already says which command and why;
897/// every other start failure and every non-zero exit is named here.
898///
899/// In TS you'd write (pseudocode):
900/// ```ts
901/// function checkedFrom(outcome: GitOutput | Error, args: string[]): GitOutput; // throws CommitTransactionGitError
902/// ```
903fn checked_from(outcome: std::io::Result<GitOutput>, arguments: &[OsString]) -> Result<GitOutput, GitFailure> {
904 let output: GitOutput = match outcome {
905 Ok(finished) => finished,
906 Err(error) if error.kind() == std::io::ErrorKind::TimedOut => {
907 return Err(GitFailure(error.to_string()));
908 }
909 Err(error) => {
910 return Err(GitFailure(format!("{} could not start: {error}", command_text(arguments))));
911 }
912 };
913 if !output.succeeded() {
914 return Err(GitFailure(format!("{} failed: {}", command_text(arguments), output.error_text())));
915 }
916 return Ok(output);
917}
918
919/// Real-Git runner controls stay out of the release executable.
920#[cfg(test)]
921#[path = "transaction_git_tests.rs"]
922mod tests;