git_policy_cli/wrapped_command.rs
1//! What:
2//! Decide what one wrapped Git command does once real Git is known:
3//! forward it,
4//! possibly rewritten,
5//! or stop with policy events or a refusal.
6//! Why:
7//! This is the whole lifecycle of a forwarded command in one place and in the
8//! installed wrapper's order:
9//! unported work is refused,
10//! configuration is loaded only
11//! for commands that need it,
12//! the policy pass runs,
13//! and a real push is stopped until
14//! its own lifecycle exists.
15//! A read-only command never reads configuration and asks
16//! Git at most where it runs.
17//!
18//! In TS you'd write (pseudocode):
19//! ```ts
20//! // const outcome = await runWrappedCommand(stripped, environment, checks);
21//! ```
22
23/// What:
24/// `use` brings names from sibling files into this file;
25/// `super::` means "the parent
26/// module",
27/// where every sibling file of this crate is declared.
28/// Why:
29/// The lifecycle joins control stripping,
30/// the frontier,
31/// configuration and the pass.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// import { runPolicyPass } from './policy_pass.ts';
36/// ```
37use super::action::ENGINE_FAILURE_EXIT_CODE;
38use super::auto_push_single_flight::PushGit;
39use super::child_environment::environment_with_overlay;
40/// `git add` asks for what it would stage.
41use super::candidate_prediction::CandidateRequest;
42use super::command_options::OptionError;
43use super::command_push::{PushRegion, parse_push_region};
44use super::config_loading::{ConfigLoading, classify_config_loading};
45use super::command_commit::{CommitRegion, parse_commit_region};
46use super::commit_command::{CommitCommand, run_commit_command};
47use super::config_schema::{CliGitConfig, PolicyConfig};
48use super::diagnostics::EngineFailureCode;
49use super::global_arguments::GlobalOutcome;
50use super::invocation_config::{config_invalid_event, load_identity_config};
51use super::manual_push_lifecycle::manual_push_scope;
52use super::policy_checks::ShippedChecks;
53/// What the command offers its content policies.
54use super::policy_content::LifecycleContent;
55use super::policy_engine::{
56 StageEnd, StageRequest, StageResult, any_policy_applies, pass_exit_code, run_policy_stage,
57};
58use super::policy_events::{PolicyEvent, render_policy_events};
59use super::policy_pass::{PassResult, policies_of_kind, run_policy_pass};
60use super::policy_registry::PolicyId;
61use super::policy_trigger::Trigger;
62use super::repository_facts::RepositoryFacts;
63use super::repository_location::RepositoryLocation;
64/// Startup recovery as a command step.
65use super::transaction_gate::{Gate, recover_for_command};
66/// A valid preparation lease marks a command started by a hook of a live transaction.
67use super::transaction_lease::has_valid_preparation_lease;
68use super::unported::{Unported, unported_from_unavailable, unported_notice};
69use super::worktree_identity::worktree_root;
70use super::wrapper_invocation::{StrippedInvocation, command_region, command_word};
71/// What:
72/// `OsString` is owned operating-system text of raw bytes.
73/// Sibling the reader might
74/// expect:
75/// `String`,
76/// which must be valid UTF-8.
77/// Why:
78/// Arguments and environment values are never decoded.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// // string, but byte-preserving.
83/// ```
84use std::ffi::OsString;
85/// Paths of the probe's working directory.
86use std::path::{Path, PathBuf};
87
88/// What:
89/// How the lifecycle of one wrapped command ended.
90/// An `enum` is a closed set of named
91/// alternatives;
92/// each carries the values its ending needs.
93/// `#[derive(...)]` asks the
94/// compiler to generate copying,
95/// debug printing and `==`.
96/// Why:
97/// The caller either becomes Git with these arguments or exits with this code;
98/// both may first print event lines on standard error.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// type WrappedOutcome = { kind: 'forward'; args: string[]; stderr: string } | { kind: 'exit'; code: number; stderr: string };
103/// ```
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub enum WrappedOutcome {
106 /// Run real Git with these arguments.
107 Forward {
108 /// The arguments for Git:
109 /// no wrapper control,
110 /// fixed transforms applied.
111 arguments: Vec<OsString>,
112 /// Complete event lines for standard error,
113 /// written before Git starts.
114 stderr: String,
115 },
116 /// Stop without running Git.
117 Exit {
118 /// Process exit code:
119 /// 1 for a policy rejection,
120 /// 2 for a failure or a refusal.
121 code: i32,
122 /// Complete text for standard error.
123 stderr: String,
124 },
125}
126
127/// What:
128/// The ending for a command that needs unported work:
129/// events so far,
130/// then the notice.
131/// `String` is the owned event text;
132/// `&Unported` borrows the reason;
133/// `&str` borrows
134/// the command word.
135/// Why:
136/// Every refusal exits 2,
137/// whether it was found before or inside the policy pass.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// function refused(events: string, what: Unported, command: string): WrappedOutcome;
142/// ```
143fn refused(events: String, what: &Unported, command: &str) -> WrappedOutcome {
144 // `mut` allows appending the notice after the events.
145 let mut stderr: String = events;
146 // `.as_str()` lends the owned notice as borrowed text.
147 stderr.push_str(unported_notice(what, command).as_str());
148 return WrappedOutcome::Exit {
149 code: ENGINE_FAILURE_EXIT_CODE,
150 stderr,
151 };
152}
153
154/// What:
155/// The ending of a stage that did not let the command proceed,
156/// or nothing when it
157/// did.
158/// `&[PolicyEvent]` borrows every event so far;
159/// `Option<WrappedOutcome>` is "an
160/// ending or nothing".
161/// Why:
162/// The pre-forward pass and the push gate end the same way:
163/// an unavailable stage is
164/// a refusal,
165/// a failed one exits 2,
166/// and an error finding exits 1,
167/// each after the
168/// events gathered so far.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// function stageEnding(events: PolicyEvent[], end: StageEnd, command: string): WrappedOutcome | undefined;
173/// ```
174fn stage_ending(events: &[PolicyEvent], end: StageEnd, command: &str) -> Option<WrappedOutcome> {
175 let rendered: String = render_policy_events(0, events);
176 // `if let StageEnd::Unavailable(x) = end` runs only for that ending and binds what it carries.
177 if let StageEnd::Unavailable(unavailable) = end {
178 // `Some(x)` is the "present" case of `Option`.
179 return Some(refused(
180 rendered,
181 &unported_from_unavailable(unavailable),
182 command,
183 ));
184 }
185 let code: i32 = pass_exit_code(events, end);
186 if code == 0 {
187 // `None` is the "absent" case: the command may proceed.
188 return None;
189 }
190 return Some(WrappedOutcome::Exit {
191 code,
192 stderr: rendered,
193 });
194}
195
196/// What:
197/// Whether Git's own option table reads the tokens after `push` as a dry run.
198/// `&[OsString]` borrows those tokens,
199/// already free of wrapper controls.
200/// Why:
201/// Only a dry run publishes nothing.
202/// A region the table refuses is not known to be
203/// a dry run,
204/// so it is treated as a real push.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// function pushIsDryRun(region: string[]): boolean { try { return parsePushRegion(region).isDryRun; } catch { return false; } }
209/// ```
210fn push_is_dry_run(region: &[OsString]) -> bool {
211 // `&[]` is an empty list of wrapper flags: they were removed before this point.
212 let parsed: Result<PushRegion, OptionError> = parse_push_region(region, &[]);
213 // `match` unpacks the `Result`: `Ok` carries the facts, `Err(_)` ignores the refusal.
214 match parsed {
215 Ok(found) => return found.dry_run,
216 Err(_) => return false,
217 }
218}
219
220/// What:
221/// Every shipped policy in registry order:
222/// the built-in ones,
223/// then the optional ones.
224/// Why:
225/// The push gate asks all of them at once,
226/// as the installed wrapper does.
227///
228/// In TS you'd write (pseudocode):
229/// ```ts
230/// const everyPolicy = [...builtInPolicies, ...optionalPolicies];
231/// ```
232fn every_policy() -> Vec<PolicyId> {
233 // `mut` allows appending the second kind.
234 let mut policies: Vec<PolicyId> = policies_of_kind(false);
235 policies.extend(policies_of_kind(true));
236 return policies;
237}
238
239/// What:
240/// Prepare a command that loads configuration:
241/// recover dead transactions,
242/// read the
243/// configuration of the worktree Git reports,
244/// and say what a content policy may
245/// read.
246/// Returns the effective policy settings,
247/// or the ending that stops the command.
248/// `Result<PolicyConfig, WrappedOutcome>` is "settings,
249/// or an ending".
250/// Why:
251/// The order is the installed wrapper's:
252/// dead transactions are recovered before
253/// configuration,
254/// and a commit is refused only after its configuration was accepted,
255/// so an invalid file is always reported as such.
256///
257/// In TS you'd write (pseudocode):
258/// ```ts
259/// async function prepareGuardedCommand(stripped, environment, checks): Promise<PolicyConfig>; // throws the ending
260/// ```
261fn prepare_guarded_command<F: RepositoryFacts>(
262 stripped: &StrippedInvocation,
263 environment: &[(OsString, OsString)],
264 checks: &mut ShippedChecks<F>,
265) -> Result<CliGitConfig, WrappedOutcome> {
266 // `match` unpacks the location answer; a failure to ask Git stops the command.
267 let location: RepositoryLocation = match checks.facts.location() {
268 Ok(found) => found,
269 Err(message) => {
270 return Err(WrappedOutcome::Exit {
271 code: ENGINE_FAILURE_EXIT_CODE,
272 // `format!` builds the owned, line-terminated text.
273 stderr: format!("cli-git: {message}\n"),
274 });
275 }
276 };
277 // Dead transactions are recovered before configuration loads. A live transaction no longer
278 // refuses a guarded command: an index writer waits for its landing lock when it is forwarded,
279 // and a commit still stops at its own frontier below.
280 // A command inside a live transaction's hook inherits its preparation lease; recovery would only find that live owner.
281 if !has_valid_preparation_lease(environment) {
282 match recover_for_command(&checks.facts.transaction_context(), &location.identity) {
283 Gate::Clear | Gate::Live(_) => {}
284 Gate::Stop(stderr) => {
285 return Err(WrappedOutcome::Exit {
286 code: ENGINE_FAILURE_EXIT_CODE,
287 stderr,
288 });
289 }
290 }
291 }
292 let config: CliGitConfig = match load_identity_config(&location.identity) {
293 Ok(loaded) => loaded.config,
294 Err(error) => {
295 return Err(WrappedOutcome::Exit {
296 code: ENGINE_FAILURE_EXIT_CODE,
297 stderr: config_invalid_event(&error),
298 });
299 }
300 };
301 // `.clone()` copies the scanner and Markdown options the configuration chose.
302 checks.scanner_settings.options = config.policies.forbidden_strings.clone();
303 checks.markdown.options = config.policies.markdown_autofix.clone();
304 // Only `git add` inside a worktree has content for a policy to read before Git runs:
305 // what it would stage, predicted when a content policy first reads it.
306 if command_word(stripped) == b"add" && worktree_root(&location.identity).is_some() {
307 checks.candidates = LifecycleContent::Requested(CandidateRequest::Add(
308 // `.to_vec()` copies the arguments after `add` for the prediction.
309 command_region(stripped).to_vec(),
310 ));
311 }
312 // `Ok(x)` is the success case.
313 return Ok(config);
314}
315
316/// What:
317/// Run a real `git commit` through the commit transaction, or nothing when the command is anything else.
318/// `Option<WrappedOutcome>` is "the ending, or nothing to say".
319/// Why:
320/// Every non-dry-run commit goes through the transaction instead of being forwarded, because native `git commit`
321/// keeps `index.lock` on disk through hooks and the editor. A region Git's table refuses is not known to be a
322/// dry run, so it is treated as a real commit and Git itself rejects it.
323///
324/// In TS you'd write (pseudocode):
325/// ```ts
326/// const transaction = willShortCircuit ? NOT_APPLICABLE : await runCommitTransactionBoundary({ args, gitPath, policyOptions, concurrency });
327/// ```
328fn run_commit_if_real<F: RepositoryFacts>(
329 stripped: &StrippedInvocation,
330 environment: &[(OsString, OsString)],
331 checks: &mut ShippedChecks<F>,
332 config: &CliGitConfig,
333) -> Option<WrappedOutcome> {
334 if command_word(stripped) != b"commit" {
335 return None;
336 }
337 let region: CommitRegion = match parse_commit_region(command_region(stripped), &[]) {
338 Ok(found) => found,
339 Err(_) => return None,
340 };
341 if region.dry_run {
342 return None;
343 }
344 let context = checks.facts.transaction_context();
345 let ending = run_commit_command(
346 &CommitCommand {
347 context: &context,
348 environment,
349 arguments: stripped.arguments.as_slice(),
350 subcommand_index: stripped.layout.prefix_len,
351 region: ®ion,
352 config,
353 controls: &stripped.controls,
354 },
355 checks,
356 );
357 return Some(WrappedOutcome::Exit {
358 code: ending.code,
359 stderr: String::new(),
360 });
361}
362
363/// What:
364/// Recover before a read-only command that already asked Git for the repository location;
365/// a live transaction does not stop it,
366/// a legacy directory or a recovery failure does.
367/// `Option<WrappedOutcome>` is "the ending,
368/// or nothing to say".
369/// Why:
370/// The incumbent recovers before read-only commands too,
371/// reusing the identity it already
372/// resolved;
373/// a command that never asked for the location starts no extra work.
374///
375/// In TS you'd write (pseudocode):
376/// ```ts
377/// if (configFreeIdentity !== undefined) await recoverCommitTransaction({ args, gitPath, identity: configFreeIdentity });
378/// ```
379fn recover_before_read_only<F: RepositoryFacts>(
380 checks: &ShippedChecks<F>,
381) -> Option<WrappedOutcome> {
382 let Some(Ok(location)) = checks.facts.asked_location() else {
383 return None;
384 };
385 match recover_for_command(&checks.facts.transaction_context(), &location.identity) {
386 Gate::Clear | Gate::Live(_) => return None,
387 Gate::Stop(stderr) => {
388 return Some(WrappedOutcome::Exit {
389 code: ENGINE_FAILURE_EXIT_CODE,
390 stderr,
391 });
392 }
393 }
394}
395
396/// What:
397/// Run the lifecycle of one wrapped command.
398/// `&StrippedInvocation` borrows the
399/// invocation without its wrapper controls;
400/// `&mut ShippedChecks<F>` lends the shipped
401/// policies and their facts provider `F` for writing.
402/// Why:
403/// A command Git does not run as a subcommand is forwarded as it is.
404/// A read-only
405/// command skips configuration and every refusal check,
406/// and uses the default
407/// settings.
408/// Every other command is prepared first.
409/// Then one pre-forward pass runs,
410/// and a real push must also clear the manual-push gate.
411///
412/// In TS you'd write (pseudocode):
413/// ```ts
414/// async function runWrappedCommand(stripped, environment, checks): Promise<WrappedOutcome>;
415/// ```
416pub fn run_wrapped_command<F: RepositoryFacts>(
417 stripped: &StrippedInvocation,
418 environment: &[(OsString, OsString)],
419 checks: &mut ShippedChecks<F>,
420) -> WrappedOutcome {
421 if stripped.layout.outcome != GlobalOutcome::Command {
422 return WrappedOutcome::Forward {
423 // `.clone()` copies the arguments for the caller.
424 arguments: stripped.arguments.clone(),
425 stderr: String::new(),
426 };
427 }
428 // `String::from_utf8_lossy` renders the command word for messages, replacing bytes that
429 // are not UTF-8; `.into_owned()` makes it owned text.
430 let command: String = String::from_utf8_lossy(command_word(stripped)).into_owned();
431 // `.as_slice()` lends an owned list as a borrowed view.
432 let skips_config: bool =
433 classify_config_loading(stripped.arguments.as_slice()) == ConfigLoading::Skip;
434 let loaded: Option<CliGitConfig> = if skips_config {
435 None
436 } else {
437 match prepare_guarded_command(stripped, environment, checks) {
438 Ok(found) => Some(found),
439 Err(ending) => return ending,
440 }
441 };
442 if let Some(config) = &loaded
443 && let Some(ending) = run_commit_if_real(stripped, environment, checks, config)
444 {
445 return ending;
446 }
447 let policies: PolicyConfig = match loaded {
448 Some(config) => config.policies,
449 None => PolicyConfig::defaults(),
450 };
451 let request: StageRequest = StageRequest {
452 trigger: Trigger::PreForward,
453 config: policies,
454 controls: stripped.controls.clone(),
455 // `Vec::<PolicyId>::new()` is an empty owned list: a wrapped command selects every policy.
456 selected: Vec::<PolicyId>::new(),
457 };
458 let pass: PassResult = run_policy_pass(&request, checks);
459 if let Some(ending) = stage_ending(pass.events.as_slice(), pass.end, command.as_str()) {
460 return ending;
461 }
462 if skips_config
463 && !has_valid_preparation_lease(environment)
464 && let Some(ending) = recover_before_read_only(checks)
465 {
466 return ending;
467 }
468 // `mut` allows the push gate to append its events.
469 let mut events: Vec<PolicyEvent> = pass.events;
470 if command_word(stripped) == b"push" && !push_is_dry_run(command_region(stripped)) {
471 let gate_request: StageRequest = StageRequest {
472 trigger: Trigger::ManualPush,
473 config: request.config,
474 controls: request.controls,
475 selected: request.selected,
476 };
477 let gated: Vec<PolicyId> = every_policy();
478 if any_policy_applies(&gate_request, gated.as_slice()) {
479 // The push's scope is discovered before any policy reads content; a discovery failure refuses the push.
480 if let Err(failure) = prepare_manual_push(checks, environment, command_region(stripped)) {
481 events.push(PolicyEvent::EngineFailure {
482 code: EngineFailureCode::ContentUnavailable,
483 message: failure.message,
484 trigger: Some(Trigger::ManualPush),
485 policy: None,
486 path: None,
487 });
488 return WrappedOutcome::Exit {
489 code: ENGINE_FAILURE_EXIT_CODE,
490 stderr: render_policy_events(0, events.as_slice()),
491 };
492 }
493 let gate: StageResult = run_policy_stage(&gate_request, gated.as_slice(), checks);
494 // `.extend(list)` moves every item of `list` onto the end.
495 events.extend(gate.events);
496 if let Some(ending) = stage_ending(events.as_slice(), gate.end, command.as_str()) {
497 return ending;
498 }
499 }
500 }
501 return WrappedOutcome::Forward {
502 arguments: pass.arguments,
503 stderr: render_policy_events(0, events.as_slice()),
504 };
505}
506
507/// Lifecycle order,
508/// fact use and every ending stay out of the release executable.
509/// What:
510/// Discover the scope of a manual push and hand it to the manual-push stage as its candidates.
511/// Why:
512/// The stage reads only what the push publishes, and the discovery runs against the repository the
513/// command targets, with the invocation's global options and environment.
514///
515/// In TS you'd write (pseudocode):
516/// ```ts
517/// const scope = await manualPushScope({ gitPath, cwd, args }); // then the manual-push stage reads it
518/// ```
519fn prepare_manual_push<F: RepositoryFacts>(
520 checks: &mut ShippedChecks<F>,
521 environment: &[(OsString, OsString)],
522 post_subcommand: &[OsString],
523) -> Result<(), super::manual_push_lifecycle::ManualPushFailure> {
524 let worktree: Option<PathBuf> = match checks.facts.location() {
525 Ok(location) => worktree_root(&location.identity).map(Path::to_path_buf),
526 Err(message) => return Err(super::manual_push_lifecycle::ManualPushFailure { message }),
527 };
528 let context = checks.facts.transaction_context();
529 let cwd = match worktree {
530 Some(root) => root,
531 None => std::env::current_dir().map_err(|error| {
532 return super::manual_push_lifecycle::ManualPushFailure {
533 message: format!("the working directory could not be read: {error}"),
534 };
535 })?,
536 };
537 let git = PushGit {
538 real_git: context.real_git,
539 cwd,
540 environment: environment_with_overlay(environment, context.overlay.as_slice()),
541 };
542 let scope = manual_push_scope(&git, context.global_prefix.as_slice(), post_subcommand)?;
543 checks.candidates = LifecycleContent::Requested(CandidateRequest::Pushed(scope));
544 return Ok(());
545}
546
547#[cfg(test)]
548#[path = "wrapped_command_tests.rs"]
549mod tests;