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