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