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