Skip to main content

git_policy_cli/
management.rs

1//! What:
2//!  Decide what one `git cli-git ...` invocation does.
3//! Why:
4//!  Help,
5//!  refusals and retired commands need no repository;
6//!  `check` and `fix`
7//!      validate their policy selection and configuration,
8//!  then run one policy pass over
9//!      the selected repository and report its events on standard output.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! // const action = planManagement(managementArgs, gitGlobalArgs, controls, environment, inputs);
14//! ```
15
16/// Import the sibling modules the decision combines.
17use super::action::{Action, ENGINE_FAILURE_EXIT_CODE, failure};
18/// A direct command asks for the worktree files its scope selects.
19use super::candidate_prediction::CandidateRequest;
20use super::child_environment::{child_environment_overlay, environment_value};
21use super::config_error::ConfigError;
22use super::config_file::LoadedConfig;
23/// A scope that cannot be projected is a `transaction-failed` engine failure.
24use super::diagnostics::EngineFailureCode;
25/// The direct fix:
26///  converge corrections in memory,
27///  then install them.
28use super::direct_fix::run_direct_fix;
29use super::invocation_config::{config_invalid_event, legacy_warning_events, load_identity_config};
30use super::management_arguments::{
31    MANAGEMENT_HELP, MANAGEMENT_USAGE, ManagementAction, ManagementRefusal, RetiredCommand,
32    parse_management_arguments,
33};
34/// The linter the executable gives the Markdown policy.
35use super::markdown_linter::executable_linter;
36/// The shipped checks and their constructor.
37use super::policy_checks::{ShippedChecks, shipped_checks};
38/// What the direct command offers its content policies.
39use super::policy_content::LifecycleContent;
40use super::policy_engine::{StageEnd, StageRequest, pass_exit_code};
41/// The events of a direct command and their rendering.
42use super::policy_events::{PolicyEvent, render_policy_events};
43use super::policy_pass::{PassResult, run_policy_pass};
44use super::policy_registry::{POLICY_REGISTRY, PolicyId, policy_by_name};
45use super::policy_trigger::Trigger;
46use super::real_git::{ResolutionInputs, resolve_real_git};
47use super::repository_facts::{GitFacts, RepositoryFacts, git_facts};
48use super::repository_location::RepositoryLocation;
49/// The variable that names the forbidden-strings rules file.
50use super::scanner_selection::RULES_VARIABLE;
51/// Startup recovery as a command step.
52use super::transaction_gate::{Gate, recover_for_command};
53use super::index_writer_coordination::hold_landing_lock;
54use super::owner_lock::OwnerLock;
55use super::unported::{unported_from_unavailable, unported_notice};
56/// The worktree's top level,
57///  where a fix installs corrected files.
58use super::worktree_identity::worktree_root;
59use super::wrapper_controls::Controls;
60/// What:
61///  `OsString` is owned operating-system text of raw OS bytes (sibling `String`
62///       must be UTF-8).
63/// Why:
64///   Arguments and environment values are never decoded.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// // string[] argv, but byte-preserving.
69/// ```
70use std::ffi::OsString;
71/// `PathBuf` is an owned filesystem path of raw OS bytes.
72use std::path::{Path, PathBuf};
73
74/// What:
75///  The command word of a direct command.
76///       `&'static str` borrows text compiled into the executable for its whole run.
77/// Why:
78///   Messages name the command the caller typed.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// function directName(fix: boolean): string { return fix ? 'fix' : 'check'; }
83/// ```
84fn direct_name(fix: bool) -> &'static str {
85    if fix {
86        return "fix";
87    }
88    return "check";
89}
90
91/// What:
92///  The words of a direct command as the caller typed them after `git`.
93///       `String` is owned UTF-8 text.
94/// Why:
95///   A refusal names the command that was not run.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// function directCommand(fix: boolean): string { return `cli-git ${directName(fix)}`; }
100/// ```
101fn direct_command(fix: bool) -> String {
102    // `format!` builds owned text; `{}` interpolates the command word.
103    return format!("cli-git {}", direct_name(fix));
104}
105
106/// What:
107///  The command word of a retired command.
108/// Why:
109///   The explanation names the command the caller typed.
110///
111/// In TS you'd write (pseudocode):
112/// ```ts
113/// function retiredName(command: RetiredCommand): string;
114/// ```
115fn retired_name(command: RetiredCommand) -> &'static str {
116    // `match` must name every variant, so a new retired command cannot be forgotten.
117    match command {
118        RetiredCommand::Trust => return "trust",
119        RetiredCommand::Untrust => return "untrust",
120        RetiredCommand::Status => return "status",
121    }
122}
123
124/// What:
125///  Explain that a trust command is retired.
126///       `String` is the owned,
127///  line-terminated explanation.
128/// Why:
129///   Trust existed to approve executing a repository's configuration code.
130///  JSONC
131///       configuration is data,
132///  so there is nothing to approve,
133///  revoke or report.
134///       Old trust records are neither read nor deleted.
135///
136/// In TS you'd write (pseudocode):
137/// ```ts
138/// function retiredExplanation(command: RetiredCommand): string;
139/// ```
140pub fn retired_explanation(command: RetiredCommand) -> String {
141    // `format!` builds the owned text; `{}` interpolates the command word.
142    return format!(
143        "git cli-git {} is retired. cli-git now reads cli-git.config.jsonc as data and runs no \
144         repository-supplied code, so there is no code execution to approve, revoke or report. \
145         Nothing was changed; existing trust records are left in place and are no longer read.\n",
146        retired_name(command)
147    );
148}
149
150/// What:
151///  Turn a refusal into its usage action:
152///  text on standard error,
153///  exit status 2.
154/// Why:
155///   A malformed management invocation must never fall through to Git.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// function refusal(reason: ManagementRefusal): Action;
160/// ```
161fn refusal(reason: ManagementRefusal) -> Action {
162    // `match` picks the message for the refusal and binds its `fix` flag where present.
163    let stderr: String = match reason {
164        ManagementRefusal::Usage => String::from(MANAGEMENT_USAGE),
165        ManagementRefusal::PathspecsBeforeSeparator { fix } => format!(
166            "git cli-git {} pathspecs must follow --.\n",
167            direct_name(fix)
168        ),
169        ManagementRefusal::ScopeRequired { fix } => format!(
170            "git cli-git {} requires exactly one scope: --all or non-empty pathspecs after --.\n",
171            direct_name(fix)
172        ),
173    };
174    return Action::Exit {
175        code: ENGINE_FAILURE_EXIT_CODE,
176        // `String::new()` is empty owned text.
177        stdout: String::new(),
178        stderr,
179    };
180}
181
182/// What:
183///  Stop a direct command with a `config-invalid` event on standard output.
184/// Why:
185///   Direct commands report policy events on standard output,
186///  and an invalid
187///       configuration or an unknown selected policy exits 2 before any policy runs.
188///
189/// In TS you'd write (pseudocode):
190/// ```ts
191/// function directConfigFailure(error: ConfigError): Action;
192/// ```
193fn direct_config_failure(error: &ConfigError) -> Action {
194    return Action::Exit {
195        code: ENGINE_FAILURE_EXIT_CODE,
196        stdout: config_invalid_event(error),
197        stderr: String::new(),
198    };
199}
200
201/// What:
202///  The pathspecs of a direct command's scope.
203///  `all` is `--all`;
204///  `Vec<OsString>`
205///       is the owned list of pathspecs written after `--`.
206/// Why:
207///   `--all` selects the whole worktree from its top level,
208///  which Git spells `:/`,
209///       as the installed wrapper does.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// const directPathspecs = all ? [':/'] : pathspecs;
214/// ```
215fn scope_pathspecs(all: bool, pathspecs: Vec<OsString>) -> Vec<OsString> {
216    if all {
217        // `vec![...]` builds the one-item list.
218        return vec![OsString::from(":/")];
219    }
220    return pathspecs;
221}
222
223/// What:
224///  Turn the selected policy names into policy identities.
225///       `Result<Vec<PolicyId>, ConfigError>` is the identities,
226///  or the error naming the
227///       first name no shipped policy has.
228/// Why:
229///   `--policy` selects among shipped policies only;
230///  a mistyped ID must stop the
231///       command instead of selecting nothing.
232///
233/// In TS you'd write (pseudocode):
234/// ```ts
235/// function selectedPolicies(names: string[]): PolicyId[]; // throws ConfigError on an unknown name
236/// ```
237fn selected_policies(names: &[String]) -> Result<Vec<PolicyId>, ConfigError> {
238    // `Vec::<PolicyId>::new()` is an empty owned list; `mut` allows pushing.
239    let mut selected: Vec<PolicyId> = Vec::<PolicyId>::new();
240    // `for name in names` borrows each selected ID in order.
241    for name in names {
242        // `match` unpacks "a registry row or nothing".
243        match policy_by_name(name.as_str()) {
244            Some(descriptor) => selected.push(descriptor.id),
245            None => {
246                let mut known: String = String::new();
247                for descriptor in POLICY_REGISTRY {
248                    if !known.is_empty() {
249                        known.push_str(", ");
250                    }
251                    known.push_str(descriptor.name);
252                }
253                // `Err(...)` is the failure variant carrying the error.
254                return Err(ConfigError::new(
255                    format!("Unknown policy ID: {name}. Shipped policies: {known}.").as_str(),
256                ));
257            }
258        }
259    }
260    // `Ok(...)` is the success variant: every selected ID is shipped.
261    return Ok(selected);
262}
263
264/// What:
265///  Run the policy pass of one direct command and build its action.
266///       `&LoadedConfig` borrows the accepted configuration;
267///  `&mut ShippedChecks<GitFacts>`
268///       lends the shipped policies over real Git for writing.
269/// Why:
270///   Direct commands report every event on standard output.
271///  `check` first reports a
272///       legacy file left beside the JSONC file,
273///  and policy events continue its numbering.
274///       A policy that cannot be evaluated here stops the command with exit status 2 and
275///       a notice on standard error,
276///  after the events gathered so far.
277///
278/// In TS you'd write (pseudocode):
279/// ```ts
280/// async function runDirectCommand({ fix, selected, controls, loaded, checks }): Promise<Action>;
281/// ```
282fn run_direct_command(
283    fix: bool,
284    selected: Vec<PolicyId>,
285    controls: &Controls,
286    loaded: &LoadedConfig,
287    checks: &mut ShippedChecks<GitFacts>,
288    root: Option<&Path>,
289) -> Action {
290    // Only `check` reports a legacy file left beside the JSONC file; `fix` stays silent.
291    let mut stdout: String = if fix {
292        String::new()
293    } else {
294        legacy_warning_events(loaded)
295    };
296    // Each event is one line, so the lines written so far are the next event number.
297    // `.matches('\n').count()` counts them; `as u64` widens the count to the number type.
298    let first_sequence: u64 = stdout.matches('\n').count() as u64;
299    // `.clone()` copies the scanner and Markdown options the configuration chose.
300    checks.scanner_settings.options = loaded.config.policies.forbidden_strings.clone();
301    checks.markdown.options = loaded.config.policies.markdown_autofix.clone();
302    let trigger: Trigger = if fix {
303        Trigger::DirectFix
304    } else {
305        Trigger::DirectCheck
306    };
307    // The selected files are staged on a private index before any policy runs, as the
308    // installed wrapper does, so a scope Git refuses is reported whatever is selected.
309    if let Err(error) = checks
310        .content
311        .prepare(&checks.candidates, &mut checks.facts)
312    {
313        stdout.push_str(
314            render_policy_events(
315                first_sequence,
316                &[PolicyEvent::EngineFailure {
317                    code: EngineFailureCode::TransactionFailed,
318                    message: error.message,
319                    // `Some(x)` is the "present" case of `Option`.
320                    trigger: Some(trigger),
321                    // `None` is the "absent" case: no policy has run.
322                    policy: None,
323                    path: None,
324                }],
325            )
326            .as_str(),
327        );
328        return Action::Exit {
329            code: ENGINE_FAILURE_EXIT_CODE,
330            stdout,
331            stderr: String::new(),
332        };
333    }
334    let request: StageRequest = StageRequest {
335        trigger,
336        // `.clone()` copies the settings and controls into the request.
337        config: loaded.config.policies.clone(),
338        controls: controls.clone(),
339        selected,
340    };
341    // A fix converges corrections and installs them; a check runs one pass.
342    let pass: PassResult = if fix {
343        run_direct_fix(&request, checks, root)
344    } else {
345        run_policy_pass(&request, checks)
346    };
347    // `.as_slice()` lends the owned events as a borrowed view.
348    stdout.push_str(render_policy_events(first_sequence, pass.events.as_slice()).as_str());
349    // `if let StageEnd::Unavailable(x) = ...` runs only for that ending and binds what it carries.
350    let stderr: String = if let StageEnd::Unavailable(unavailable) = pass.end {
351        unported_notice(
352            &unported_from_unavailable(unavailable),
353            direct_command(fix).as_str(),
354        )
355    } else {
356        String::new()
357    };
358    return Action::Exit {
359        code: pass_exit_code(pass.events.as_slice(), pass.end),
360        stdout,
361        stderr,
362    };
363}
364
365/// What:
366///  Decide the action for the arguments after `git cli-git`.
367///       `&[OsString]` borrows argument lists;
368///  `&Controls` borrows what the wrapper
369///       controls written before `cli-git` asked for;
370///  `&ResolutionInputs` borrows process facts.
371/// Why:
372///   Help,
373///  refusals and retired commands answer without resolving Git or reading
374///       any repository.
375///  A direct command validates its selection,
376///  asks Git once where it
377///       runs,
378///  refuses beside commit transactions it cannot recover,
379///  loads that
380///       worktree's configuration and runs one policy pass.
381///
382/// In TS you'd write (pseudocode):
383/// ```ts
384/// function planManagement(args, gitGlobalArgs, controls, environment, inputs): Action;
385/// ```
386pub fn plan_management(
387    arguments: &[OsString],
388    git_global_arguments: &[OsString],
389    controls: &Controls,
390    environment: &[(OsString, OsString)],
391    inputs: &ResolutionInputs,
392) -> Action {
393    // What: `match` on the parse `Result`: keep the action or return the refusal.
394    // Why:  Nothing else may run for an invocation the grammar rejects.
395    //
396    // In TS you'd write (pseudocode):
397    // ```ts
398    // const parsed = parseManagementArgs(args); if (parsed === MANAGEMENT_REFUSED) return usage();
399    // ```
400    let parsed: ManagementAction = match parse_management_arguments(arguments) {
401        Ok(action) => action,
402        Err(reason) => return refusal(reason),
403    };
404    // `match` picks by variant and binds the fields each one carries.
405    let (fix, policies, scope): (bool, Vec<String>, Vec<OsString>) = match parsed {
406        ManagementAction::Help => {
407            return Action::Exit {
408                code: 0,
409                stdout: String::from(MANAGEMENT_HELP),
410                stderr: String::new(),
411            };
412        }
413        ManagementAction::Retired { command, help } => {
414            // Help goes to standard output; an attempted action is explained on standard error.
415            if help {
416                return Action::Exit {
417                    code: 0,
418                    stdout: retired_explanation(command),
419                    stderr: String::new(),
420                };
421            }
422            return Action::Exit {
423                code: 0,
424                stdout: String::new(),
425                stderr: retired_explanation(command),
426            };
427        }
428        ManagementAction::Direct {
429            fix,
430            all,
431            policies,
432            pathspecs,
433        } => (fix, policies, scope_pathspecs(all, pathspecs)),
434    };
435    let selected: Vec<PolicyId> = match selected_policies(policies.as_slice()) {
436        Ok(ids) => ids,
437        // `&error` lends the error to the renderer.
438        Err(error) => return direct_config_failure(&error),
439    };
440    let real_git: PathBuf = match resolve_real_git(inputs) {
441        Ok(found) => found,
442        Err(error) => return failure(error.to_string().as_str()),
443    };
444    let overlay: Vec<(OsString, OsString)> =
445        child_environment_overlay(environment, real_git.as_path());
446    // `mut` lets the pass cache facts it asks Git for.
447    let mut checks: ShippedChecks<GitFacts> = shipped_checks(
448        git_facts(real_git.as_path(), git_global_arguments, overlay.as_slice()),
449        // `.to_vec()` copies the borrowed global options for the rule cores.
450        git_global_arguments.to_vec(),
451        LifecycleContent::Requested(CandidateRequest::Direct(scope)),
452        // `Vec::new()` is an empty owned list: no direct-command policy reads the tool caches.
453        Vec::<PathBuf>::new(),
454    );
455    // The forbidden-strings rules variable is read from this invocation's environment.
456    checks.scanner_settings.rules_variable = environment_value(environment, RULES_VARIABLE);
457    // The Markdown linter is found on, and runs with, this invocation's environment.
458    checks.markdown.linter = Box::new(executable_linter(environment));
459    let location: RepositoryLocation = match checks.facts.location() {
460        Ok(found) => found,
461        Err(message) => return failure(message.as_str()),
462    };
463    // A direct command forwards nothing, so only commit transactions matter here: dead ones
464    // are recovered, and a live one still refuses, since a fix would race its landing.
465    match recover_for_command(&checks.facts.transaction_context(), &location.identity) {
466        Gate::Clear | Gate::Live(_) => {}
467        Gate::Stop(stderr) => {
468            return Action::Exit {
469                code: ENGINE_FAILURE_EXIT_CODE,
470                stdout: String::new(),
471                stderr,
472            };
473        }
474    }
475    // The direct fix runs under the landing lock, so no landing commits beside it.
476    let held: Option<OwnerLock> = match hold_landing_lock(&checks.facts.transaction_context(), &location.identity) {
477        Ok(found) => found,
478        Err(failure) => {
479            return Action::Exit {
480                code: ENGINE_FAILURE_EXIT_CODE,
481                stdout: String::new(),
482                stderr: failure.stderr(),
483            };
484        }
485    };
486    let action: Action = match load_identity_config(&location.identity) {
487        Ok(loaded) => run_direct_command(
488            fix,
489            selected,
490            controls,
491            &loaded,
492            &mut checks,
493            worktree_root(&location.identity),
494        ),
495        Err(error) => direct_config_failure(&error),
496    };
497    if let Some(lock) = held && let Err(reason) = lock.release() {
498        super::diagnostic_log::debug("directFix", format!("landing lock release failed: {reason}").as_str());
499    }
500    return action;
501}
502
503/// Decision controls stay out of the release executable.
504#[cfg(test)]
505#[path = "management_tests.rs"]
506mod tests;