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;