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;