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