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