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