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