1use crate::action::{Action, ENGINE_FAILURE_EXIT_CODE, failure, with_leading_notice};
18use crate::candidate_prediction::CandidateRequest;
20use crate::child_environment::{child_environment_overlay, environment_value};
21use crate::config_error::ConfigError;
22use crate::config_file::LoadedConfig;
23use crate::diagnostics::EngineFailureCode;
25use crate::direct_fix::run_direct_fix;
29use 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};
39use crate::management_selection::{selected_policies, selection_error};
41use crate::policy_checks::{ShippedChecks, shipped_checks};
44use crate::policy_content::LifecycleContent;
46use crate::policy_engine::{StageEnd, StageRequest, pass_exit_code};
47use 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;
55use crate::scanner_selection::RULES_VARIABLE;
57use 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};
65use crate::worktree_identity::worktree_root;
68use crate::wrapper_controls::Controls;
69use std::ffi::OsString;
80use std::path::{Path, PathBuf};
82
83fn direct_name(fix: bool) -> &'static str {
94 if fix {
95 return "fix";
96 }
97 return "check";
98}
99
100fn direct_command(fix: bool) -> String {
111 return format!("cli-git {}", direct_name(fix));
113}
114
115fn retired_name(command: RetiredCommand) -> &'static str {
125 match command {
127 RetiredCommand::Trust => return "trust",
128 RetiredCommand::Untrust => return "untrust",
129 RetiredCommand::Status => return "status",
130 }
131}
132
133pub fn retired_explanation(command: RetiredCommand) -> String {
150 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
159fn refusal(reason: ManagementRefusal) -> Action {
171 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 stdout: String::new(),
187 stderr,
188 };
189}
190
191fn 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
210fn scope_pathspecs(all: bool, pathspecs: Vec<OsString>) -> Vec<OsString> {
225 if all {
226 return vec![OsString::from(":/")];
228 }
229 return pathspecs;
230}
231
232fn 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 let mut stdout: String = if fix {
260 String::new()
261 } else {
262 legacy_warning_events(loaded)
263 };
264 let first_sequence: u64 = stdout.matches('\n').count() as u64;
267 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 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 trigger: Some(trigger),
291 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 config: loaded.config.policies.clone(),
308 controls: controls.clone(),
309 selected,
310 };
311 let pass: PassResult = if fix {
313 run_direct_fix(&request, checks, root)
314 } else {
315 run_policy_pass(&request, checks)
316 };
317 stdout.push_str(render_policy_events(first_sequence, pass.events.as_slice()).as_str());
319 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
335fn 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#[derive(Clone, Debug, Eq, PartialEq)]
367enum Selection {
368 Found(PathBuf),
370 Outside,
372 Unresolved(String),
375}
376
377fn 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 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
438const NOT_A_REPOSITORY_EXIT_CODE: i32 = 128;
440
441fn 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
457fn optional_real_git(inputs: &ResolutionInputs) -> (Option<PathBuf>, Option<String>) {
472 match resolve_real_git(inputs) {
474 Ok(found) => return (Some(found.path), found.notice),
475 Err(_) => return (None, None),
476 }
477}
478
479fn 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 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
506fn 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
534fn 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#[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#[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#[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#[cfg(not(target_os = "linux"))]
587pub fn host_filesystem_section(_common_directory: &Path) -> String {
588 return String::new();
589}
590
591#[cfg(target_os = "linux")]
593fn run_migrate(arguments: &MigrateArguments, cwd: &Path) -> Action {
594 return migrate_action(arguments, cwd);
595}
596
597#[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 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
630pub 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
661pub 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 let parsed: ManagementAction = match parse_management_arguments(arguments) {
688 Ok(action) => action,
689 Err(reason) => return refusal(reason),
690 };
691 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 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 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 return with_leading_notice(action, resolved.notice.as_deref());
746}
747
748fn 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 let mut checks: ShippedChecks<GitFacts> = shipped_checks(
771 git_facts(real_git.as_path(), git_global_arguments, overlay.as_slice()),
772 git_global_arguments.to_vec(),
774 LifecycleContent::Requested(CandidateRequest::Direct(scope)),
775 Vec::<PathBuf>::new(),
777 );
778 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 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 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#[cfg(test)]
820#[path = "management_tests.rs"]
821mod tests;