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