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