Skip to main content

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: &region,
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;