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. `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;