Skip to main content

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.
342const RERUNNABLE: [&str; 17] = [
343    "rev-parse",
344    "symbolic-ref",
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    "init",
357    "pack-objects",
358    "index-pack",
359    "version",
360];
361
362/// What:
363///  The subcommand a request runs and where it sits among the arguments,
364///  past the global options (`-c <pair>`, `-C <directory>` and the `--option` forms).
365/// Why:
366///   The rerun allow-list is decided by the subcommand and, for a few, by its options.
367///
368/// In TS you'd write (pseudocode):
369/// ```ts
370/// const index = args.findIndex(...); // first token that is not a global option or its value
371/// ```
372pub(crate) fn subcommand_of(arguments: &[OsString]) -> Option<(usize, String)> {
373    let mut index: usize = 0;
374    while index < arguments.len() {
375        let text: String = arguments[index].to_string_lossy().into_owned();
376        if text == "-c" || text == "-C" {
377            index += 2;
378            continue;
379        }
380        if text.starts_with('-') {
381            index += 1;
382            continue;
383        }
384        return Some((index, text));
385    }
386    return None;
387}
388
389/// What:
390///  Whether running the request a second time after the system refused it is harmless.
391/// Why:
392///   Git reports a refused process or thread as a failure after it started,
393///  so only commands that read, write content-addressed objects, or change a private index the same way twice
394///  may be run again;
395///  reference updates, commits and every other writer are not,
396///  and recovery already completes or rolls those back.
397///
398/// In TS you'd write (pseudocode):
399/// ```ts
400/// RERUNNABLE.includes(subcommand) || (subcommand === 'config' && reads(args)) || ...
401/// ```
402fn may_be_rerun(request: &GitRequest) -> bool {
403    let Some((position, name)) = subcommand_of(request.arguments.as_slice()) else {
404        return false;
405    };
406    let rest: Vec<String> = request.arguments[position + 1..]
407        .iter()
408        .map(|argument| { return argument.to_string_lossy().into_owned(); })
409        .collect();
410    match name.as_str() {
411        "config" => return rest.iter().any(|option| {
412            return matches!(option.as_str(), "--get" | "--get-all" | "--get-regexp" | "--list" | "-l");
413        }),
414        "remote" => return rest.first().is_some_and(|word| { return word == "get-url"; }),
415        "add" | "reset" | "read-tree" | "update-index" => return request.index.is_some(),
416        other => return RERUNNABLE.contains(&other),
417    }
418}
419
420/// What:
421///  Whether Git failed because the system would not give it a process or a thread.
422/// Why:
423///   Git words these failures with the system's own text (`unable to create thread: Resource temporarily
424///  unavailable`, `cannot fork() for ...`), and no other failure of a rerunnable command carries it.
425///
426/// In TS you'd write (pseudocode):
427/// ```ts
428/// output.exitCode !== 0 && output.stderr.includes('Resource temporarily unavailable')
429/// ```
430fn refused_by_the_system(output: &GitOutput) -> bool {
431    return output.code != Some(0)
432        && String::from_utf8_lossy(output.stderr.as_slice()).contains("Resource temporarily unavailable");
433}
434
435/// What:
436///  Run one request, running it again within the budget when Git reports a refused process or thread
437///  and the command is harmless to run twice.
438///  After the budget the last output is returned as it was, so the caller reports Git's own words.
439/// Why:
440///   The command is rebuilt for each run, so its input file is fresh.
441///  A start that already waited out its own budget is not waited for again.
442///
443/// In TS you'd write (pseudocode):
444/// ```ts
445/// await retryWhile(() => runGitOnce(request), (output) => refusedBySystem(output) && mayBeRerun(request));
446/// ```
447fn run_git_pausing(
448    context: &GitContext,
449    request: &GitRequest,
450    budget: Duration,
451    pause: &mut dyn FnMut(Duration),
452) -> std::io::Result<GitOutput> {
453    if !may_be_rerun(request) {
454        return run_git_once(context, request);
455    }
456    let what: String = command_text(&request.arguments);
457    let mut refused: Option<GitOutput> = None;
458    let result: std::io::Result<GitOutput> = retry_refused_start_with(what.as_str(), budget, pause, &mut || {
459        let output: GitOutput = match run_git_once(context, request) {
460            Ok(finished) => finished,
461            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Err(std::io::Error::other(error)),
462            Err(error) => return Err(error),
463        };
464        if refused_by_the_system(&output) {
465            refused = Some(output);
466            return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock));
467        }
468        return Ok(output);
469    });
470    match (result, refused.take()) {
471        (Ok(finished), _) => return Ok(finished),
472        (Err(error), Some(last)) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(last),
473        (Err(error), _) => return Err(error),
474    }
475}
476
477/// What:
478///  Run one request once and capture its output, whatever its exit code.
479/// Why:
480///   The command and its input file are built here, so every run starts from the request alone.
481///
482/// In TS you'd write (pseudocode):
483/// ```ts
484/// await runTransactionGitOnce({ gitPath, cwd, args })
485/// ```
486fn run_git_once(context: &GitContext, request: &GitRequest) -> std::io::Result<GitOutput> {
487    return run_git_once_with(context, request, &|| { return terms_for_request(request); });
488}
489
490/// What:
491///  The watch terms of a request: those of the class its subcommand belongs to.
492/// Why:
493///   A query step and a bulk step are stopped at different bounds, so the choice is one function a test can pin.
494///
495/// In TS you'd write (pseudocode):
496/// ```ts
497/// termsForClass(classOf(request.args))
498/// ```
499pub(crate) fn terms_for_request(request: &GitRequest) -> WatchTerms {
500    return WatchTerms::for_class(class_of(request.arguments.as_slice()));
501}
502
503/// What:
504///  `run_git_once` with the notice cadence, the quiet window and the notice sink of its wait made by `terms`, which is asked
505///  for again at each start.
506///  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
507///  waited for.
508/// Why:
509///   The production entry reads the terms from the environment;
510///  tests choose short ones and read the notices from a buffer.
511///  The owner's rule: "End Git, hold hooks (Recommended)": a Git child with no progress is ended and its step redone, and a
512///  person's hook is never ended (none runs below a step the wrapper starts, `hooks_off.rs`). Redoing is limited to the steps
513///  that are safe to run twice (`may_be_rerun`); a step that is quiet again in its redo is reported with the sentence of
514///  `watched_timeout_text`. That the redo happens once is the coordinator's reading of "redone" (audit, pick 4), open to veto.
515///
516/// In TS you'd write (pseudocode):
517/// ```ts
518/// try { return await runOnce(terms()); } catch (quiet) { if (!mayBeRerun(request)) throw quiet; return await runOnce(terms()); }
519/// ```
520fn run_git_once_with(
521    context: &GitContext,
522    request: &GitRequest,
523    terms: &dyn Fn() -> WatchTerms,
524) -> std::io::Result<GitOutput> {
525    let first: std::io::Result<GitOutput> = run_git_watched(context, request, terms());
526    return match first {
527        Err(error) if error.kind() == std::io::ErrorKind::TimedOut && may_be_rerun(request) && !crate::child_watch::is_ceiling(&error) => {
528            // Terminal output error: a notice is advisory; a failed write must not stop the commit.
529            let _written = writeln!(
530                std::io::stderr(),
531                "cli-git: {} showed no sign of work and was ended; running it again",
532                step_subject(request)
533            );
534            run_git_watched(context, request, terms())
535        }
536        other => other,
537    };
538}
539
540/// How a request names itself in notices and in the sentence of a step that was ended: the command and the kind of step.
541fn step_subject(request: &GitRequest) -> String {
542    return format!(
543        "{}, {}",
544        command_text(&request.arguments),
545        class_of(request.arguments.as_slice()).label()
546    );
547}
548
549/// What:
550///  Whether the request's step is one that cannot be run twice: it changes the repository in place and, cut part way and run again,
551///  did not come out in the state an uncut run leaves.
552/// Why:
553///   The owner's criterion (2026-11-04, "Never cut a branch write (Recommended)"): "Steps that can safely run twice keep their
554///  ceilings". Decided by experiment and not by reading (`doc/planning/cli-git-no-unbounded-waits.md`, "The cut experiment"): each
555///  form below was ended with SIGKILL and with SIGTERM at every mutating system call of its run on a throwaway repository, the lock
556///  files a killed Git leaves were removed as the wrapper's sweep removes them, the step was run again, and the refs, reflogs,
557///  index, worktree, stash list, configuration and every file of the Git directory were compared with an uncut run's.
558///  Identical every time, and so ended at the ceiling of their class: `update-ref` of a ref under `refs/cli-git/` or
559///  `refs/worktree/cli-git-service/` (set, or delete one), `stash list`, `clean`, `read-tree --reset`, `update-index` with
560///  `--refresh`, `--index-info` or `--cacheinfo`, `add`, `checkout-index`, `restore`, `reset <commit> -- <paths>`, and `config`
561///  writes. Different at some cut, and so never ended: `update-ref` of anything else (a branch has a reflog, which a second run
562///  doubles, and a compare-and-swap or a transaction of two refs fails or leaves the scratch ref), `stash store`, `merge`,
563///  `reset --hard`, `reset --soft`, `read-tree -m`, `checkout` and `switch`. A form no experiment ran is counted with the
564///  different ones, because data is lost by guessing wrong and a wait is not. Requests `may_be_rerun` accepts, the readers, and
565///  the writers of unreferenced objects (`commit-tree`, `fast-import`, `mktree`) are not in question and are ended.
566///
567/// In TS you'd write (pseudocode):
568/// ```ts
569/// !mayBeRerun(request) && CUT_EXPERIMENT_DIFFERED.has(formOf(request))
570/// ```
571pub(crate) fn cannot_run_twice(request: &GitRequest) -> bool {
572    if may_be_rerun(request) {
573        return false;
574    }
575    let Some((position, name)) = subcommand_of(request.arguments.as_slice()) else {
576        return false;
577    };
578    let rest: Vec<String> = request.arguments[position + 1..].iter().map(|argument| { return argument.to_string_lossy().into_owned(); }).collect();
579    let has = |flag: &str| -> bool { return rest.iter().any(|word| { return word == flag; }); };
580    let positional: Vec<&str> = rest.iter().map(String::as_str).filter(|word| { return !word.starts_with('-'); }).collect();
581    let side_ref = |reference: &str| -> bool { return reference.starts_with("refs/cli-git/") || reference.starts_with("refs/worktree/cli-git-service/"); };
582    return match name.as_str() {
583        "update-ref" => {
584            let plain_delete: bool = has("-d") && positional.len() == 1 && side_ref(positional[0]);
585            let plain_set: bool = !has("-d") && !has("--stdin") && positional.len() == 2 && side_ref(positional[0]);
586            !(plain_delete || plain_set)
587        }
588        "stash" => !positional.first().is_some_and(|word| { return *word == "list"; }),
589        "merge" | "checkout" | "switch" => true,
590        "reset" => has("--hard") || has("--soft") || has("--merge") || has("--keep") || !has("--"),
591        "read-tree" => !has("--reset") || has("-m"),
592        "update-index" => !(has("--refresh") || has("--index-info") || has("--cacheinfo")),
593        _ => false,
594    };
595}
596
597/// What:
598///  How long the request's step is waited for, and what happens then.
599/// Why:
600///   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
601///  (owner, 2026-11-04: "Never cut a branch write (Recommended)"): on the commit service's landing thread it is waited for, and the
602///  clients that wait behind it are told what it is stuck in (`service_busy.rs`); anywhere else the command that started it stops
603///  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
604///  left running. In a unit test the bound of the class can be shortened for the thread that runs the control.
605///
606/// In TS you'd write (pseudocode):
607/// ```ts
608/// function stepBound(request): StepBound { return !cannotRunTwice(request) ? endAt(classCeiling) : onLandingThread() ? unbounded : leaveRunning(classCeiling, SHORT_EXTRA_WAIT) }
609/// ```
610fn step_bound(request: &GitRequest) -> StepBound {
611    #[cfg(test)]
612    let shortened: Option<Duration> = STEP_CEILING_FOR_THIS_THREAD.with(std::cell::Cell::get);
613    #[cfg(not(test))]
614    let shortened: Option<Duration> = None;
615    let class_ceiling: Duration = shortened.unwrap_or(match class_of(request.arguments.as_slice()) {
616        ChildClass::Plumbing => PLUMBING_STEP_CEILING,
617        ChildClass::Object => OBJECT_STEP_CEILING,
618    });
619    if !cannot_run_twice(request) {
620        return StepBound::EndAt(class_ceiling);
621    }
622    if crate::service_busy::on_landing_thread() {
623        return StepBound::Unbounded;
624    }
625    #[cfg(test)]
626    let extra: Duration = EXTRA_WAIT_FOR_THIS_THREAD.with(std::cell::Cell::get).unwrap_or(crate::wait_ceilings::SHORT_EXTRA_WAIT);
627    #[cfg(not(test))]
628    let extra: Duration = crate::wait_ceilings::SHORT_EXTRA_WAIT;
629    return StepBound::LeaveRunning { after: class_ceiling, extra };
630}
631
632#[cfg(test)]
633thread_local! {
634    /// A shortened ceiling for the steps the current thread starts, so a control ends a step in a moment.
635    pub(crate) static STEP_CEILING_FOR_THIS_THREAD: std::cell::Cell<Option<Duration>> = const { std::cell::Cell::new(None) };
636    /// A shortened extra wait for the steps the current thread leaves running.
637    pub(crate) static EXTRA_WAIT_FOR_THIS_THREAD: std::cell::Cell<Option<Duration>> = const { std::cell::Cell::new(None) };
638}
639
640/// What:
641///  Start one request once under a watch and capture its output, whatever its exit code.
642/// Why:
643///   The command and its input file are built here, so every run starts from the request alone.
644///
645/// In TS you'd write (pseudocode):
646/// ```ts
647/// await runWatched({ gitPath, cwd, args, terms, stoppable: mayBeRerun(request) })
648/// ```
649fn run_git_watched(context: &GitContext, request: &GitRequest, terms: WatchTerms) -> std::io::Result<GitOutput> {
650    let mut command: Command = build_command(context, request);
651    match &request.input {
652        Some(bytes) => {
653            command.stdin(Stdio::from(input_file(bytes.as_slice(), context.common_dir.as_deref())?));
654        }
655        None => {
656            command.stdin(Stdio::null());
657        }
658    }
659    // The class label travels in the subject, so the notices and the timeout sentence name the kind of step.
660    let subject: String = step_subject(request);
661    let ledger_step: String = crate::add_timing::git_step_label(request.arguments.as_slice());
662    let bound: StepBound = step_bound(request);
663    // A write that is never ended is recorded for the clients that queue behind it (`service_busy.rs`); the record ends with the step.
664    let _recorded = if matches!(bound, StepBound::Unbounded) {
665        crate::service_busy::enter_step(format!("{} in {}", command_text(&request.arguments), request.cwd.display()))
666    } else {
667        None
668    };
669    let output: std::process::Output =
670        output_watched_within(&mut command, subject.as_str(), ledger_step.as_str(), terms, may_be_rerun(request), bound)?;
671    return Ok(GitOutput {
672        code: output.status.code(),
673        stdout: output.stdout,
674        stderr: output.stderr,
675    });
676}
677
678/// What:
679///  Run one request and require success;
680///  a start failure or a non-zero exit is a
681///       failure naming the command and quoting Git's standard error.
682/// Why:
683///   Most transaction steps cannot continue without Git's answer.
684///
685/// In TS you'd write (pseudocode):
686/// ```ts
687/// await runTransactionGit({ gitPath, cwd, args }) // throws CommitTransactionGitError
688/// ```
689pub fn run_git_checked(
690    context: &GitContext,
691    request: &GitRequest,
692) -> Result<GitOutput, GitFailure> {
693    return checked_from(run_git(context, request), request.arguments.as_slice());
694}
695
696/// What:
697///  Turn the outcome of a run into the checked result:
698///  success needs a start and a zero exit, and each failure names the command.
699/// Why:
700///   A step stopped at its bound did start, and its own text already says which command and why;
701///  every other start failure and every non-zero exit is named here.
702///
703/// In TS you'd write (pseudocode):
704/// ```ts
705/// function checkedFrom(outcome: GitOutput | Error, args: string[]): GitOutput; // throws CommitTransactionGitError
706/// ```
707fn checked_from(outcome: std::io::Result<GitOutput>, arguments: &[OsString]) -> Result<GitOutput, GitFailure> {
708    let output: GitOutput = match outcome {
709        Ok(finished) => finished,
710        Err(error) if error.kind() == std::io::ErrorKind::TimedOut => {
711            return Err(GitFailure(error.to_string()));
712        }
713        Err(error) => {
714            return Err(GitFailure(format!("{} could not start: {error}", command_text(arguments))));
715        }
716    };
717    if !output.succeeded() {
718        return Err(GitFailure(format!("{} failed: {}", command_text(arguments), output.error_text())));
719    }
720    return Ok(output);
721}
722
723/// Real-Git runner controls stay out of the release executable.
724#[cfg(test)]
725#[path = "transaction_git_tests.rs"]
726mod tests;