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