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