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;