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::{Decision, Origin, 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(&Decision {
481 common_dir: Some(found.common_dir()),
482 origin: Some(found.origin()),
483 command: trigger_command(&forward.command).as_str(),
484 outcome: Outcome::Declined,
485 reason: "command-failed",
486 detail: format!("Git exited with status {code}; {} was at {} before the command", found.branch_ref(), found.tip_before()).as_str(),
487 branch: Some(found.branch_ref()),
488 });
489 }
490 }
491 return code;
492}
493
494#[cfg(not(unix))]
496struct TipWatch;
497
498#[cfg(not(unix))]
500fn watch_forward(_forward: &CoordinatedForward) -> Option<TipWatch> {
501 return None;
502}
503
504#[cfg(not(unix))]
506fn hand_over_if_ahead(_watch: &TipWatch, _command: &str, _stderr: &mut dyn Write) {}
507
508fn adds_commits(command: &ResolvedCommand) -> bool {
520 return command.is_builtin() && matches!(command.word(), b"merge" | b"pull" | b"cherry-pick" | b"revert" | b"am");
521}
522
523fn trigger_command(command: &ResolvedCommand) -> String {
530 return String::from_utf8_lossy(command.word()).into_owned();
531}
532
533#[cfg(unix)]
542fn watch_forward(forward: &CoordinatedForward) -> Option<TipWatch> {
543 let (root, common): (PathBuf, PathBuf) = match &forward.identity {
544 WorktreeIdentity::MainWorktree { worktree_root, common_dir, .. } | WorktreeIdentity::LinkedWorktree { worktree_root, common_dir, .. } => {
545 (worktree_root.clone(), common_dir.clone())
546 }
547 WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => return None,
548 };
549 return match try_watch_tip(root.as_path(), forward.real_git.as_path(), forward.overlay.as_slice(), forward.environment.as_slice()) {
550 Ok(watch) => Some(watch),
551 Err(declined) => {
552 let git_dir: PathBuf = match &forward.identity {
554 WorktreeIdentity::MainWorktree { git_dir, .. } | WorktreeIdentity::LinkedWorktree { git_dir, .. } => git_dir.clone(),
555 WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => common.clone(),
556 };
557 record_decision(&Decision {
558 common_dir: Some(common.as_path()),
559 origin: Some(Origin { worktree: root.as_path(), git_dir: git_dir.as_path() }),
560 command: trigger_command(&forward.command).as_str(),
561 outcome: Outcome::Declined,
562 reason: declined.reason,
563 detail: declined.detail.as_str(),
564 branch: None,
565 });
566 None
567 }
568 };
569}
570
571fn forward_action(
587 real_git: PathBuf,
588 arguments: Vec<OsString>,
589 overlay: Vec<(OsString, OsString)>,
590 stderr: String,
591 environment: &[(OsString, OsString)],
592 copy_bypassed: bool,
593 checks: &mut ShippedChecks<GitFacts>,
594) -> Action {
595 let context: GitContext = GitContext {
596 real_git: real_git.clone(),
597 overlay: overlay.clone(),
598 global_prefix: Vec::new(),
599 common_dir: None,
601 };
602 let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
603 let forwarded_arguments: Vec<OsString> = match command.route {
606 Route::Alias => command.arguments.clone(),
607 Route::Direct | Route::ShellAlias | Route::Unresolved => arguments,
608 };
609 let writes_index: bool = is_index_writer(&command);
610 if !writes_index && copy_bypassed {
611 return Action::Forward {
612 real_git,
613 arguments: forwarded_arguments,
614 overlay,
615 stderr,
616 };
617 }
618 let location: RepositoryLocation = match checks.facts.location() {
619 Ok(found) => found,
620 Err(message) => return failure(message.as_str()),
621 };
622 if location.identity == WorktreeIdentity::OutsideWorktree {
623 return Action::Forward {
624 real_git,
625 arguments: forwarded_arguments,
626 overlay,
627 stderr,
628 };
629 }
630 let loaded: Option<LoadedConfig> = match configuration_needed(&location.identity, &command, writes_index) {
631 false => None,
632 true => match load_identity_config(&location.identity) {
633 Ok(found) => Some(found),
634 Err(error) => {
635 return Action::Exit {
636 code: ENGINE_FAILURE_EXIT_CODE,
637 stdout: String::new(),
638 stderr: config_invalid_event(&error),
639 };
640 }
641 },
642 };
643 let creates_or_moves: bool = creates_or_moves_worktrees(&command);
644 let main_checkout_paths: Vec<String> = match (&location.identity, &loaded) {
646 (WorktreeIdentity::MainWorktree { .. }, Some(found)) => found
648 .config
649 .policies
650 .forbidden_strings
651 .rules_files
652 .iter()
653 .flatten()
654 .map(|file| return file.path.clone())
655 .collect(),
656 _ => Vec::new(),
657 };
658 let copy: Option<CopyFacts> =
659 if !copy_bypassed && copy_applies(&location.identity, creates_or_moves, main_checkout_paths.as_slice()) {
660 Some(CopyFacts {
661 identity: location.identity.clone(),
662 creates_or_moves,
663 main_checkout_paths,
664 global_prefix: checks.facts.transaction_context().global_prefix,
665 environment: environment.to_vec(),
666 })
667 } else {
668 None
669 };
670 if !writes_index {
671 let Some(facts) = copy else {
672 return Action::Forward {
673 real_git,
674 arguments: forwarded_arguments,
675 overlay,
676 stderr,
677 };
678 };
679 return Action::ForwardSettled {
680 real_git,
681 arguments: forwarded_arguments,
682 overlay,
683 copy: Box::new(facts),
684 stderr,
685 };
686 }
687 return Action::ForwardCoordinated {
688 forward: Box::new(CoordinatedForward {
689 real_git,
690 arguments: forwarded_arguments,
691 overlay,
692 identity: location.identity,
693 command,
694 environment: environment.to_vec(),
695 copy,
696 }),
697 stderr,
698 };
699}
700
701fn needs_lfs_upload(
716 command: &ResolvedCommand,
717 real_git: &Path,
718 overlay: &[(OsString, OsString)],
719) -> (bool, String, Option<Vec<(String, String)>>) {
720 if command.word() != b"push" {
721 return (false, String::new(), None);
722 }
723 let dry_run: bool = parse_push_region(command.region(), &[]).is_ok_and(|region| {
724 return region.dry_run;
725 });
726 let context: GitContext = GitContext {
727 real_git: real_git.to_path_buf(),
728 overlay: overlay.to_vec(),
729 global_prefix: command.arguments[..command.subcommand_index].to_vec(),
730 common_dir: None,
731 };
732 let snapshot: Option<ConfigSnapshot> = match std::env::current_dir() {
733 Ok(directory) => read_config_snapshot(&context, directory.as_path()),
734 Err(_) => None,
735 };
736 let warning: String = external_hooks_warning(snapshot.as_ref()).unwrap_or_default();
737 if dry_run {
738 return (false, warning, None);
739 }
740 return match snapshot {
741 Some(found) => {
742 let uses: bool = uses_lfs(&found);
743 let settings: Option<Vec<(String, String)>> = uses.then(|| {
744 return settings_from_snapshot(&found);
745 });
746 (uses, warning, settings)
747 }
748 None => (true, warning, None),
749 };
750}
751
752fn stack_if_push(action: Action, environment: &[(OsString, OsString)]) -> Action {
769 let (real_git, arguments, overlay, copy, stderr) = match action {
770 Action::Forward {
771 real_git,
772 arguments,
773 overlay,
774 stderr,
775 } => (real_git, arguments, overlay, None, stderr),
776 Action::ForwardSettled {
777 real_git,
778 arguments,
779 overlay,
780 copy,
781 stderr,
782 } => (real_git, arguments, overlay, Some(copy), stderr),
783 other => return other,
784 };
785 let context: GitContext = GitContext {
786 real_git: real_git.clone(),
787 overlay: overlay.clone(),
788 global_prefix: Vec::new(),
789 common_dir: None,
791 };
792 let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
793 let (lfs, hook_warning, lfs_settings): (bool, String, Option<Vec<(String, String)>>) =
794 needs_lfs_upload(&command, real_git.as_path(), overlay.as_slice());
795 let stderr_with_warning: String = format!("{stderr}{hook_warning}");
796 let stacked_already: bool = environment_value(environment, STACKED_PUSH_MARKER).is_some();
797 let stack: bool =
798 !stacked_already && command.word() == b"push" && stackable_option_tokens(command.region()).is_some();
799 if stack || lfs {
802 return Action::ForwardPush {
803 forward: Box::new(PushForward {
804 real_git,
805 subcommand_index: command.subcommand_index,
806 arguments: command.arguments,
807 overlay,
808 copy,
809 environment: environment.to_vec(),
810 lfs,
811 lfs_settings,
812 stack,
813 }),
814 stderr: stderr_with_warning,
815 };
816 }
817 let forwarded: Vec<OsString> = arguments;
818 return match copy {
819 Some(facts) => Action::ForwardSettled {
820 real_git,
821 arguments: forwarded,
822 overlay,
823 copy: facts,
824 stderr: stderr_with_warning,
825 },
826 None => Action::Forward {
827 real_git,
828 arguments: forwarded,
829 overlay,
830 stderr: stderr_with_warning,
831 },
832 };
833}
834
835fn configuration_needed(identity: &WorktreeIdentity, command: &ResolvedCommand, writes_index: bool) -> bool {
846 return writes_index
847 || (matches!(identity, WorktreeIdentity::MainWorktree { .. })
848 && creates_or_moves_worktrees(command));
849}
850
851fn write_stream(stream: &mut dyn Write, text: &str) {
867 let _ = stream.write_all(text.as_bytes());
875}
876
877#[cfg(test)]
880#[path = "entry_announced_tests.rs"]
881mod announced_tests;
882
883#[cfg(test)]
886#[path = "entry_stop_tests.rs"]
887mod stop_tests;
888
889#[cfg(test)]
891#[path = "entry_tests.rs"]
892mod tests;