Skip to main content

git_policy_cli/
entry.rs

1//! What:
2//!  Decide what one `git` invocation of the native wrapper does,
3//!  and perform it.
4//! Why:
5//!  The executable stays thin:
6//!  it gathers process facts,
7//!  asks this module for an
8//!      action,
9//!  and performs it.
10//!  The decision itself is testable without replacing
11//!      the test process.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! // const action = planInvocation(args, env, inputs); perform(action);
16//! ```
17
18/// Import the sibling modules the decision combines.
19use super::action::{Action, ENGINE_FAILURE_EXIT_CODE, PushForward, failure};
20use super::announced_query::{WRAPPER_VERSION, announced_query, announcement, printable_after};
21use super::command_push::stackable_option_tokens;
22use super::external_hooks::external_hooks_warning;
23use super::lfs_pre_push::uses_lfs;
24use super::lfs_upload_plan::settings_from_snapshot;
25use super::config_snapshot::{ConfigSnapshot, read_config_snapshot};
26use super::push_stacking::{STACKED_PUSH_MARKER, run_stacked_push};
27use super::command_push::parse_push_region;
28use super::child_environment::{
29    FORWARD_TARGET_VARIABLE, child_environment_overlay, environment_value,
30};
31use super::effective_target::default_allowed_worktree_dirs;
32use super::forwarding::{exit_code, replace_process_with_real_git, run_real_git};
33use super::index_writer_forward::{CoordinatedForward, CoordinatedRun, run_coordinated_forward};
34use super::index_writer_commands::is_index_writer;
35use super::forwarded_command::{ResolvedCommand, Route, creates_or_moves_worktrees, resolve_forwarded_command};
36use super::invocation_config::{config_invalid_event, load_identity_config};
37use super::config_file::LoadedConfig;
38use super::repository_location::RepositoryLocation;
39use super::transaction_git::GitContext;
40use super::worktree_identity::WorktreeIdentity;
41use super::global_arguments::{GlobalLayout, GlobalOutcome, global_layout};
42use super::management::plan_management;
43/// The linter the executable gives the Markdown policy.
44/// The shipped checks and their constructor.
45use super::policy_checks::{ShippedChecks, shipped_checks};
46/// A forwarded command starts with no candidate content.
47use super::policy_content::LifecycleContent;
48use super::real_git::{ResolutionInputs, process_resolution_inputs, resolve_real_git};
49use super::real_git_candidate::same_file;
50use super::repository_facts::{GitFacts, RepositoryFacts, git_facts};
51use super::rule_require_root::{RequireRootDecision, decide_require_root};
52/// The variable that names the forbidden-strings rules list.
53use super::scanner_selection::RULES_VARIABLE;
54use super::wrapped_command::{WrappedOutcome, run_wrapped_command};
55use super::wrapper_controls::{Controls, no_controls, strip_global_controls};
56use super::worktree_copy_forward::{CopyFacts, ForwardedRun, copy_applies, run_forwarded_git};
57use super::wrapper_invocation::{StrippedInvocation, strip_wrapper_controls};
58/// What:
59///  `OsString` is owned operating-system text of raw OS bytes (sibling `String`
60///       must be UTF-8).
61/// Why:
62///   Arguments and environment values are never decoded.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// // process.argv and process.env, but byte-preserving.
67/// ```
68use std::ffi::OsString;
69/// The trait that gives output streams `.write_all(..)`.
70use std::io::Write;
71/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
72use std::path::{Path, PathBuf};
73
74/// The management namespace:
75///  `git cli-git ...` is handled by the wrapper,
76///  never by Git.
77pub const MANAGEMENT_COMMAND: &str = "cli-git";
78
79/// What:
80///  Explain that another cli-git wrapper selected this executable as real Git.
81///       `&Path` borrows this executable's path for the message.
82/// Why:
83///   Forwarding again would bounce between the two wrappers forever;
84///  stopping
85///       names this executable so the user can fix PATH.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// function forwardedToSelf(ownExecutable: string): Action;
90/// ```
91fn forwarded_to_self(own_executable: &Path) -> Action {
92    // `format!` builds owned text; `.as_str()` lends it to the helper.
93    return failure(
94        format!(
95            "another cli-git wrapper selected this cli-git executable ({}) as real Git. \
96             Forwarding again would never reach Git. Remove the extra cli-git from PATH or \
97             place the real Git executable on PATH.",
98            own_executable.display()
99        )
100        .as_str(),
101    );
102}
103
104/// What:
105///  The caller's home directory from the environment,
106///  if it names one.
107///       `Option<PathBuf>` is "an owned path or nothing".
108/// Why:
109///   The tool caches exempt from linked-worktree enforcement live under the home
110///       directory;
111///  it comes from the injected environment,
112///  never from a fixed path.
113///
114/// In TS you'd write (pseudocode):
115/// ```ts
116/// const home = environment.HOME === undefined || environment.HOME === '' ? undefined : environment.HOME;
117/// ```
118fn home_directory(environment: &[(OsString, OsString)]) -> Option<PathBuf> {
119    // `if let Some(home) = ... && cond` runs only when the variable is set and not empty.
120    if let Some(home) = environment_value(environment, "HOME")
121        && !home.is_empty()
122    {
123        // `Some(...)` is the "present" case; `PathBuf::from` turns the text into a path.
124        return Some(PathBuf::from(home));
125    }
126    // `None` is the "absent" case.
127    return None;
128}
129
130/// What:
131///  Decide the action for one invocation from injected process facts.
132///       `&[OsString]` borrows the arguments after the program name;
133///       `&[(OsString, OsString)]` borrows the environment as name/value pairs.
134/// Why:
135///   Order matters.
136///  The recursion check runs before anything else.
137///  Wrapper controls
138///       written before the subcommand are removed first,
139///  because Git's own reading of
140///       the arguments stops at them and would hide the subcommand from every later
141///       decision.
142///  The management namespace is answered by the wrapper.
143///  Every other
144///       command goes through the wrapped-command lifecycle with real Git behind it.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// function planInvocation(args: string[], environment: [string, string][], inputs: ResolutionInputs): Action;
149/// ```
150pub fn plan_invocation(
151    arguments: &[OsString],
152    environment: &[(OsString, OsString)],
153    inputs: &ResolutionInputs,
154) -> Action {
155    // `if let Some(target) = ... && cond` runs only when a forwarding wrapper left its
156    // marker and that marker names this executable.
157    if let Some(target) = environment_value(environment, FORWARD_TARGET_VARIABLE)
158        && same_file(Path::new(&target), inputs.own_executable.as_path())
159    {
160        return forwarded_to_self(inputs.own_executable.as_path());
161    }
162    // `mut` allows the scan to record what the removed controls asked for.
163    let mut global_controls: Controls = no_controls();
164    // `&mut global_controls` lends the record for writing.
165    let global_clean: Vec<OsString> = strip_global_controls(arguments, &mut global_controls);
166    // `.as_slice()` lends the owned list as a borrowed view.
167    let layout: GlobalLayout = global_layout(global_clean.as_slice());
168    if layout.outcome == GlobalOutcome::Command
169        && global_clean[layout.prefix_len] == MANAGEMENT_COMMAND
170    {
171        // `&list[a..]` borrows from index `a` on; `&list[..a]` borrows up to it.
172        return plan_management(
173            &global_clean[layout.prefix_len + 1..],
174            &global_clean[..layout.prefix_len],
175            &global_controls,
176            environment,
177            inputs,
178        );
179    }
180    // What: `match` on the resolver's `Result`: keep the path or stop with its message.
181    // Why:  Without real Git there is nothing to forward to.
182    //
183    // In TS you'd write (pseudocode):
184    // ```ts
185    // let realGit; try { realGit = resolveRealGit(inputs); } catch (e) { return failure(String(e)); }
186    // ```
187    let real_git: PathBuf = match resolve_real_git(inputs) {
188        Ok(found) => found,
189        Err(error) => return failure(error.to_string().as_str()),
190    };
191    let overlay: Vec<(OsString, OsString)> =
192        child_environment_overlay(environment, real_git.as_path());
193    // What: Top-level `--version`, `-v`, `--help` and `-h` run Git as a child and add the announcement.
194    // Why:  The layout is already known here, so every other invocation pays one enum comparison;
195    //       the other queries keep the exact process replacement below.
196    //
197    // In TS you'd write (pseudocode):
198    // ```ts
199    // const query = announcedQuery(args, layout); if (query) return { kind: 'forward-announced', ... };
200    // ```
201    if let Some(query) = announced_query(global_clean.as_slice(), &layout) {
202        return Action::ForwardAnnounced {
203            announcement: announcement(query, WRAPPER_VERSION, real_git.as_path()),
204            real_git,
205            arguments: global_clean,
206            overlay,
207            stderr: String::new(),
208        };
209    }
210    let stripped: StrippedInvocation = strip_wrapper_controls(arguments);
211    // The global options before the subcommand select the repository for every fact query.
212    let facts: GitFacts = git_facts(
213        real_git.as_path(),
214        &stripped.arguments[..stripped.layout.prefix_len],
215        overlay.as_slice(),
216    );
217    // `mut` lets the lifecycle cache facts and state what content policies can read.
218    let mut checks: ShippedChecks<GitFacts> = shipped_checks(
219        facts,
220        // `.clone()` copies the stripped arguments for the rule cores.
221        stripped.arguments.clone(),
222        LifecycleContent::None,
223        default_allowed_worktree_dirs(
224            environment,
225            // `.as_deref()` lends the optional owned path as an optional borrowed one.
226            home_directory(environment).as_deref(),
227        ),
228    );
229    // The forbidden-strings rules variable is read from this invocation's environment.
230    checks.scanner_settings.rules_variable = environment_value(environment, RULES_VARIABLE);
231    // `match` picks by variant and binds the fields each ending carries.
232    match run_wrapped_command(&stripped, environment, &mut checks) {
233        // `arguments: forwarded` binds the field under a new name.
234        WrappedOutcome::Forward {
235            arguments: forwarded,
236            stderr,
237        } => {
238            // The opt-out, Git's other query forms (`--exec-path` and so on), and the commands that need no repository
239            // (`version`, `help`, `init`, `clone`, user-level `config`) never settle a copy.
240            let copy_bypassed: bool = stripped.controls.skip_worktree_copy
241                || stripped.layout.outcome != GlobalOutcome::Command
242                || decide_require_root(forwarded.as_slice(), &[])
243                    != RequireRootDecision::NeedsRepositoryRoot;
244            return stack_if_push(
245                forward_action(
246                    real_git,
247                    forwarded,
248                    overlay,
249                    stderr,
250                    environment,
251                    copy_bypassed,
252                    &mut checks,
253                ),
254                environment,
255            );
256        }
257        WrappedOutcome::Exit { code, stderr } => {
258            return Action::Exit {
259                code,
260                // `String::new()` is empty owned text: wrapped commands report on standard error.
261                stdout: String::new(),
262                stderr,
263            };
264        }
265    }
266}
267
268/// What:
269///  Run one invocation with the real process facts and return its exit code.
270///       `i32` is a signed 32-bit integer,
271///  the type of process exit codes.
272///       Returns only when Git was not started;
273///  a forwarded command never returns.
274/// Why:
275///   This is everything the executable's `main` does;
276///  keeping it in the library
277///       lets the same code be measured by tests and mutation runs.
278///
279/// In TS you'd write (pseudocode):
280/// ```ts
281/// function runProcess(args: string[], environment: [string, string][]): number;
282/// ```
283pub fn run_process(arguments: &[OsString], environment: &[(OsString, OsString)]) -> i32 {
284    // From here on every real-Git start of this process runs with traditional hooks off.
285    super::hooks_off::activate();
286    let action: Action = match process_resolution_inputs(environment) {
287        Ok(inputs) => plan_invocation(arguments, environment, &inputs),
288        Err(error) => failure(
289            format!(
290                "cannot determine this executable or the current directory, so real Git \
291                 cannot be selected safely: {error}"
292            )
293            .as_str(),
294        ),
295    };
296    // `match` picks by variant and binds the fields each one carries.
297    match action {
298        Action::Forward {
299            real_git,
300            arguments: forwarded,
301            overlay,
302            stderr,
303        } => {
304            // Warning events reach the caller before Git's own output.
305            write_stream(&mut std::io::stderr(), stderr.as_str());
306            let error: std::io::Error = replace_process_with_real_git(
307                real_git.as_path(),
308                forwarded.as_slice(),
309                overlay.as_slice(),
310            );
311            write_stream(
312                &mut std::io::stderr(),
313                format!(
314                    "cli-git: cannot start {}: {error}. Hint: if you moved or upgraded Git, check that the file exists and is executable with `ls -l {}`, \
315                     then reinstall Git or the wrapper; no Git command ran.\n",
316                    real_git.display(),
317                    real_git.display()
318                )
319                .as_str(),
320            );
321            return ENGINE_FAILURE_EXIT_CODE;
322        }
323        Action::ForwardCoordinated { forward, stderr } => {
324            write_stream(&mut std::io::stderr(), stderr.as_str());
325            return run_coordinated_action(&forward);
326        }
327        Action::ForwardAnnounced {
328            real_git,
329            arguments: forwarded,
330            overlay,
331            announcement,
332            stderr,
333        } => {
334            write_stream(&mut std::io::stderr(), stderr.as_str());
335            return run_announced(
336                real_git.as_path(),
337                forwarded.as_slice(),
338                overlay.as_slice(),
339                announcement.as_str(),
340            );
341        }
342        Action::ForwardSettled {
343            real_git,
344            arguments: forwarded,
345            overlay,
346            copy,
347            stderr,
348        } => {
349            write_stream(&mut std::io::stderr(), stderr.as_str());
350            return finish_forwarded(run_forwarded_git(
351                real_git.as_path(),
352                forwarded.as_slice(),
353                overlay.as_slice(),
354                Some(&*copy),
355            ));
356        }
357        Action::ForwardPush { forward, stderr } => {
358            write_stream(&mut std::io::stderr(), stderr.as_str());
359            return run_stacked_push(&forward);
360        }
361        Action::Exit {
362            code,
363            stdout,
364            stderr,
365        } => {
366            // `&mut` lends each stream for writing.
367            write_stream(&mut std::io::stdout(), stdout.as_str());
368            write_stream(&mut std::io::stderr(), stderr.as_str());
369            return code;
370        }
371    }
372}
373
374/// What:
375///  Run Git as a child for an announced query,
376///  print the announcement when Git exited with status 0,
377///  and return Git's status.
378/// Why:
379///   The child shares the caller's streams, so Git's own answer is untouched and comes first;
380///  the announcement follows it only when Git answered.
381///
382/// In TS you'd write (pseudocode):
383/// ```ts
384/// const outcome = await runRealGit(realGit, args, overlay); if (outcome.exited === 0) print(text); return exitCode(outcome);
385/// ```
386fn run_announced(
387    real_git: &Path,
388    arguments: &[OsString],
389    overlay: &[(OsString, OsString)],
390    text: &str,
391) -> i32 {
392    match run_real_git(real_git, arguments, overlay) {
393        Ok(outcome) => {
394            write_stream(&mut std::io::stdout(), printable_after(outcome, text));
395            return exit_code(outcome);
396        }
397        Err(error) => {
398            write_stream(
399                &mut std::io::stderr(),
400                format!(
401                    "cli-git: cannot start {}: {error}. Hint: if you moved or upgraded Git, check that the file exists and is executable with `ls -l {}`, \
402                     then reinstall Git or the wrapper; no Git command ran.\n",
403                    real_git.display(),
404                    real_git.display()
405                )
406                .as_str(),
407            );
408            return ENGINE_FAILURE_EXIT_CODE;
409        }
410    }
411}
412
413/// What:
414///  The exit code of a forwarded run, after printing the text of a run that failed.
415/// Why:
416///   A settled run keeps the wrapper alive around Git, so the exit code returns here instead of replacing the process.
417///
418/// In TS you'd write (pseudocode):
419/// ```ts
420/// process.exitCode = outcome.exitCode;
421/// ```
422fn finish_forwarded(run: ForwardedRun) -> i32 {
423    // `match` on the ending: a status, or a failure with its own text.
424    match run {
425        ForwardedRun::Ran(code) => return code,
426        ForwardedRun::Failed { stderr, code } => {
427            write_stream(&mut std::io::stderr(), stderr.as_str());
428            return code;
429        }
430    }
431}
432
433/// What:
434///  Run a coordinated forward:
435///  print its failure,
436///  run Git under the landing lock,
437///  or replace the process when no coordination applies after all.
438/// Why:
439///   The lock must outlive Git,
440///  so only a run that takes no lock may replace the process.
441///
442/// In TS you'd write (pseudocode):
443/// ```ts
444/// const outcome = await forwardToRealGit({ ... }); process.exitCode = outcome.exitCode;
445/// ```
446fn run_coordinated_action(forward: &CoordinatedForward) -> i32 {
447    match run_coordinated_forward(forward) {
448        CoordinatedRun::Ran(code) => return code,
449        CoordinatedRun::Failed { stderr, code } => {
450            write_stream(&mut std::io::stderr(), stderr.as_str());
451            return code;
452        }
453        CoordinatedRun::NotCoordinated => {
454            let error: std::io::Error = replace_process_with_real_git(
455                forward.real_git.as_path(),
456                forward.arguments.as_slice(),
457                forward.overlay.as_slice(),
458            );
459            write_stream(
460                &mut std::io::stderr(),
461                format!("cli-git: cannot start {}: {error}\n", forward.real_git.display()).as_str(),
462            );
463            return ENGINE_FAILURE_EXIT_CODE;
464        }
465    }
466}
467
468/// What:
469///  Choose how a forwarded command runs:
470///  plainly,
471///  under the landing lock when it writes the real index,
472///  and settled by the linked-worktree copy when it runs from a linked worktree or a bare repository
473///  (or creates a worktree from a main checkout whose configuration names the rules file).
474///  `copy_bypassed` is true for the wrapper's opt-out and for Git's query forms.
475/// Why:
476///   Only these commands need the wrapper to outlive Git or to hold a lock around it;
477///  everything else keeps the exact process replacement.
478///
479/// In TS you'd write (pseudocode):
480/// ```ts
481/// if (!isIndexWriter(command) && !copyApplies) forward(args); else await forwardToRealGit({ ..., unprovenOwnerTimeoutMs });
482/// ```
483fn forward_action(
484    real_git: PathBuf,
485    arguments: Vec<OsString>,
486    overlay: Vec<(OsString, OsString)>,
487    stderr: String,
488    environment: &[(OsString, OsString)],
489    copy_bypassed: bool,
490    checks: &mut ShippedChecks<GitFacts>,
491) -> Action {
492    let context: GitContext = GitContext {
493        real_git: real_git.clone(),
494        overlay: overlay.clone(),
495        global_prefix: Vec::new(),
496        // These queries send no input, so no scratch directory is needed.
497        common_dir: None,
498    };
499    let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
500    // An alias value may carry its own `-c` options, and Git applies those after any option put before the alias word,
501    //  so the real Git gets the expansion the wrapper already resolved: the hooks-off options then follow the alias's own.
502    let forwarded_arguments: Vec<OsString> = match command.route {
503        Route::Alias => command.arguments.clone(),
504        Route::Direct | Route::ShellAlias | Route::Unresolved => arguments,
505    };
506    let writes_index: bool = is_index_writer(&command);
507    if !writes_index && copy_bypassed {
508        return Action::Forward {
509            real_git,
510            arguments: forwarded_arguments,
511            overlay,
512            stderr,
513        };
514    }
515    let location: RepositoryLocation = match checks.facts.location() {
516        Ok(found) => found,
517        Err(message) => return failure(message.as_str()),
518    };
519    if location.identity == WorktreeIdentity::OutsideWorktree {
520        return Action::Forward {
521            real_git,
522            arguments: forwarded_arguments,
523            overlay,
524            stderr,
525        };
526    }
527    let loaded: Option<LoadedConfig> = match configuration_needed(&location.identity, &command, writes_index) {
528        false => None,
529        true => match load_identity_config(&location.identity) {
530            Ok(found) => Some(found),
531            Err(error) => {
532                return Action::Exit {
533                    code: ENGINE_FAILURE_EXIT_CODE,
534                    stdout: String::new(),
535                    stderr: config_invalid_event(&error),
536                };
537            }
538        },
539    };
540    let creates_or_moves: bool = creates_or_moves_worktrees(&command);
541    // Only a main checkout hands over the configured rules files; `copy_applies` limits it to a creating command.
542    let main_checkout_paths: Vec<String> = match (&location.identity, &loaded) {
543        // Every configured file, required or optional: the copier skips a file Git does not ignore or that is absent.
544        (WorktreeIdentity::MainWorktree { .. }, Some(found)) => found
545            .config
546            .policies
547            .forbidden_strings
548            .rules_files
549            .iter()
550            .flatten()
551            .map(|file| return file.path.clone())
552            .collect(),
553        _ => Vec::new(),
554    };
555    let copy: Option<CopyFacts> =
556        if !copy_bypassed && copy_applies(&location.identity, creates_or_moves, main_checkout_paths.as_slice()) {
557            Some(CopyFacts {
558                identity: location.identity.clone(),
559                creates_or_moves,
560                main_checkout_paths,
561                global_prefix: checks.facts.transaction_context().global_prefix,
562                environment: environment.to_vec(),
563            })
564        } else {
565            None
566        };
567    if !writes_index {
568        let Some(facts) = copy else {
569            return Action::Forward {
570                real_git,
571                arguments: forwarded_arguments,
572                overlay,
573                stderr,
574            };
575        };
576        return Action::ForwardSettled {
577            real_git,
578            arguments: forwarded_arguments,
579            overlay,
580            copy: Box::new(facts),
581            stderr,
582        };
583    }
584    let Some(found) = loaded else {
585        return failure("the repository configuration was not loaded for an index writer");
586    };
587    return Action::ForwardCoordinated {
588        forward: Box::new(CoordinatedForward {
589            real_git,
590            arguments: forwarded_arguments,
591            overlay,
592            identity: location.identity,
593            command,
594            environment: environment.to_vec(),
595            timeout_ms: found.config.concurrency.index_lock.unproven_owner_timeout_ms,
596            copy,
597        }),
598        stderr,
599    };
600}
601
602/// What:
603///  Whether `command` is a push that must upload LFS objects first, and the configuration warning for configured
604///  external hooks, when the same read shows any, as empty text otherwise.
605///  A dry-run push reads the configuration too, for the warning, but uploads nothing.
606/// Why:
607///   A push that sends nothing (`--dry-run`) needs no upload, and a repository without LFS keys needs none either.
608///  One configuration read answers it, made with the invocation's own global options; when Git cannot list its
609///  configuration the hook is added anyway, because it then decides with Git's own answers.
610///  The warning costs no read of its own: a push is the one forwarded command that already holds the snapshot.
611///
612/// In TS you'd write (pseudocode):
613/// ```ts
614/// function needsLfsUpload(command, realGit, overlay): [boolean, string, settings];
615/// ```
616fn needs_lfs_upload(
617    command: &ResolvedCommand,
618    real_git: &Path,
619    overlay: &[(OsString, OsString)],
620) -> (bool, String, Option<Vec<(String, String)>>) {
621    if command.word() != b"push" {
622        return (false, String::new(), None);
623    }
624    let dry_run: bool = parse_push_region(command.region(), &[]).is_ok_and(|region| {
625        return region.dry_run;
626    });
627    let context: GitContext = GitContext {
628        real_git: real_git.to_path_buf(),
629        overlay: overlay.to_vec(),
630        global_prefix: command.arguments[..command.subcommand_index].to_vec(),
631        common_dir: None,
632    };
633    let snapshot: Option<ConfigSnapshot> = match std::env::current_dir() {
634        Ok(directory) => read_config_snapshot(&context, directory.as_path()),
635        Err(_) => None,
636    };
637    let warning: String = external_hooks_warning(snapshot.as_ref()).unwrap_or_default();
638    if dry_run {
639        return (false, warning, None);
640    }
641    return match snapshot {
642        Some(found) => {
643            let uses: bool = uses_lfs(&found);
644            let settings: Option<Vec<(String, String)>> = uses.then(|| {
645                return settings_from_snapshot(&found);
646            });
647            (uses, warning, settings)
648        }
649        None => (true, warning, None),
650    };
651}
652
653/// What:
654///  Turn the forward of a plain push that may be integrated into a run that can integrate it,
655///  and put the Git LFS upload in front of every push that publishes.
656/// Why:
657///   A push that lost a race to another session cannot succeed again, and the wrapper can only look at its status
658///  while it is alive;
659///  the stacked push itself carries `STACKED_PUSH_MARKER`, so it is forwarded as it is and never stacks again.
660///   A real push in a repository with LFS keys also runs as a child that the Git LFS upload (`lfs_upload`) precedes,
661///  the stacked push too, so the parent can explain a failed upload;
662///  the integration step's own dry-run queries never upload.
663///   Every other action is returned unchanged.
664///
665/// In TS you'd write (pseudocode):
666/// ```ts
667/// function stackIfPush(action: Action, environment): Action;
668/// ```
669fn stack_if_push(action: Action, environment: &[(OsString, OsString)]) -> Action {
670    let (real_git, arguments, overlay, copy, stderr) = match action {
671        Action::Forward {
672            real_git,
673            arguments,
674            overlay,
675            stderr,
676        } => (real_git, arguments, overlay, None, stderr),
677        Action::ForwardSettled {
678            real_git,
679            arguments,
680            overlay,
681            copy,
682            stderr,
683        } => (real_git, arguments, overlay, Some(copy), stderr),
684        other => return other,
685    };
686    let context: GitContext = GitContext {
687        real_git: real_git.clone(),
688        overlay: overlay.clone(),
689        global_prefix: Vec::new(),
690        // These queries send no input, so no scratch directory is needed.
691        common_dir: None,
692    };
693    let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
694    let (lfs, hook_warning, lfs_settings): (bool, String, Option<Vec<(String, String)>>) =
695        needs_lfs_upload(&command, real_git.as_path(), overlay.as_slice());
696    let stderr_with_warning: String = format!("{stderr}{hook_warning}");
697    let stacked_already: bool = environment_value(environment, STACKED_PUSH_MARKER).is_some();
698    let stack: bool =
699        !stacked_already && command.word() == b"push" && stackable_option_tokens(command.region()).is_some();
700    // A push that uploads LFS objects runs as a child like a stackable one: the parent that owns the push is the one
701    // that explains a failed upload, and no wrapper is started for it.
702    if stack || lfs {
703        return Action::ForwardPush {
704            forward: Box::new(PushForward {
705                real_git,
706                subcommand_index: command.subcommand_index,
707                arguments: command.arguments,
708                overlay,
709                copy,
710                environment: environment.to_vec(),
711                lfs,
712                lfs_settings,
713                stack,
714            }),
715            stderr: stderr_with_warning,
716        };
717    }
718    let forwarded: Vec<OsString> = arguments;
719    return match copy {
720        Some(facts) => Action::ForwardSettled {
721            real_git,
722            arguments: forwarded,
723            overlay,
724            copy: facts,
725            stderr: stderr_with_warning,
726        },
727        None => Action::Forward {
728            real_git,
729            arguments: forwarded,
730            overlay,
731            stderr: stderr_with_warning,
732        },
733    };
734}
735
736/// What:
737///  Whether the forwarded command needs the repository configuration.
738/// Why:
739///   An index writer reads its patience from it, and a main checkout reads the rules file name from it;
740///  linked worktrees and bare repositories need no configuration for the copy, so their commands load nothing new.
741///
742/// In TS you'd write (pseudocode):
743/// ```ts
744/// isIndexWriter(command) || (identity.kind === 'main-worktree' && createsOrMovesWorktrees(command))
745/// ```
746fn configuration_needed(identity: &WorktreeIdentity, command: &ResolvedCommand, writes_index: bool) -> bool {
747    return writes_index
748        || (matches!(identity, WorktreeIdentity::MainWorktree { .. })
749            && creates_or_moves_worktrees(command));
750}
751
752/// What:
753///  Write text to an output stream,
754///  ignoring a closed stream.
755///       `&mut dyn Write` lends "any writable stream" (`dyn` means the concrete type is
756///       chosen at run time,
757///  like a TS interface value).
758/// Why:
759///   The exit code already carries the result;
760///  a reader that went away must not
761///       turn a diagnostic into a panic.
762///
763/// In TS you'd write (pseudocode):
764/// ```ts
765/// function writeStream(stream: Writable, text: string): void { try { stream.write(text); } catch {} }
766/// ```
767fn write_stream(stream: &mut dyn Write, text: &str) {
768    // What: `let _ = ...` deliberately discards the write's `Result`.
769    // Why:  There is nowhere left to report a failure to write a diagnostic.
770    //
771    // In TS you'd write (pseudocode):
772    // ```ts
773    // try { stream.write(text); } catch { /* reader closed */ }
774    // ```
775    let _ = stream.write_all(text.as_bytes());
776}
777
778/// Plans of the announced queries,
779///  `--version`, `-v`, `--help` and `-h`, against the queries that stay exact.
780#[cfg(test)]
781#[path = "entry_announced_tests.rs"]
782mod announced_tests;
783
784/// Stop controls for a looping,
785///  missing or uninterpretable real Git.
786#[cfg(test)]
787#[path = "entry_stop_tests.rs"]
788mod stop_tests;
789
790/// Decision controls and the shared fixtures stay out of the release executable.
791#[cfg(test)]
792#[path = "entry_tests.rs"]
793mod tests;