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!(
287                    "cli-git: cannot start {}: {error}. Hint: if you moved or upgraded Git, check that the file exists and is executable with `ls -l {}`, \
288                     then reinstall Git or the wrapper; no Git command ran.\n",
289                    real_git.display(),
290                    real_git.display()
291                )
292                .as_str(),
293            );
294            return ENGINE_FAILURE_EXIT_CODE;
295        }
296        Action::ForwardCoordinated { forward, stderr } => {
297            write_stream(&mut std::io::stderr(), stderr.as_str());
298            return run_coordinated_action(&forward);
299        }
300        Action::ForwardSettled {
301            real_git,
302            arguments: forwarded,
303            overlay,
304            copy,
305            stderr,
306        } => {
307            write_stream(&mut std::io::stderr(), stderr.as_str());
308            return finish_forwarded(run_forwarded_git(
309                real_git.as_path(),
310                forwarded.as_slice(),
311                overlay.as_slice(),
312                Some(&*copy),
313            ));
314        }
315        Action::Exit {
316            code,
317            stdout,
318            stderr,
319        } => {
320            // `&mut` lends each stream for writing.
321            write_stream(&mut std::io::stdout(), stdout.as_str());
322            write_stream(&mut std::io::stderr(), stderr.as_str());
323            return code;
324        }
325    }
326}
327
328/// What:
329///  The exit code of a forwarded run, after printing the text of a run that failed.
330/// Why:
331///   A settled run keeps the wrapper alive around Git, so the exit code returns here instead of replacing the process.
332///
333/// In TS you'd write (pseudocode):
334/// ```ts
335/// process.exitCode = outcome.exitCode;
336/// ```
337fn finish_forwarded(run: ForwardedRun) -> i32 {
338    // `match` on the ending: a status, or a failure with its own text.
339    match run {
340        ForwardedRun::Ran(code) => return code,
341        ForwardedRun::Failed { stderr, code } => {
342            write_stream(&mut std::io::stderr(), stderr.as_str());
343            return code;
344        }
345    }
346}
347
348/// What:
349///  Run a coordinated forward:
350///  print its failure,
351///  run Git under the landing lock,
352///  or replace the process when no coordination applies after all.
353/// Why:
354///   The lock must outlive Git,
355///  so only a run that takes no lock may replace the process.
356///
357/// In TS you'd write (pseudocode):
358/// ```ts
359/// const outcome = await forwardToRealGit({ ... }); process.exitCode = outcome.exitCode;
360/// ```
361fn run_coordinated_action(forward: &CoordinatedForward) -> i32 {
362    match run_coordinated_forward(forward) {
363        CoordinatedRun::Ran(code) => return code,
364        CoordinatedRun::Failed { stderr, code } => {
365            write_stream(&mut std::io::stderr(), stderr.as_str());
366            return code;
367        }
368        CoordinatedRun::NotCoordinated => {
369            let error: std::io::Error = replace_process_with_real_git(
370                forward.real_git.as_path(),
371                forward.arguments.as_slice(),
372                forward.overlay.as_slice(),
373            );
374            write_stream(
375                &mut std::io::stderr(),
376                format!("cli-git: cannot start {}: {error}\n", forward.real_git.display()).as_str(),
377            );
378            return ENGINE_FAILURE_EXIT_CODE;
379        }
380    }
381}
382
383/// What:
384///  Choose how a forwarded command runs:
385///  plainly,
386///  under the landing lock when it writes the real index,
387///  and settled by the linked-worktree copy when it runs from a linked worktree or a bare repository
388///  (or creates a worktree from a main checkout whose configuration names the rules file).
389///  `copy_bypassed` is true for the wrapper's opt-out and for Git's help and version forms.
390/// Why:
391///   Only these commands need the wrapper to outlive Git or to hold a lock around it;
392///  everything else keeps the exact process replacement.
393///
394/// In TS you'd write (pseudocode):
395/// ```ts
396/// if (!isIndexWriter(command) && !copyApplies) forward(args); else await forwardToRealGit({ ..., unprovenOwnerTimeoutMs });
397/// ```
398fn forward_action(
399    real_git: PathBuf,
400    arguments: Vec<OsString>,
401    overlay: Vec<(OsString, OsString)>,
402    stderr: String,
403    environment: &[(OsString, OsString)],
404    copy_bypassed: bool,
405    checks: &mut ShippedChecks<GitFacts>,
406) -> Action {
407    let context: GitContext = GitContext {
408        real_git: real_git.clone(),
409        overlay: overlay.clone(),
410        global_prefix: Vec::new(),
411    };
412    let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
413    let writes_index: bool = is_index_writer(&command);
414    if !writes_index && copy_bypassed {
415        return Action::Forward {
416            real_git,
417            arguments,
418            overlay,
419            stderr,
420        };
421    }
422    let location: RepositoryLocation = match checks.facts.location() {
423        Ok(found) => found,
424        Err(message) => return failure(message.as_str()),
425    };
426    if location.identity == WorktreeIdentity::OutsideWorktree {
427        return Action::Forward {
428            real_git,
429            arguments,
430            overlay,
431            stderr,
432        };
433    }
434    let loaded: Option<LoadedConfig> = match configuration_needed(&location.identity, &command, writes_index) {
435        false => None,
436        true => match load_identity_config(&location.identity) {
437            Ok(found) => Some(found),
438            Err(error) => {
439                return Action::Exit {
440                    code: ENGINE_FAILURE_EXIT_CODE,
441                    stdout: String::new(),
442                    stderr: config_invalid_event(&error),
443                };
444            }
445        },
446    };
447    let creates_or_moves: bool = creates_or_moves_worktrees(&command);
448    // Only a main checkout hands over the configured rules file; `copy_applies` limits it to a creating command.
449    let main_checkout_paths: Vec<String> = match (&location.identity, &loaded) {
450        (WorktreeIdentity::MainWorktree { .. }, Some(found)) => found
451            .config
452            .policies
453            .forbidden_strings
454            .rules_file
455            .iter()
456            .cloned()
457            .collect(),
458        _ => Vec::new(),
459    };
460    let copy: Option<CopyFacts> =
461        if !copy_bypassed && copy_applies(&location.identity, creates_or_moves, main_checkout_paths.as_slice()) {
462            Some(CopyFacts {
463                identity: location.identity.clone(),
464                creates_or_moves,
465                main_checkout_paths,
466                global_prefix: checks.facts.transaction_context().global_prefix,
467                environment: environment.to_vec(),
468            })
469        } else {
470            None
471        };
472    if !writes_index {
473        let Some(facts) = copy else {
474            return Action::Forward {
475                real_git,
476                arguments,
477                overlay,
478                stderr,
479            };
480        };
481        return Action::ForwardSettled {
482            real_git,
483            arguments,
484            overlay,
485            copy: Box::new(facts),
486            stderr,
487        };
488    }
489    let Some(found) = loaded else {
490        return failure("the repository configuration was not loaded for an index writer");
491    };
492    return Action::ForwardCoordinated {
493        forward: Box::new(CoordinatedForward {
494            real_git,
495            arguments,
496            overlay,
497            identity: location.identity,
498            command,
499            environment: environment.to_vec(),
500            timeout_ms: found.config.concurrency.index_lock.unproven_owner_timeout_ms,
501            copy,
502        }),
503        stderr,
504    };
505}
506
507/// What:
508///  Whether the forwarded command needs the repository configuration.
509/// Why:
510///   An index writer reads its patience from it, and a main checkout reads the rules file name from it;
511///  linked worktrees and bare repositories need no configuration for the copy, so their commands load nothing new.
512///
513/// In TS you'd write (pseudocode):
514/// ```ts
515/// isIndexWriter(command) || (identity.kind === 'main-worktree' && createsOrMovesWorktrees(command))
516/// ```
517fn configuration_needed(identity: &WorktreeIdentity, command: &ResolvedCommand, writes_index: bool) -> bool {
518    return writes_index
519        || (matches!(identity, WorktreeIdentity::MainWorktree { .. })
520            && creates_or_moves_worktrees(command));
521}
522
523/// What:
524///  Write text to an output stream,
525///  ignoring a closed stream.
526///       `&mut dyn Write` lends "any writable stream" (`dyn` means the concrete type is
527///       chosen at run time,
528///  like a TS interface value).
529/// Why:
530///   The exit code already carries the result;
531///  a reader that went away must not
532///       turn a diagnostic into a panic.
533///
534/// In TS you'd write (pseudocode):
535/// ```ts
536/// function writeStream(stream: Writable, text: string): void { try { stream.write(text); } catch {} }
537/// ```
538fn write_stream(stream: &mut dyn Write, text: &str) {
539    // What: `let _ = ...` deliberately discards the write's `Result`.
540    // Why:  There is nowhere left to report a failure to write a diagnostic.
541    //
542    // In TS you'd write (pseudocode):
543    // ```ts
544    // try { stream.write(text); } catch { /* reader closed */ }
545    // ```
546    let _ = stream.write_all(text.as_bytes());
547}
548
549/// Stop controls for a looping,
550///  missing or uninterpretable real Git.
551#[cfg(test)]
552#[path = "entry_stop_tests.rs"]
553mod stop_tests;
554
555/// Decision controls and the shared fixtures stay out of the release executable.
556#[cfg(test)]
557#[path = "entry_tests.rs"]
558mod tests;