1use super::action::{Action, ENGINE_FAILURE_EXIT_CODE, PushForward, failure};
20#[cfg(unix)]
21use super::auto_push_trigger::{TipWatch, hand_over_if_ahead, try_watch_tip};
22use super::push_trigger_log::{Outcome, record_decision};
23use super::announced_query::{WRAPPER_VERSION, announced_query, announcement, printable_after};
24use super::command_push::stackable_option_tokens;
25use super::external_hooks::external_hooks_warning;
26use super::lfs_pre_push::uses_lfs;
27use super::lfs_upload_plan::settings_from_snapshot;
28use super::config_snapshot::{ConfigSnapshot, read_config_snapshot};
29use super::push_stacking::{STACKED_PUSH_MARKER, run_stacked_push};
30use super::command_push::parse_push_region;
31use super::child_environment::{
32 FORWARD_TARGET_VARIABLE, child_environment_overlay, environment_value,
33};
34use super::effective_target::default_allowed_worktree_dirs;
35use super::forwarding::{exit_code, replace_process_with_real_git, run_real_git};
36use super::index_writer_forward::{CoordinatedForward, CoordinatedRun, run_coordinated_forward};
37use super::index_writer_commands::is_index_writer;
38use super::forwarded_command::{ResolvedCommand, Route, creates_or_moves_worktrees, resolve_forwarded_command};
39use super::invocation_config::{config_invalid_event, load_identity_config};
40use super::config_file::LoadedConfig;
41use super::repository_location::RepositoryLocation;
42use super::transaction_git::GitContext;
43use super::worktree_identity::WorktreeIdentity;
44use super::global_arguments::{GlobalLayout, GlobalOutcome, global_layout};
45use super::management::{host_filesystem_section, plan_management_with_section};
46use super::policy_checks::{ShippedChecks, shipped_checks};
49use super::policy_content::LifecycleContent;
51use super::real_git::{ResolutionInputs, process_resolution_inputs, resolve_real_git};
52use super::real_git_candidate::same_file;
53use super::repository_facts::{GitFacts, RepositoryFacts, git_facts};
54use super::rule_require_root::{RequireRootDecision, decide_require_root};
55use super::scanner_selection::RULES_VARIABLE;
57use super::wrapped_command::{WrappedOutcome, run_wrapped_command};
58use super::wrapper_controls::{Controls, no_controls, strip_global_controls};
59use super::worktree_copy_forward::{CopyFacts, ForwardedRun, copy_applies, run_forwarded_git};
60use super::wrapper_invocation::{StrippedInvocation, strip_wrapper_controls};
61use std::ffi::OsString;
72use std::io::Write;
74use std::path::{Path, PathBuf};
76
77pub const MANAGEMENT_COMMAND: &str = "cli-git";
81
82fn forwarded_to_self(own_executable: &Path) -> Action {
95 return failure(
97 format!(
98 "another cli-git wrapper selected this cli-git executable ({}) as real Git. \
99 Forwarding again would never reach Git. Remove the extra cli-git from PATH or \
100 place the real Git executable on PATH.",
101 own_executable.display()
102 )
103 .as_str(),
104 );
105}
106
107fn home_directory(environment: &[(OsString, OsString)]) -> Option<PathBuf> {
122 if let Some(home) = environment_value(environment, "HOME")
124 && !home.is_empty()
125 {
126 return Some(PathBuf::from(home));
128 }
129 return None;
131}
132
133pub fn plan_invocation(
154 arguments: &[OsString],
155 environment: &[(OsString, OsString)],
156 inputs: &ResolutionInputs,
157) -> Action {
158 if let Some(target) = environment_value(environment, FORWARD_TARGET_VARIABLE)
161 && same_file(Path::new(&target), inputs.own_executable.as_path())
162 {
163 return forwarded_to_self(inputs.own_executable.as_path());
164 }
165 let mut global_controls: Controls = no_controls();
167 let global_clean: Vec<OsString> = strip_global_controls(arguments, &mut global_controls);
169 let layout: GlobalLayout = global_layout(global_clean.as_slice());
171 if layout.outcome == GlobalOutcome::Command
172 && global_clean[layout.prefix_len] == MANAGEMENT_COMMAND
173 {
174 return plan_management_with_section(
176 &global_clean[layout.prefix_len + 1..],
177 &global_clean[..layout.prefix_len],
178 &global_controls,
179 environment,
180 inputs,
181 &host_filesystem_section,
182 );
183 }
184 let real_git: PathBuf = match resolve_real_git(inputs) {
192 Ok(found) => found,
193 Err(error) => return failure(error.to_string().as_str()),
194 };
195 let overlay: Vec<(OsString, OsString)> =
196 child_environment_overlay(environment, real_git.as_path());
197 if let Some(query) = announced_query(global_clean.as_slice(), &layout) {
206 return Action::ForwardAnnounced {
207 announcement: announcement(query, WRAPPER_VERSION, real_git.as_path()),
208 real_git,
209 arguments: global_clean,
210 overlay,
211 stderr: String::new(),
212 };
213 }
214 let stripped: StrippedInvocation = strip_wrapper_controls(arguments);
215 let facts: GitFacts = git_facts(
217 real_git.as_path(),
218 &stripped.arguments[..stripped.layout.prefix_len],
219 overlay.as_slice(),
220 );
221 let mut checks: ShippedChecks<GitFacts> = shipped_checks(
223 facts,
224 stripped.arguments.clone(),
226 LifecycleContent::None,
227 default_allowed_worktree_dirs(
228 environment,
229 home_directory(environment).as_deref(),
231 ),
232 );
233 checks.scanner_settings.rules_variable = environment_value(environment, RULES_VARIABLE);
235 match run_wrapped_command(&stripped, environment, &mut checks) {
237 WrappedOutcome::Forward {
239 arguments: forwarded,
240 stderr,
241 } => {
242 let copy_bypassed: bool = stripped.controls.skip_worktree_copy
245 || stripped.layout.outcome != GlobalOutcome::Command
246 || decide_require_root(forwarded.as_slice(), &[])
247 != RequireRootDecision::NeedsRepositoryRoot;
248 return stack_if_push(
249 forward_action(
250 real_git,
251 forwarded,
252 overlay,
253 stderr,
254 environment,
255 copy_bypassed,
256 &mut checks,
257 ),
258 environment,
259 );
260 }
261 WrappedOutcome::Exit { code, stderr } => {
262 return Action::Exit {
263 code,
264 stdout: String::new(),
266 stderr,
267 };
268 }
269 }
270}
271
272pub fn run_process(arguments: &[OsString], environment: &[(OsString, OsString)]) -> i32 {
288 super::hooks_off::activate();
290 let action: Action = match process_resolution_inputs(environment) {
291 Ok(inputs) => plan_invocation(arguments, environment, &inputs),
292 Err(error) => failure(
293 format!(
294 "cannot determine this executable or the current directory, so real Git \
295 cannot be selected safely: {error}"
296 )
297 .as_str(),
298 ),
299 };
300 match action {
302 Action::Forward {
303 real_git,
304 arguments: forwarded,
305 overlay,
306 stderr,
307 } => {
308 write_stream(&mut std::io::stderr(), stderr.as_str());
310 let error: std::io::Error = replace_process_with_real_git(
311 real_git.as_path(),
312 forwarded.as_slice(),
313 overlay.as_slice(),
314 );
315 write_stream(
316 &mut std::io::stderr(),
317 format!(
318 "cli-git: cannot start {}: {error}. Hint: if you moved or upgraded Git, check that the file exists and is executable with `ls -l {}`, \
319 then reinstall Git or the wrapper; no Git command ran.\n",
320 real_git.display(),
321 real_git.display()
322 )
323 .as_str(),
324 );
325 return ENGINE_FAILURE_EXIT_CODE;
326 }
327 Action::ForwardCoordinated { forward, stderr } => {
328 write_stream(&mut std::io::stderr(), stderr.as_str());
329 return run_coordinated_action(&forward);
330 }
331 Action::ForwardAnnounced {
332 real_git,
333 arguments: forwarded,
334 overlay,
335 announcement,
336 stderr,
337 } => {
338 write_stream(&mut std::io::stderr(), stderr.as_str());
339 return run_announced(
340 real_git.as_path(),
341 forwarded.as_slice(),
342 overlay.as_slice(),
343 announcement.as_str(),
344 );
345 }
346 Action::ForwardSettled {
347 real_git,
348 arguments: forwarded,
349 overlay,
350 copy,
351 stderr,
352 } => {
353 write_stream(&mut std::io::stderr(), stderr.as_str());
354 return finish_forwarded(run_forwarded_git(
355 real_git.as_path(),
356 forwarded.as_slice(),
357 overlay.as_slice(),
358 Some(&*copy),
359 ));
360 }
361 Action::ForwardPush { forward, stderr } => {
362 write_stream(&mut std::io::stderr(), stderr.as_str());
363 return run_stacked_push(&forward);
364 }
365 Action::Exit {
366 code,
367 stdout,
368 stderr,
369 } => {
370 write_stream(&mut std::io::stdout(), stdout.as_str());
372 write_stream(&mut std::io::stderr(), stderr.as_str());
373 return code;
374 }
375 }
376}
377
378fn run_announced(
391 real_git: &Path,
392 arguments: &[OsString],
393 overlay: &[(OsString, OsString)],
394 text: &str,
395) -> i32 {
396 match run_real_git(real_git, arguments, overlay) {
397 Ok(outcome) => {
398 write_stream(&mut std::io::stdout(), printable_after(outcome, text));
399 return exit_code(outcome);
400 }
401 Err(error) => {
402 write_stream(
403 &mut std::io::stderr(),
404 format!(
405 "cli-git: cannot start {}: {error}. Hint: if you moved or upgraded Git, check that the file exists and is executable with `ls -l {}`, \
406 then reinstall Git or the wrapper; no Git command ran.\n",
407 real_git.display(),
408 real_git.display()
409 )
410 .as_str(),
411 );
412 return ENGINE_FAILURE_EXIT_CODE;
413 }
414 }
415}
416
417fn finish_forwarded(run: ForwardedRun) -> i32 {
427 match run {
429 ForwardedRun::Ran(code) => return code,
430 ForwardedRun::Failed { stderr, code } => {
431 write_stream(&mut std::io::stderr(), stderr.as_str());
432 return code;
433 }
434 }
435}
436
437fn run_coordinated_action(forward: &CoordinatedForward) -> i32 {
451 let watch: Option<TipWatch> = if adds_commits(&forward.command) { watch_forward(forward) } else { None };
453 let code: i32 = match run_coordinated_forward(forward) {
454 CoordinatedRun::Ran(code) => code,
455 CoordinatedRun::Failed { stderr, code } => {
456 write_stream(&mut std::io::stderr(), stderr.as_str());
457 code
458 }
459 CoordinatedRun::NotCoordinated if watch.is_some() => {
460 finish_forwarded(run_forwarded_git(forward.real_git.as_path(), forward.arguments.as_slice(), forward.overlay.as_slice(), None))
461 }
462 CoordinatedRun::NotCoordinated => {
463 let error: std::io::Error = replace_process_with_real_git(
464 forward.real_git.as_path(),
465 forward.arguments.as_slice(),
466 forward.overlay.as_slice(),
467 );
468 write_stream(
469 &mut std::io::stderr(),
470 format!("cli-git: cannot start {}: {error}\n", forward.real_git.display()).as_str(),
471 );
472 ENGINE_FAILURE_EXIT_CODE
473 }
474 };
475 if let Some(found) = watch.as_ref() {
476 if code == 0 {
477 hand_over_if_ahead(found, trigger_command(&forward.command).as_str(), &mut std::io::stderr());
478 } else {
479 record_decision(
480 Some(found.common_dir()),
481 trigger_command(&forward.command).as_str(),
482 Outcome::Declined,
483 "command-failed",
484 format!("Git exited with status {code}").as_str(),
485 None,
486 );
487 }
488 }
489 return code;
490}
491
492#[cfg(not(unix))]
494struct TipWatch;
495
496#[cfg(not(unix))]
498fn watch_forward(_forward: &CoordinatedForward) -> Option<TipWatch> {
499 return None;
500}
501
502#[cfg(not(unix))]
504fn hand_over_if_ahead(_watch: &TipWatch, _command: &str, _stderr: &mut dyn Write) {}
505
506fn adds_commits(command: &ResolvedCommand) -> bool {
518 return command.is_builtin() && matches!(command.word(), b"merge" | b"pull" | b"cherry-pick" | b"revert" | b"am");
519}
520
521fn trigger_command(command: &ResolvedCommand) -> String {
528 return String::from_utf8_lossy(command.word()).into_owned();
529}
530
531#[cfg(unix)]
540fn watch_forward(forward: &CoordinatedForward) -> Option<TipWatch> {
541 let (root, common): (PathBuf, PathBuf) = match &forward.identity {
542 WorktreeIdentity::MainWorktree { worktree_root, common_dir, .. } | WorktreeIdentity::LinkedWorktree { worktree_root, common_dir, .. } => {
543 (worktree_root.clone(), common_dir.clone())
544 }
545 WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => return None,
546 };
547 return match try_watch_tip(root.as_path(), forward.real_git.as_path(), forward.overlay.as_slice(), forward.environment.as_slice()) {
548 Ok(watch) => Some(watch),
549 Err(declined) => {
550 record_decision(Some(common.as_path()), trigger_command(&forward.command).as_str(), Outcome::Declined, declined.reason, declined.detail.as_str(), None);
551 None
552 }
553 };
554}
555
556fn forward_action(
572 real_git: PathBuf,
573 arguments: Vec<OsString>,
574 overlay: Vec<(OsString, OsString)>,
575 stderr: String,
576 environment: &[(OsString, OsString)],
577 copy_bypassed: bool,
578 checks: &mut ShippedChecks<GitFacts>,
579) -> Action {
580 let context: GitContext = GitContext {
581 real_git: real_git.clone(),
582 overlay: overlay.clone(),
583 global_prefix: Vec::new(),
584 common_dir: None,
586 };
587 let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
588 let forwarded_arguments: Vec<OsString> = match command.route {
591 Route::Alias => command.arguments.clone(),
592 Route::Direct | Route::ShellAlias | Route::Unresolved => arguments,
593 };
594 let writes_index: bool = is_index_writer(&command);
595 if !writes_index && copy_bypassed {
596 return Action::Forward {
597 real_git,
598 arguments: forwarded_arguments,
599 overlay,
600 stderr,
601 };
602 }
603 let location: RepositoryLocation = match checks.facts.location() {
604 Ok(found) => found,
605 Err(message) => return failure(message.as_str()),
606 };
607 if location.identity == WorktreeIdentity::OutsideWorktree {
608 return Action::Forward {
609 real_git,
610 arguments: forwarded_arguments,
611 overlay,
612 stderr,
613 };
614 }
615 let loaded: Option<LoadedConfig> = match configuration_needed(&location.identity, &command, writes_index) {
616 false => None,
617 true => match load_identity_config(&location.identity) {
618 Ok(found) => Some(found),
619 Err(error) => {
620 return Action::Exit {
621 code: ENGINE_FAILURE_EXIT_CODE,
622 stdout: String::new(),
623 stderr: config_invalid_event(&error),
624 };
625 }
626 },
627 };
628 let creates_or_moves: bool = creates_or_moves_worktrees(&command);
629 let main_checkout_paths: Vec<String> = match (&location.identity, &loaded) {
631 (WorktreeIdentity::MainWorktree { .. }, Some(found)) => found
633 .config
634 .policies
635 .forbidden_strings
636 .rules_files
637 .iter()
638 .flatten()
639 .map(|file| return file.path.clone())
640 .collect(),
641 _ => Vec::new(),
642 };
643 let copy: Option<CopyFacts> =
644 if !copy_bypassed && copy_applies(&location.identity, creates_or_moves, main_checkout_paths.as_slice()) {
645 Some(CopyFacts {
646 identity: location.identity.clone(),
647 creates_or_moves,
648 main_checkout_paths,
649 global_prefix: checks.facts.transaction_context().global_prefix,
650 environment: environment.to_vec(),
651 })
652 } else {
653 None
654 };
655 if !writes_index {
656 let Some(facts) = copy else {
657 return Action::Forward {
658 real_git,
659 arguments: forwarded_arguments,
660 overlay,
661 stderr,
662 };
663 };
664 return Action::ForwardSettled {
665 real_git,
666 arguments: forwarded_arguments,
667 overlay,
668 copy: Box::new(facts),
669 stderr,
670 };
671 }
672 return Action::ForwardCoordinated {
673 forward: Box::new(CoordinatedForward {
674 real_git,
675 arguments: forwarded_arguments,
676 overlay,
677 identity: location.identity,
678 command,
679 environment: environment.to_vec(),
680 copy,
681 }),
682 stderr,
683 };
684}
685
686fn needs_lfs_upload(
701 command: &ResolvedCommand,
702 real_git: &Path,
703 overlay: &[(OsString, OsString)],
704) -> (bool, String, Option<Vec<(String, String)>>) {
705 if command.word() != b"push" {
706 return (false, String::new(), None);
707 }
708 let dry_run: bool = parse_push_region(command.region(), &[]).is_ok_and(|region| {
709 return region.dry_run;
710 });
711 let context: GitContext = GitContext {
712 real_git: real_git.to_path_buf(),
713 overlay: overlay.to_vec(),
714 global_prefix: command.arguments[..command.subcommand_index].to_vec(),
715 common_dir: None,
716 };
717 let snapshot: Option<ConfigSnapshot> = match std::env::current_dir() {
718 Ok(directory) => read_config_snapshot(&context, directory.as_path()),
719 Err(_) => None,
720 };
721 let warning: String = external_hooks_warning(snapshot.as_ref()).unwrap_or_default();
722 if dry_run {
723 return (false, warning, None);
724 }
725 return match snapshot {
726 Some(found) => {
727 let uses: bool = uses_lfs(&found);
728 let settings: Option<Vec<(String, String)>> = uses.then(|| {
729 return settings_from_snapshot(&found);
730 });
731 (uses, warning, settings)
732 }
733 None => (true, warning, None),
734 };
735}
736
737fn stack_if_push(action: Action, environment: &[(OsString, OsString)]) -> Action {
754 let (real_git, arguments, overlay, copy, stderr) = match action {
755 Action::Forward {
756 real_git,
757 arguments,
758 overlay,
759 stderr,
760 } => (real_git, arguments, overlay, None, stderr),
761 Action::ForwardSettled {
762 real_git,
763 arguments,
764 overlay,
765 copy,
766 stderr,
767 } => (real_git, arguments, overlay, Some(copy), stderr),
768 other => return other,
769 };
770 let context: GitContext = GitContext {
771 real_git: real_git.clone(),
772 overlay: overlay.clone(),
773 global_prefix: Vec::new(),
774 common_dir: None,
776 };
777 let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
778 let (lfs, hook_warning, lfs_settings): (bool, String, Option<Vec<(String, String)>>) =
779 needs_lfs_upload(&command, real_git.as_path(), overlay.as_slice());
780 let stderr_with_warning: String = format!("{stderr}{hook_warning}");
781 let stacked_already: bool = environment_value(environment, STACKED_PUSH_MARKER).is_some();
782 let stack: bool =
783 !stacked_already && command.word() == b"push" && stackable_option_tokens(command.region()).is_some();
784 if stack || lfs {
787 return Action::ForwardPush {
788 forward: Box::new(PushForward {
789 real_git,
790 subcommand_index: command.subcommand_index,
791 arguments: command.arguments,
792 overlay,
793 copy,
794 environment: environment.to_vec(),
795 lfs,
796 lfs_settings,
797 stack,
798 }),
799 stderr: stderr_with_warning,
800 };
801 }
802 let forwarded: Vec<OsString> = arguments;
803 return match copy {
804 Some(facts) => Action::ForwardSettled {
805 real_git,
806 arguments: forwarded,
807 overlay,
808 copy: facts,
809 stderr: stderr_with_warning,
810 },
811 None => Action::Forward {
812 real_git,
813 arguments: forwarded,
814 overlay,
815 stderr: stderr_with_warning,
816 },
817 };
818}
819
820fn configuration_needed(identity: &WorktreeIdentity, command: &ResolvedCommand, writes_index: bool) -> bool {
831 return writes_index
832 || (matches!(identity, WorktreeIdentity::MainWorktree { .. })
833 && creates_or_moves_worktrees(command));
834}
835
836fn write_stream(stream: &mut dyn Write, text: &str) {
852 let _ = stream.write_all(text.as_bytes());
860}
861
862#[cfg(test)]
865#[path = "entry_announced_tests.rs"]
866mod announced_tests;
867
868#[cfg(test)]
871#[path = "entry_stop_tests.rs"]
872mod stop_tests;
873
874#[cfg(test)]
876#[path = "entry_tests.rs"]
877mod tests;