Skip to main content

git_policy_cli/invocation/
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 crate::action::{Action, ENGINE_FAILURE_EXIT_CODE, failure, with_leading_notice};
18/// A direct command asks for the worktree files its scope selects.
19use crate::candidate_prediction::CandidateRequest;
20use crate::child_environment::{child_environment_overlay, environment_value};
21use crate::config_error::ConfigError;
22use crate::config_file::LoadedConfig;
23/// A scope that cannot be projected is a `transaction-failed` engine failure.
24use crate::diagnostics::EngineFailureCode;
25/// The direct fix:
26///  converge corrections in memory,
27///  then install them.
28use crate::direct_fix::run_direct_fix;
29/// The traditional-hook listing of `git cli-git doctor`.
30use crate::doctor::doctor_action_with_section;
31use crate::invocation_config::{config_invalid_event, legacy_warning_events, load_identity_config};
32use crate::git_system_config::with_real_git;
33use crate::repo_read_layout::read_layout_after_success_for;
34use crate::service_route::service_status;
35use crate::management_arguments::{
36    MANAGEMENT_HELP, MANAGEMENT_USAGE, ManagementAction, ManagementRefusal, RetiredCommand,
37    parse_management_arguments,
38};
39/// The refusal of a `--policy` selection that is off.
40use crate::management_selection::{selected_policies, selection_error};
41/// The linter the executable gives the Markdown policy.
42/// The shipped checks and their constructor.
43use crate::policy_checks::{ShippedChecks, shipped_checks};
44/// What the direct command offers its content policies.
45use crate::policy_content::LifecycleContent;
46use crate::policy_engine::{StageEnd, StageRequest, pass_exit_code};
47/// The events of a direct command and their rendering.
48use crate::policy_events::{PolicyEvent, render_policy_events};
49use crate::policy_pass::{PassResult, run_policy_pass};
50use crate::policy_registry::PolicyId;
51use crate::policy_trigger::Trigger;
52use crate::real_git::{ResolutionInputs, ResolvedGit, resolve_real_git};
53use crate::repository_facts::{GitFacts, RepositoryFacts, git_facts};
54use crate::repository_location::RepositoryLocation;
55/// The variable that names the forbidden-strings rules file.
56use crate::scanner_selection::RULES_VARIABLE;
57/// Startup recovery as a command step.
58use crate::startup_gate::{Gate, recover_for_command};
59use crate::transaction_git::GitContext;
60use crate::subvolume_arguments::MigrateArguments;
61use crate::worktree_convert_arguments::ConvertArguments;
62#[cfg(target_os = "linux")]
63use crate::subvolume_command::migrate_action;
64use crate::unported::{unported_from_unavailable, unported_notice};
65/// The worktree's top level,
66///  where a fix installs corrected files.
67use crate::worktree_identity::worktree_root;
68use crate::wrapper_controls::Controls;
69/// What:
70///  `OsString` is owned operating-system text of raw OS bytes (sibling `String`
71///       must be UTF-8).
72/// Why:
73///   Arguments and environment values are never decoded.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// // string[] argv, but byte-preserving.
78/// ```
79use std::ffi::OsString;
80/// `PathBuf` is an owned filesystem path of raw OS bytes.
81use std::path::{Path, PathBuf};
82
83/// What:
84///  The command word of a direct command.
85///       `&'static str` borrows text compiled into the executable for its whole run.
86/// Why:
87///   Messages name the command the caller typed.
88///
89/// In TS you'd write (pseudocode):
90/// ```ts
91/// function directName(fix: boolean): string { return fix ? 'fix' : 'check'; }
92/// ```
93fn direct_name(fix: bool) -> &'static str {
94    if fix {
95        return "fix";
96    }
97    return "check";
98}
99
100/// What:
101///  The words of a direct command as the caller typed them after `git`.
102///       `String` is owned UTF-8 text.
103/// Why:
104///   A refusal names the command that was not run.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// function directCommand(fix: boolean): string { return `cli-git ${directName(fix)}`; }
109/// ```
110fn direct_command(fix: bool) -> String {
111    // `format!` builds owned text; `{}` interpolates the command word.
112    return format!("cli-git {}", direct_name(fix));
113}
114
115/// What:
116///  The command word of a retired command.
117/// Why:
118///   The explanation names the command the caller typed.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// function retiredName(command: RetiredCommand): string;
123/// ```
124fn retired_name(command: RetiredCommand) -> &'static str {
125    // `match` must name every variant, so a new retired command cannot be forgotten.
126    match command {
127        RetiredCommand::Trust => return "trust",
128        RetiredCommand::Untrust => return "untrust",
129        RetiredCommand::Status => return "status",
130    }
131}
132
133/// What:
134///  Explain that a trust command is retired.
135///       `String` is the owned,
136///  line-terminated explanation.
137/// Why:
138///   Trust existed to approve executing a repository's configuration code.
139///  JSONC
140///       configuration is data,
141///  so there is nothing to approve,
142///  revoke or report.
143///       Old trust records are neither read nor deleted.
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// function retiredExplanation(command: RetiredCommand): string;
148/// ```
149pub fn retired_explanation(command: RetiredCommand) -> String {
150    // `format!` builds the owned text; `{}` interpolates the command word.
151    return format!(
152        "git cli-git {} is retired. cli-git now reads cli-git.config.jsonc as data and runs no \
153         repository-supplied code, so there is no code execution to approve, revoke or report. \
154         Nothing was changed; existing trust records are left in place and are no longer read.\n",
155        retired_name(command)
156    );
157}
158
159/// What:
160///  Turn a refusal into its usage action:
161///  text on standard error,
162///  exit status 2.
163/// Why:
164///   A malformed management invocation must never fall through to Git.
165///
166/// In TS you'd write (pseudocode):
167/// ```ts
168/// function refusal(reason: ManagementRefusal): Action;
169/// ```
170fn refusal(reason: ManagementRefusal) -> Action {
171    // `match` picks the message for the refusal and binds its `fix` flag where present.
172    let stderr: String = match reason {
173        ManagementRefusal::Usage => String::from(MANAGEMENT_USAGE),
174        ManagementRefusal::PathspecsBeforeSeparator { fix } => format!(
175            "git cli-git {} pathspecs must follow --.\n",
176            direct_name(fix)
177        ),
178        ManagementRefusal::ScopeRequired { fix } => format!(
179            "git cli-git {} requires exactly one scope: --all or non-empty pathspecs after --.\n",
180            direct_name(fix)
181        ),
182    };
183    return Action::Exit {
184        code: ENGINE_FAILURE_EXIT_CODE,
185        // `String::new()` is empty owned text.
186        stdout: String::new(),
187        stderr,
188    };
189}
190
191/// What:
192///  Stop a direct command with a `config-invalid` event on standard output.
193/// Why:
194///   Direct commands report policy events on standard output,
195///  and an invalid
196///       configuration or an unknown selected policy exits 2 before any policy runs.
197///
198/// In TS you'd write (pseudocode):
199/// ```ts
200/// function directConfigFailure(error: ConfigError): Action;
201/// ```
202fn direct_config_failure(error: &ConfigError) -> Action {
203    return Action::Exit {
204        code: ENGINE_FAILURE_EXIT_CODE,
205        stdout: config_invalid_event(error),
206        stderr: String::new(),
207    };
208}
209
210/// What:
211///  The pathspecs of a direct command's scope.
212///  `all` is `--all`;
213///  `Vec<OsString>`
214///       is the owned list of pathspecs written after `--`.
215/// Why:
216///   `--all` selects the whole worktree from its top level,
217///  which Git spells `:/`,
218///       as the installed wrapper does.
219///
220/// In TS you'd write (pseudocode):
221/// ```ts
222/// const directPathspecs = all ? [':/'] : pathspecs;
223/// ```
224fn scope_pathspecs(all: bool, pathspecs: Vec<OsString>) -> Vec<OsString> {
225    if all {
226        // `vec![...]` builds the one-item list.
227        return vec![OsString::from(":/")];
228    }
229    return pathspecs;
230}
231
232/// What:
233///  Run the policy pass of one direct command and build its action.
234///       `&LoadedConfig` borrows the accepted configuration;
235///  `&mut ShippedChecks<GitFacts>`
236///       lends the shipped policies over real Git for writing.
237/// Why:
238///   Direct commands report every event on standard output.
239///  `check` first reports a
240///       legacy file left beside the JSONC file,
241///  and policy events continue its numbering.
242///       A policy that cannot be evaluated here stops the command with exit status 2 and
243///       a notice on standard error,
244///  after the events gathered so far.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// async function runDirectCommand({ fix, selected, controls, loaded, checks }): Promise<Action>;
249/// ```
250fn run_direct_command(
251    fix: bool,
252    selected: Vec<PolicyId>,
253    controls: &Controls,
254    loaded: &LoadedConfig,
255    checks: &mut ShippedChecks<GitFacts>,
256    root: Option<&Path>,
257) -> Action {
258    // Only `check` reports a legacy file left beside the JSONC file; `fix` stays silent.
259    let mut stdout: String = if fix {
260        String::new()
261    } else {
262        legacy_warning_events(loaded)
263    };
264    // Each event is one line, so the lines written so far are the next event number.
265    // `.matches('\n').count()` counts them; `as u64` widens the count to the number type.
266    let first_sequence: u64 = stdout.matches('\n').count() as u64;
267    // `.clone()` copies the scanner and Markdown options the configuration chose.
268    checks.scanner_settings.options = loaded.config.policies.forbidden_strings.clone();
269    checks.markdown.options = loaded.config.policies.markdown_autofix.clone();
270    checks.discarded_limits = loaded.config.discarded;
271    let trigger: Trigger = if fix {
272        Trigger::DirectFix
273    } else {
274        Trigger::DirectCheck
275    };
276    // The selected files are staged on a private index before any policy runs, as the
277    // installed wrapper does, so a scope Git refuses is reported whatever is selected.
278    if let Err(error) = checks
279        .content
280        .prepare(&checks.candidates, &mut checks.facts)
281    {
282        stdout.push_str(
283            render_policy_events(
284                first_sequence,
285                &[PolicyEvent::EngineFailure {
286                    code: EngineFailureCode::TransactionFailed,
287                    hint: crate::policy_events::EngineFailureHint::OfCode,
288                    message: error.message,
289                    // `Some(x)` is the "present" case of `Option`.
290                    trigger: Some(trigger),
291                    // `None` is the "absent" case: no policy has run.
292                    policy: None,
293                    path: None,
294                }],
295            )
296            .as_str(),
297        );
298        return Action::Exit {
299            code: ENGINE_FAILURE_EXIT_CODE,
300            stdout,
301            stderr: String::new(),
302        };
303    }
304    let request: StageRequest = StageRequest {
305        trigger,
306        // `.clone()` copies the settings and controls into the request.
307        config: loaded.config.policies.clone(),
308        controls: controls.clone(),
309        selected,
310    };
311    // A fix converges corrections and installs them; a check runs one pass.
312    let pass: PassResult = if fix {
313        run_direct_fix(&request, checks, root)
314    } else {
315        run_policy_pass(&request, checks)
316    };
317    // `.as_slice()` lends the owned events as a borrowed view.
318    stdout.push_str(render_policy_events(first_sequence, pass.events.as_slice()).as_str());
319    // `if let StageEnd::Unavailable(x) = ...` runs only for that ending and binds what it carries.
320    let stderr: String = if let StageEnd::Unavailable(unavailable) = pass.end {
321        unported_notice(
322            &unported_from_unavailable(unavailable),
323            direct_command(fix).as_str(),
324        )
325    } else {
326        String::new()
327    };
328    return Action::Exit {
329        code: pass_exit_code(pass.events.as_slice(), pass.end),
330        stdout,
331        stderr,
332    };
333}
334
335/// What:
336///  The global options of an invocation that choose the directory it runs in (`-C <path>`,
337/// in order),
338/// or nothing when
339///  another option could choose the repository too.
340/// Why:
341///   `git -C /path/B cli-git service status` asks about B,
342/// wherever it is run from;
343/// `-c <name>=<value>` changes no directory,
344///  while `--git-dir` and its kin name a repository the in-process reader does not resolve.
345///
346/// In TS you'd write (pseudocode):
347/// ```ts
348/// function directoryOptions(globalArguments): string[] | undefined;
349/// ```
350fn directory_options(global_arguments: &[OsString]) -> Option<Vec<OsString>> {
351    let mut kept: Vec<OsString> = Vec::new();
352    let mut options = global_arguments.iter();
353    while let Some(option) = options.next() {
354        let value: &OsString = options.next()?;
355        if option == "-C" {
356            kept.push(option.clone());
357            kept.push(value.clone());
358        } else if option != "-c" {
359            return None;
360        }
361    }
362    return Some(kept);
363}
364
365/// Which repository a `git cli-git service status` invocation selects.
366#[derive(Clone, Debug, Eq, PartialEq)]
367enum Selection {
368    /// The Git directory of the worktree it selects.
369    Found(PathBuf),
370    /// Git says the invocation is outside a repository.
371    Outside,
372    /// Neither the reader nor Git could say,
373    /// with the reason.
374    Unresolved(String),
375}
376
377/// What:
378///  The repository a `git <global options> cli-git service status` selects:
379/// the Git directory of its worktree,
380/// outside a
381///  repository,
382/// or unresolved.
383/// Why:
384///   The command needs no valid configuration,
385/// so the structure of the files decides and the configuration values do not.
386///  The reader learns the system configuration file from the real Git `real_git` names,
387/// and resolves `-C` options only.
388///  Whatever it declines (`--git-dir`,
389/// `--work-tree`,
390/// `GIT_DIR`,
391/// an unusual layout) is put to the real Git once,
392/// which resolves the
393///  selection as every other command does;
394/// "outside a repository" is Git's own answer,
395/// and anything Git cannot answer is
396///  unresolved,
397/// never an invitation to count unrelated services.
398///
399/// In TS you'd write (pseudocode):
400/// ```ts
401/// function statusGitDir(globalArguments, environment, realGit): string | undefined;
402/// ```
403fn status_git_dir(global_arguments: &[OsString], environment: &[(OsString, OsString)], real_git: Option<&Path>) -> Selection {
404    let named: Vec<(OsString, OsString)> = real_git.map_or_else(|| { return environment.to_vec(); }, |found| { return with_real_git(environment, found); });
405    if let Some(prefix) = directory_options(global_arguments)
406        && let Some(layout) = read_layout_after_success_for(prefix.as_slice(), named.as_slice())
407    {
408        return Selection::Found(layout.git_dir);
409    }
410    let Some(real) = real_git else {
411        return Selection::Unresolved(String::from("no real Git was found to ask"));
412    };
413    let mut asking = std::process::Command::new(real);
414    asking
415        .args(global_arguments)
416        .args(["rev-parse", "--path-format=absolute", "--absolute-git-dir"])
417        .envs(environment.iter().map(|(name, value)| { return (name, value); }))
418        .env("LC_ALL", "C")
419        .stdin(std::process::Stdio::null());
420    // This start of the real Git says so too, like every other (`started_as_git.rs`).
421    crate::started_as_git::mark_real_git_start(&mut asking, crate::started_as_git::StartMode::Child);
422    let asked = asking.output();
423    let output = match asked {
424        Ok(found) => found,
425        Err(error) => return Selection::Unresolved(format!("Git could not be started: {error}")),
426    };
427    let said: String = String::from_utf8_lossy(output.stdout.as_slice()).trim().to_owned();
428    if output.status.success() && !said.is_empty() {
429        return Selection::Found(PathBuf::from(said));
430    }
431    let complaint: String = String::from_utf8_lossy(output.stderr.as_slice()).trim().to_owned();
432    if output.status.code() == Some(NOT_A_REPOSITORY_EXIT_CODE) && complaint.contains("not a git repository") {
433        return Selection::Outside;
434    }
435    return Selection::Unresolved(format!("Git said: {complaint}"));
436}
437
438/// The exit status Git gives for a fatal error such as running outside a repository.
439const NOT_A_REPOSITORY_EXIT_CODE: i32 = 128;
440
441/// What:
442///  Decide the action of `git cli-git service status`.
443/// Why:
444///   The command is read-only:
445/// it says whether the service of the worktree it runs in answers.
446///
447/// In TS you'd write (pseudocode):
448/// ```ts
449/// function planService(globalArguments, environment, inputs): Action;
450/// ```
451fn plan_service(global_arguments: &[OsString], environment: &[(OsString, OsString)], inputs: &ResolutionInputs) -> Action {
452    let (real_git, notice): (Option<PathBuf>, Option<String>) = optional_real_git(inputs);
453    let action: Action = plan_service_with(global_arguments, environment, real_git.as_deref());
454    return with_leading_notice(action, notice.as_deref());
455}
456
457/// What:
458///  Resolve the real Git for a command that can run without one.
459///  The pair is the path,
460///  if any,
461///  and the notice owed when it came from the commit service's record.
462/// Why:
463///   The status and conversion commands answer from what they can read when Git is missing,
464///  but a recorded fallback is the same resolution every other start uses,
465///  so its notice is given here too.
466///
467/// In TS you'd write (pseudocode):
468/// ```ts
469/// function optionalRealGit(inputs: ResolutionInputs): [string | undefined, string | undefined];
470/// ```
471fn optional_real_git(inputs: &ResolutionInputs) -> (Option<PathBuf>, Option<String>) {
472    // `match` on the resolution: both parts when found, neither when not.
473    match resolve_real_git(inputs) {
474        Ok(found) => return (Some(found.path), found.notice),
475        Err(_) => return (None, None),
476    }
477}
478
479/// What:
480///  Decide the action of `git cli-git service status` once the optional real Git is known.
481/// Why:
482///   `plan_service` adds the notice of a recorded fallback to the action this returns.
483///
484/// In TS you'd write (pseudocode):
485/// ```ts
486/// function planServiceWith(globalArguments, environment, realGit?: string): Action;
487/// ```
488fn plan_service_with(global_arguments: &[OsString], environment: &[(OsString, OsString)], real_git: Option<&Path>) -> Action {
489    let runtime: Option<PathBuf> = environment_value(environment, "XDG_RUNTIME_DIR").filter(|text| { return !text.is_empty(); }).map(PathBuf::from);
490    // The worktree the command runs in has its own service; outside a repository the answer counts any.
491    let git_dir: Option<PathBuf> = match status_git_dir(global_arguments, environment, real_git) {
492        Selection::Found(found) => Some(found),
493        Selection::Outside => None,
494        Selection::Unresolved(reason) => {
495            return Action::Exit {
496                code: 1,
497                stdout: String::new(),
498                stderr: format!("The commit service cannot be checked: {reason}, so it is not known which repository this invocation selects, and no service was counted.\n"),
499            };
500        }
501    };
502    let report = service_status(runtime.as_deref(), git_dir.as_deref());
503    return Action::Exit { code: report.code, stdout: report.stdout, stderr: report.stderr };
504}
505
506/// What:
507///  Decide the action of `git cli-git migrate-to-subvolume`: a refused invocation prints its reason, anything else runs
508///  from the working directory.
509/// Why:
510///   The command needs neither configuration nor Git: it reads `.git` itself and calls the filesystem.
511///
512/// In TS you'd write (pseudocode):
513/// ```ts
514/// function planMigrate(parsed: MigrateArguments | string): Action;
515/// ```
516fn plan_migrate(parsed: Result<MigrateArguments, String>) -> Action {
517    let arguments: MigrateArguments = match parsed {
518        Ok(found) => found,
519        Err(reason) => {
520            return Action::Exit {
521                code: ENGINE_FAILURE_EXIT_CODE,
522                stdout: String::new(),
523                stderr: format!("git cli-git migrate-to-subvolume: {reason}\n"),
524            };
525        }
526    };
527    let cwd: PathBuf = match std::env::current_dir() {
528        Ok(found) => found,
529        Err(error) => return failure(format!("cannot determine the current directory: {error}").as_str()),
530    };
531    return run_migrate(&arguments, cwd.as_path());
532}
533
534/// What:
535///  Decide the action of `git cli-git convert-worktree-to-subvolume`: a refused invocation prints its reason, anything else
536///  runs from the working directory.
537/// Why:
538///   Like the migration, the command needs no valid configuration and starts no Git. It resolves the real Git the wrapper
539///  forwards to, as every forwarding path does, because the wrapper's own environment does not name it (only its children's
540///  does) and the content check needs to know where that Git reads its system files from. Where no real Git is found the
541///  command still runs, and a worktree that needs the system files is refused with that reason instead of guessed.
542///
543/// In TS you'd write (pseudocode):
544/// ```ts
545/// function planConvert(parsed: ConvertArguments | string, globalArguments, environment, inputs): Action;
546/// ```
547fn plan_convert(parsed: Result<ConvertArguments, String>, global_arguments: &[OsString], environment: &[(OsString, OsString)], inputs: &ResolutionInputs) -> Action {
548    let arguments: ConvertArguments = match parsed {
549        Ok(found) => ConvertArguments { command_line_attributes: crate::worktree_convert_arguments::selects_attribute_source(global_arguments), ..found },
550        Err(reason) => {
551            return Action::Exit {
552                code: ENGINE_FAILURE_EXIT_CODE,
553                stdout: String::new(),
554                stderr: format!("git cli-git convert-worktree-to-subvolume: {reason}\n"),
555            };
556        }
557    };
558    let cwd: PathBuf = match std::env::current_dir() {
559        Ok(found) => found,
560        Err(error) => return failure(format!("cannot determine the current directory: {error}").as_str()),
561    };
562    let (real_git, notice): (Option<PathBuf>, Option<String>) = optional_real_git(inputs);
563    let action: Action = run_convert(&arguments, cwd.as_path(), environment, real_git.as_deref());
564    return with_leading_notice(action, notice.as_deref());
565}
566
567/// Run the conversion command where the platform has btrfs subvolumes.
568#[cfg(target_os = "linux")]
569fn run_convert(arguments: &ConvertArguments, cwd: &Path, environment: &[(OsString, OsString)], real_git: Option<&Path>) -> Action {
570    return crate::worktree_convert_command::convert_action(arguments, cwd, environment, real_git);
571}
572
573/// Other platforms have no btrfs subvolumes, so the command says so.
574#[cfg(not(target_os = "linux"))]
575fn run_convert(_arguments: &ConvertArguments, _cwd: &Path, _environment: &[(OsString, OsString)], _real_git: Option<&Path>) -> Action {
576    return failure("git cli-git convert-worktree-to-subvolume works on Linux with btrfs only");
577}
578
579/// The doctor's filesystem section read from the running system.
580#[cfg(target_os = "linux")]
581pub fn host_filesystem_section(common_directory: &Path) -> String {
582    return crate::doctor_filesystem::host_section(common_directory);
583}
584
585/// Other platforms have no btrfs or overlayfs section.
586#[cfg(not(target_os = "linux"))]
587pub fn host_filesystem_section(_common_directory: &Path) -> String {
588    return String::new();
589}
590
591/// Run the migration command where the platform has btrfs subvolumes.
592#[cfg(target_os = "linux")]
593fn run_migrate(arguments: &MigrateArguments, cwd: &Path) -> Action {
594    return migrate_action(arguments, cwd);
595}
596
597/// Other platforms have no btrfs subvolumes, so the command says so.
598#[cfg(not(target_os = "linux"))]
599fn run_migrate(_arguments: &MigrateArguments, _cwd: &Path) -> Action {
600    return failure("git cli-git migrate-to-subvolume works on Linux with btrfs only");
601}
602
603fn plan_doctor(
604    git_global_arguments: &[OsString],
605    environment: &[(OsString, OsString)],
606    inputs: &ResolutionInputs,
607    filesystem_section: &dyn Fn(&Path) -> String,
608) -> Action {
609    let resolved: ResolvedGit = match resolve_real_git(inputs) {
610        Ok(found) => found,
611        Err(error) => return failure(error.to_string().as_str()),
612    };
613    let real_git: PathBuf = resolved.path;
614    let cwd: PathBuf = match std::env::current_dir() {
615        Ok(found) => found,
616        Err(error) => return failure(format!("cannot determine the current directory: {error}").as_str()),
617    };
618    let overlay: Vec<(OsString, OsString)> = child_environment_overlay(environment, real_git.as_path());
619    let context: GitContext = GitContext {
620        real_git,
621        overlay,
622        global_prefix: git_global_arguments.to_vec(),
623        // The doctor sends no input, so no scratch directory is needed.
624        common_dir: None,
625    };
626    let action: Action = doctor_action_with_section(&context, cwd.as_path(), filesystem_section);
627    return with_leading_notice(action, resolved.notice.as_deref());
628}
629
630/// What:
631///  Decide the action for the arguments after `git cli-git`.
632///       `&[OsString]` borrows argument lists;
633///  `&Controls` borrows what the wrapper
634///       controls written before `cli-git` asked for;
635///  `&ResolutionInputs` borrows process facts.
636/// Why:
637///   Help,
638///  refusals and retired commands answer without resolving Git or reading
639///       any repository.
640///  A direct command validates its selection,
641///  asks Git once where it
642///       runs,
643///  refuses beside commit transactions it cannot recover,
644///  loads that
645///       worktree's configuration and runs one policy pass.
646///
647/// In TS you'd write (pseudocode):
648/// ```ts
649/// function planManagement(args, gitGlobalArgs, controls, environment, inputs): Action;
650/// ```
651pub fn plan_management(
652    arguments: &[OsString],
653    git_global_arguments: &[OsString],
654    controls: &Controls,
655    environment: &[(OsString, OsString)],
656    inputs: &ResolutionInputs,
657) -> Action {
658    return plan_management_with_section(arguments, git_global_arguments, controls, environment, inputs, &|_cwd: &Path| { return String::new(); });
659}
660
661/// What:
662///  The same decision with the doctor's filesystem section supplied by the caller.
663///  `&dyn Fn(&Path) -> String` is a borrowed function from the repository's common Git directory to the section text.
664/// Why:
665///   The executable passes the section read from the running system (`host_filesystem_section`); the controls of the
666///  doctor and of the management command pass an empty one, so their exact output does not depend on the host.
667///
668/// In TS you'd write (pseudocode):
669/// ```ts
670/// function planManagementWithSection(args, gitGlobalArgs, controls, environment, inputs, filesystemSection): Action;
671/// ```
672pub fn plan_management_with_section(
673    arguments: &[OsString],
674    git_global_arguments: &[OsString],
675    controls: &Controls,
676    environment: &[(OsString, OsString)],
677    inputs: &ResolutionInputs,
678    filesystem_section: &dyn Fn(&Path) -> String,
679) -> Action {
680    // What: `match` on the parse `Result`: keep the action or return the refusal.
681    // Why:  Nothing else may run for an invocation the grammar rejects.
682    //
683    // In TS you'd write (pseudocode):
684    // ```ts
685    // const parsed = parseManagementArgs(args); if (parsed === MANAGEMENT_REFUSED) return usage();
686    // ```
687    let parsed: ManagementAction = match parse_management_arguments(arguments) {
688        Ok(action) => action,
689        Err(reason) => return refusal(reason),
690    };
691    // `match` picks by variant and binds the fields each one carries.
692    let (fix, policies, scope): (bool, Vec<String>, Vec<OsString>) = match parsed {
693        ManagementAction::Help => {
694            return Action::Exit {
695                code: 0,
696                stdout: String::from(MANAGEMENT_HELP),
697                stderr: String::new(),
698            };
699        }
700        ManagementAction::Retired { command, help } => {
701            // Help goes to standard output; an attempted action is explained on standard error.
702            if help {
703                return Action::Exit {
704                    code: 0,
705                    stdout: retired_explanation(command),
706                    stderr: String::new(),
707                };
708            }
709            return Action::Exit {
710                code: 0,
711                stdout: String::new(),
712                stderr: retired_explanation(command),
713            };
714        }
715        ManagementAction::Doctor => return plan_doctor(git_global_arguments, environment, inputs, filesystem_section),
716        ManagementAction::ServiceStatus => return plan_service(git_global_arguments, environment, inputs),
717        ManagementAction::MigrateToSubvolume(migrate_parse) => return plan_migrate(migrate_parse),
718        ManagementAction::ConvertWorktree(convert_parse) => return plan_convert(convert_parse, git_global_arguments, environment, inputs),
719        ManagementAction::Direct {
720            fix,
721            all,
722            policies,
723            pathspecs,
724        } => (fix, policies, scope_pathspecs(all, pathspecs)),
725    };
726    let selected: Vec<PolicyId> = match selected_policies(policies.as_slice()) {
727        Ok(ids) => ids,
728        // `&error` lends the error to the renderer.
729        Err(error) => return direct_config_failure(&error),
730    };
731    let resolved: ResolvedGit = match resolve_real_git(inputs) {
732        Ok(found) => found,
733        Err(error) => return failure(error.to_string().as_str()),
734    };
735    let action: Action = plan_direct(
736        fix,
737        selected,
738        scope,
739        controls,
740        environment,
741        git_global_arguments,
742        resolved.path,
743    );
744    // `.as_deref()` lends the optional owned notice as an optional borrowed one.
745    return with_leading_notice(action, resolved.notice.as_deref());
746}
747
748/// What:
749///  Plan a direct command (`git cli-git check` and its kin) once the real Git is known.
750/// Why:
751///   `plan_management_with_section` adds the notice of a recorded fallback to whatever action this returns,
752///  so the many early stops of the planning carry it without repeating it.
753///
754/// In TS you'd write (pseudocode):
755/// ```ts
756/// function planDirect(fix, selected, scope, controls, environment, gitGlobalArguments, realGit): Action;
757/// ```
758fn plan_direct(
759    fix: bool,
760    selected: Vec<PolicyId>,
761    scope: Vec<OsString>,
762    controls: &Controls,
763    environment: &[(OsString, OsString)],
764    git_global_arguments: &[OsString],
765    real_git: PathBuf,
766) -> Action {
767    let overlay: Vec<(OsString, OsString)> =
768        child_environment_overlay(environment, real_git.as_path());
769    // `mut` lets the pass cache facts it asks Git for.
770    let mut checks: ShippedChecks<GitFacts> = shipped_checks(
771        git_facts(real_git.as_path(), git_global_arguments, overlay.as_slice()),
772        // `.to_vec()` copies the borrowed global options for the rule cores.
773        git_global_arguments.to_vec(),
774        LifecycleContent::Requested(CandidateRequest::Direct(scope)),
775        // `Vec::new()` is an empty owned list: no direct-command policy reads the tool caches.
776        Vec::<PathBuf>::new(),
777    );
778    // The forbidden-strings rules variable is read from this invocation's environment.
779    checks.scanner_settings.rules_variable = environment_value(environment, RULES_VARIABLE);
780    let location: RepositoryLocation = match checks.facts.location() {
781        Ok(found) => found,
782        Err(message) => return failure(message.as_str()),
783    };
784    // A direct command forwards nothing, so only an isolated merge whose process died matters here: it is settled first.
785    match recover_for_command(&checks.facts.transaction_context(), &location.identity) {
786        Gate::Clear => {}
787        Gate::Stop(stderr) => {
788            return Action::Exit {
789                code: ENGINE_FAILURE_EXIT_CODE,
790                stdout: String::new(),
791                stderr,
792            };
793        }
794    }
795    let action: Action = match load_identity_config(&location.identity) {
796        Ok(loaded) => match selection_error(
797            selected.as_slice(),
798            &loaded.config.policies,
799            if fix { Trigger::DirectFix } else { Trigger::DirectCheck },
800        ) {
801            // A selected policy that is off, or does not run for this command, would check nothing and look clean:
802            // stop before any work.
803            Some(error) => direct_config_failure(&error),
804            None => run_direct_command(
805                fix,
806                selected,
807                controls,
808                &loaded,
809                &mut checks,
810                worktree_root(&location.identity),
811            ),
812        },
813        Err(error) => direct_config_failure(&error),
814    };
815    return action;
816}
817
818/// Decision controls stay out of the release executable.
819#[cfg(test)]
820#[path = "management_tests.rs"]
821mod tests;