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::command_push::stackable_option_tokens;
21use super::push_stacking::{STACKED_PUSH_MARKER, run_stacked_push};
22use super::child_environment::{
23    FORWARD_TARGET_VARIABLE, child_environment_overlay, environment_value,
24};
25use super::effective_target::default_allowed_worktree_dirs;
26use super::forwarding::replace_process_with_real_git;
27use super::index_writer_forward::{CoordinatedForward, CoordinatedRun, run_coordinated_forward};
28use super::index_writer_commands::is_index_writer;
29use super::forwarded_command::{ResolvedCommand, creates_or_moves_worktrees, resolve_forwarded_command};
30use super::invocation_config::{config_invalid_event, load_identity_config};
31use super::config_file::LoadedConfig;
32use super::repository_location::RepositoryLocation;
33use super::transaction_git::GitContext;
34use super::worktree_identity::WorktreeIdentity;
35use super::global_arguments::{GlobalLayout, GlobalOutcome, global_layout};
36use super::management::plan_management;
37/// The linter the executable gives the Markdown policy.
38use super::markdown_linter::executable_linter;
39/// The shipped checks and their constructor.
40use super::policy_checks::{ShippedChecks, shipped_checks};
41/// A forwarded command starts with no candidate content.
42use super::policy_content::LifecycleContent;
43use super::real_git::{ResolutionInputs, process_resolution_inputs, resolve_real_git};
44use super::real_git_candidate::same_file;
45use super::repository_facts::{GitFacts, RepositoryFacts, git_facts};
46use super::rule_require_root::{RequireRootDecision, decide_require_root};
47/// The variable that names the forbidden-strings rules file.
48use super::scanner_selection::RULES_VARIABLE;
49use super::wrapped_command::{WrappedOutcome, run_wrapped_command};
50use super::wrapper_controls::{Controls, no_controls, strip_global_controls};
51use super::worktree_copy_forward::{CopyFacts, ForwardedRun, copy_applies, run_forwarded_git};
52use super::wrapper_invocation::{StrippedInvocation, strip_wrapper_controls};
53/// What:
54///  `OsString` is owned operating-system text of raw OS bytes (sibling `String`
55///       must be UTF-8).
56/// Why:
57///   Arguments and environment values are never decoded.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// // process.argv and process.env, but byte-preserving.
62/// ```
63use std::ffi::OsString;
64/// The trait that gives output streams `.write_all(..)`.
65use std::io::Write;
66/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
67use std::path::{Path, PathBuf};
68
69/// The management namespace:
70///  `git cli-git ...` is handled by the wrapper,
71///  never by Git.
72pub const MANAGEMENT_COMMAND: &str = "cli-git";
73
74/// What:
75///  Explain that another cli-git wrapper selected this executable as real Git.
76///       `&Path` borrows this executable's path for the message.
77/// Why:
78///   Forwarding again would bounce between the two wrappers forever;
79///  stopping
80///       names this executable so the user can fix PATH.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// function forwardedToSelf(ownExecutable: string): Action;
85/// ```
86fn forwarded_to_self(own_executable: &Path) -> Action {
87    // `format!` builds owned text; `.as_str()` lends it to the helper.
88    return failure(
89        format!(
90            "another cli-git wrapper selected this cli-git executable ({}) as real Git. \
91             Forwarding again would never reach Git. Remove the extra cli-git from PATH or \
92             place the real Git executable on PATH.",
93            own_executable.display()
94        )
95        .as_str(),
96    );
97}
98
99/// What:
100///  The caller's home directory from the environment,
101///  if it names one.
102///       `Option<PathBuf>` is "an owned path or nothing".
103/// Why:
104///   The tool caches exempt from linked-worktree enforcement live under the home
105///       directory;
106///  it comes from the injected environment,
107///  never from a fixed path.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// const home = environment.HOME === undefined || environment.HOME === '' ? undefined : environment.HOME;
112/// ```
113fn home_directory(environment: &[(OsString, OsString)]) -> Option<PathBuf> {
114    // `if let Some(home) = ... && cond` runs only when the variable is set and not empty.
115    if let Some(home) = environment_value(environment, "HOME")
116        && !home.is_empty()
117    {
118        // `Some(...)` is the "present" case; `PathBuf::from` turns the text into a path.
119        return Some(PathBuf::from(home));
120    }
121    // `None` is the "absent" case.
122    return None;
123}
124
125/// What:
126///  Decide the action for one invocation from injected process facts.
127///       `&[OsString]` borrows the arguments after the program name;
128///       `&[(OsString, OsString)]` borrows the environment as name/value pairs.
129/// Why:
130///   Order matters.
131///  The recursion check runs before anything else.
132///  Wrapper controls
133///       written before the subcommand are removed first,
134///  because Git's own reading of
135///       the arguments stops at them and would hide the subcommand from every later
136///       decision.
137///  The management namespace is answered by the wrapper.
138///  Every other
139///       command goes through the wrapped-command lifecycle with real Git behind it.
140///
141/// In TS you'd write (pseudocode):
142/// ```ts
143/// function planInvocation(args: string[], environment: [string, string][], inputs: ResolutionInputs): Action;
144/// ```
145pub fn plan_invocation(
146    arguments: &[OsString],
147    environment: &[(OsString, OsString)],
148    inputs: &ResolutionInputs,
149) -> Action {
150    // `if let Some(target) = ... && cond` runs only when a forwarding wrapper left its
151    // marker and that marker names this executable.
152    if let Some(target) = environment_value(environment, FORWARD_TARGET_VARIABLE)
153        && same_file(Path::new(&target), inputs.own_executable.as_path())
154    {
155        return forwarded_to_self(inputs.own_executable.as_path());
156    }
157    // `mut` allows the scan to record what the removed controls asked for.
158    let mut global_controls: Controls = no_controls();
159    // `&mut global_controls` lends the record for writing.
160    let global_clean: Vec<OsString> = strip_global_controls(arguments, &mut global_controls);
161    // `.as_slice()` lends the owned list as a borrowed view.
162    let layout: GlobalLayout = global_layout(global_clean.as_slice());
163    if layout.outcome == GlobalOutcome::Command
164        && global_clean[layout.prefix_len] == MANAGEMENT_COMMAND
165    {
166        // `&list[a..]` borrows from index `a` on; `&list[..a]` borrows up to it.
167        return plan_management(
168            &global_clean[layout.prefix_len + 1..],
169            &global_clean[..layout.prefix_len],
170            &global_controls,
171            environment,
172            inputs,
173        );
174    }
175    // What: `match` on the resolver's `Result`: keep the path or stop with its message.
176    // Why:  Without real Git there is nothing to forward to.
177    //
178    // In TS you'd write (pseudocode):
179    // ```ts
180    // let realGit; try { realGit = resolveRealGit(inputs); } catch (e) { return failure(String(e)); }
181    // ```
182    let real_git: PathBuf = match resolve_real_git(inputs) {
183        Ok(found) => found,
184        Err(error) => return failure(error.to_string().as_str()),
185    };
186    let overlay: Vec<(OsString, OsString)> =
187        child_environment_overlay(environment, real_git.as_path());
188    let stripped: StrippedInvocation = strip_wrapper_controls(arguments);
189    // The global options before the subcommand select the repository for every fact query.
190    let facts: GitFacts = git_facts(
191        real_git.as_path(),
192        &stripped.arguments[..stripped.layout.prefix_len],
193        overlay.as_slice(),
194    );
195    // `mut` lets the lifecycle cache facts and state what content policies can read.
196    let mut checks: ShippedChecks<GitFacts> = shipped_checks(
197        facts,
198        // `.clone()` copies the stripped arguments for the rule cores.
199        stripped.arguments.clone(),
200        LifecycleContent::None,
201        default_allowed_worktree_dirs(
202            environment,
203            // `.as_deref()` lends the optional owned path as an optional borrowed one.
204            home_directory(environment).as_deref(),
205        ),
206    );
207    // The forbidden-strings rules variable is read from this invocation's environment.
208    checks.scanner_settings.rules_variable = environment_value(environment, RULES_VARIABLE);
209    // The Markdown linter is found on, and runs with, this invocation's environment.
210    checks.markdown.linter = Box::new(executable_linter(environment));
211    // `match` picks by variant and binds the fields each ending carries.
212    match run_wrapped_command(&stripped, environment, &mut checks) {
213        // `arguments: forwarded` binds the field under a new name.
214        WrappedOutcome::Forward {
215            arguments: forwarded,
216            stderr,
217        } => {
218            // The opt-out, Git's help and version forms, and the commands that need no repository
219            // (`version`, `help`, `init`, `clone`, user-level `config`) never settle a copy.
220            let copy_bypassed: bool = stripped.controls.skip_worktree_copy
221                || stripped.layout.outcome != GlobalOutcome::Command
222                || decide_require_root(forwarded.as_slice(), &[])
223                    != RequireRootDecision::NeedsRepositoryRoot;
224            return stack_if_push(
225                forward_action(
226                    real_git,
227                    forwarded,
228                    overlay,
229                    stderr,
230                    environment,
231                    copy_bypassed,
232                    &mut checks,
233                ),
234                environment,
235            );
236        }
237        WrappedOutcome::Exit { code, stderr } => {
238            return Action::Exit {
239                code,
240                // `String::new()` is empty owned text: wrapped commands report on standard error.
241                stdout: String::new(),
242                stderr,
243            };
244        }
245    }
246}
247
248/// What:
249///  Run one invocation with the real process facts and return its exit code.
250///       `i32` is a signed 32-bit integer,
251///  the type of process exit codes.
252///       Returns only when Git was not started;
253///  a forwarded command never returns.
254/// Why:
255///   This is everything the executable's `main` does;
256///  keeping it in the library
257///       lets the same code be measured by tests and mutation runs.
258///
259/// In TS you'd write (pseudocode):
260/// ```ts
261/// function runProcess(args: string[], environment: [string, string][]): number;
262/// ```
263pub fn run_process(arguments: &[OsString], environment: &[(OsString, OsString)]) -> i32 {
264    let action: Action = match process_resolution_inputs(environment) {
265        Ok(inputs) => plan_invocation(arguments, environment, &inputs),
266        Err(error) => failure(
267            format!(
268                "cannot determine this executable or the current directory, so real Git \
269                 cannot be selected safely: {error}"
270            )
271            .as_str(),
272        ),
273    };
274    // `match` picks by variant and binds the fields each one carries.
275    match action {
276        Action::Forward {
277            real_git,
278            arguments: forwarded,
279            overlay,
280            stderr,
281        } => {
282            // Warning events reach the caller before Git's own output.
283            write_stream(&mut std::io::stderr(), stderr.as_str());
284            let error: std::io::Error = replace_process_with_real_git(
285                real_git.as_path(),
286                forwarded.as_slice(),
287                overlay.as_slice(),
288            );
289            write_stream(
290                &mut std::io::stderr(),
291                format!(
292                    "cli-git: cannot start {}: {error}. Hint: if you moved or upgraded Git, check that the file exists and is executable with `ls -l {}`, \
293                     then reinstall Git or the wrapper; no Git command ran.\n",
294                    real_git.display(),
295                    real_git.display()
296                )
297                .as_str(),
298            );
299            return ENGINE_FAILURE_EXIT_CODE;
300        }
301        Action::ForwardCoordinated { forward, stderr } => {
302            write_stream(&mut std::io::stderr(), stderr.as_str());
303            return run_coordinated_action(&forward);
304        }
305        Action::ForwardSettled {
306            real_git,
307            arguments: forwarded,
308            overlay,
309            copy,
310            stderr,
311        } => {
312            write_stream(&mut std::io::stderr(), stderr.as_str());
313            return finish_forwarded(run_forwarded_git(
314                real_git.as_path(),
315                forwarded.as_slice(),
316                overlay.as_slice(),
317                Some(&*copy),
318            ));
319        }
320        Action::ForwardPush { forward, stderr } => {
321            write_stream(&mut std::io::stderr(), stderr.as_str());
322            return run_stacked_push(&forward);
323        }
324        Action::Exit {
325            code,
326            stdout,
327            stderr,
328        } => {
329            // `&mut` lends each stream for writing.
330            write_stream(&mut std::io::stdout(), stdout.as_str());
331            write_stream(&mut std::io::stderr(), stderr.as_str());
332            return code;
333        }
334    }
335}
336
337/// What:
338///  The exit code of a forwarded run, after printing the text of a run that failed.
339/// Why:
340///   A settled run keeps the wrapper alive around Git, so the exit code returns here instead of replacing the process.
341///
342/// In TS you'd write (pseudocode):
343/// ```ts
344/// process.exitCode = outcome.exitCode;
345/// ```
346fn finish_forwarded(run: ForwardedRun) -> i32 {
347    // `match` on the ending: a status, or a failure with its own text.
348    match run {
349        ForwardedRun::Ran(code) => return code,
350        ForwardedRun::Failed { stderr, code } => {
351            write_stream(&mut std::io::stderr(), stderr.as_str());
352            return code;
353        }
354    }
355}
356
357/// What:
358///  Run a coordinated forward:
359///  print its failure,
360///  run Git under the landing lock,
361///  or replace the process when no coordination applies after all.
362/// Why:
363///   The lock must outlive Git,
364///  so only a run that takes no lock may replace the process.
365///
366/// In TS you'd write (pseudocode):
367/// ```ts
368/// const outcome = await forwardToRealGit({ ... }); process.exitCode = outcome.exitCode;
369/// ```
370fn run_coordinated_action(forward: &CoordinatedForward) -> i32 {
371    match run_coordinated_forward(forward) {
372        CoordinatedRun::Ran(code) => return code,
373        CoordinatedRun::Failed { stderr, code } => {
374            write_stream(&mut std::io::stderr(), stderr.as_str());
375            return code;
376        }
377        CoordinatedRun::NotCoordinated => {
378            let error: std::io::Error = replace_process_with_real_git(
379                forward.real_git.as_path(),
380                forward.arguments.as_slice(),
381                forward.overlay.as_slice(),
382            );
383            write_stream(
384                &mut std::io::stderr(),
385                format!("cli-git: cannot start {}: {error}\n", forward.real_git.display()).as_str(),
386            );
387            return ENGINE_FAILURE_EXIT_CODE;
388        }
389    }
390}
391
392/// What:
393///  Choose how a forwarded command runs:
394///  plainly,
395///  under the landing lock when it writes the real index,
396///  and settled by the linked-worktree copy when it runs from a linked worktree or a bare repository
397///  (or creates a worktree from a main checkout whose configuration names the rules file).
398///  `copy_bypassed` is true for the wrapper's opt-out and for Git's help and version forms.
399/// Why:
400///   Only these commands need the wrapper to outlive Git or to hold a lock around it;
401///  everything else keeps the exact process replacement.
402///
403/// In TS you'd write (pseudocode):
404/// ```ts
405/// if (!isIndexWriter(command) && !copyApplies) forward(args); else await forwardToRealGit({ ..., unprovenOwnerTimeoutMs });
406/// ```
407fn forward_action(
408    real_git: PathBuf,
409    arguments: Vec<OsString>,
410    overlay: Vec<(OsString, OsString)>,
411    stderr: String,
412    environment: &[(OsString, OsString)],
413    copy_bypassed: bool,
414    checks: &mut ShippedChecks<GitFacts>,
415) -> Action {
416    let context: GitContext = GitContext {
417        real_git: real_git.clone(),
418        overlay: overlay.clone(),
419        global_prefix: Vec::new(),
420    };
421    let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
422    let writes_index: bool = is_index_writer(&command);
423    if !writes_index && copy_bypassed {
424        return Action::Forward {
425            real_git,
426            arguments,
427            overlay,
428            stderr,
429        };
430    }
431    let location: RepositoryLocation = match checks.facts.location() {
432        Ok(found) => found,
433        Err(message) => return failure(message.as_str()),
434    };
435    if location.identity == WorktreeIdentity::OutsideWorktree {
436        return Action::Forward {
437            real_git,
438            arguments,
439            overlay,
440            stderr,
441        };
442    }
443    let loaded: Option<LoadedConfig> = match configuration_needed(&location.identity, &command, writes_index) {
444        false => None,
445        true => match load_identity_config(&location.identity) {
446            Ok(found) => Some(found),
447            Err(error) => {
448                return Action::Exit {
449                    code: ENGINE_FAILURE_EXIT_CODE,
450                    stdout: String::new(),
451                    stderr: config_invalid_event(&error),
452                };
453            }
454        },
455    };
456    let creates_or_moves: bool = creates_or_moves_worktrees(&command);
457    // Only a main checkout hands over the configured rules file; `copy_applies` limits it to a creating command.
458    let main_checkout_paths: Vec<String> = match (&location.identity, &loaded) {
459        (WorktreeIdentity::MainWorktree { .. }, Some(found)) => found
460            .config
461            .policies
462            .forbidden_strings
463            .rules_file
464            .iter()
465            .cloned()
466            .collect(),
467        _ => Vec::new(),
468    };
469    let copy: Option<CopyFacts> =
470        if !copy_bypassed && copy_applies(&location.identity, creates_or_moves, main_checkout_paths.as_slice()) {
471            Some(CopyFacts {
472                identity: location.identity.clone(),
473                creates_or_moves,
474                main_checkout_paths,
475                global_prefix: checks.facts.transaction_context().global_prefix,
476                environment: environment.to_vec(),
477            })
478        } else {
479            None
480        };
481    if !writes_index {
482        let Some(facts) = copy else {
483            return Action::Forward {
484                real_git,
485                arguments,
486                overlay,
487                stderr,
488            };
489        };
490        return Action::ForwardSettled {
491            real_git,
492            arguments,
493            overlay,
494            copy: Box::new(facts),
495            stderr,
496        };
497    }
498    let Some(found) = loaded else {
499        return failure("the repository configuration was not loaded for an index writer");
500    };
501    return Action::ForwardCoordinated {
502        forward: Box::new(CoordinatedForward {
503            real_git,
504            arguments,
505            overlay,
506            identity: location.identity,
507            command,
508            environment: environment.to_vec(),
509            timeout_ms: found.config.concurrency.index_lock.unproven_owner_timeout_ms,
510            copy,
511        }),
512        stderr,
513    };
514}
515
516/// What:
517///  Turn the forward of a plain push that may be integrated into a run that can integrate it.
518/// Why:
519///   A push that lost a race to another session cannot succeed again, and the wrapper can only look at its status
520///  while it is alive;
521///  the stacked push itself carries `STACKED_PUSH_MARKER`, so it is forwarded as it is and never stacks again.
522///   Every other action is returned unchanged.
523///
524/// In TS you'd write (pseudocode):
525/// ```ts
526/// function stackIfPush(action: Action, environment): Action;
527/// ```
528fn stack_if_push(action: Action, environment: &[(OsString, OsString)]) -> Action {
529    if environment_value(environment, STACKED_PUSH_MARKER).is_some() {
530        return action;
531    }
532    let (real_git, arguments, overlay, copy, stderr) = match action {
533        Action::Forward {
534            real_git,
535            arguments,
536            overlay,
537            stderr,
538        } => (real_git, arguments, overlay, None, stderr),
539        Action::ForwardSettled {
540            real_git,
541            arguments,
542            overlay,
543            copy,
544            stderr,
545        } => (real_git, arguments, overlay, Some(copy), stderr),
546        other => return other,
547    };
548    let context: GitContext = GitContext {
549        real_git: real_git.clone(),
550        overlay: overlay.clone(),
551        global_prefix: Vec::new(),
552    };
553    let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
554    if command.word() == b"push" && stackable_option_tokens(command.region()).is_some() {
555        return Action::ForwardPush {
556            forward: Box::new(PushForward {
557                real_git,
558                subcommand_index: command.subcommand_index,
559                arguments: command.arguments,
560                overlay,
561                copy,
562                environment: environment.to_vec(),
563            }),
564            stderr,
565        };
566    }
567    return match copy {
568        Some(facts) => Action::ForwardSettled {
569            real_git,
570            arguments,
571            overlay,
572            copy: facts,
573            stderr,
574        },
575        None => Action::Forward {
576            real_git,
577            arguments,
578            overlay,
579            stderr,
580        },
581    };
582}
583
584/// What:
585///  Whether the forwarded command needs the repository configuration.
586/// Why:
587///   An index writer reads its patience from it, and a main checkout reads the rules file name from it;
588///  linked worktrees and bare repositories need no configuration for the copy, so their commands load nothing new.
589///
590/// In TS you'd write (pseudocode):
591/// ```ts
592/// isIndexWriter(command) || (identity.kind === 'main-worktree' && createsOrMovesWorktrees(command))
593/// ```
594fn configuration_needed(identity: &WorktreeIdentity, command: &ResolvedCommand, writes_index: bool) -> bool {
595    return writes_index
596        || (matches!(identity, WorktreeIdentity::MainWorktree { .. })
597            && creates_or_moves_worktrees(command));
598}
599
600/// What:
601///  Write text to an output stream,
602///  ignoring a closed stream.
603///       `&mut dyn Write` lends "any writable stream" (`dyn` means the concrete type is
604///       chosen at run time,
605///  like a TS interface value).
606/// Why:
607///   The exit code already carries the result;
608///  a reader that went away must not
609///       turn a diagnostic into a panic.
610///
611/// In TS you'd write (pseudocode):
612/// ```ts
613/// function writeStream(stream: Writable, text: string): void { try { stream.write(text); } catch {} }
614/// ```
615fn write_stream(stream: &mut dyn Write, text: &str) {
616    // What: `let _ = ...` deliberately discards the write's `Result`.
617    // Why:  There is nowhere left to report a failure to write a diagnostic.
618    //
619    // In TS you'd write (pseudocode):
620    // ```ts
621    // try { stream.write(text); } catch { /* reader closed */ }
622    // ```
623    let _ = stream.write_all(text.as_bytes());
624}
625
626/// Stop controls for a looping,
627///  missing or uninterpretable real Git.
628#[cfg(test)]
629#[path = "entry_stop_tests.rs"]
630mod stop_tests;
631
632/// Decision controls and the shared fixtures stay out of the release executable.
633#[cfg(test)]
634#[path = "entry_tests.rs"]
635mod tests;