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