1use std::sync::{Arc, OnceLock};
49
50use lattice_grammar::{EchoLevel, Effect};
51use lattice_mode::{
52 ActionContext, ActionHandlerContribution, ActivationPolicy, BufferStoreHandle, CapabilitySet,
53 Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
54 keymap_entry,
55};
56use lattice_vcs::Repository;
57
58pub struct MagitGlobalMode;
59
60impl MagitGlobalMode {
61 pub fn mode_id() -> ModeId {
62 ModeId::new("magit-global-mode")
63 }
64}
65
66fn magit_global_keymap_entries() -> &'static [KeymapEntry] {
67 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
68 ENTRIES.get_or_init(|| {
69 vec![
70 keymap_entry! {
71 mode: Normal, chord: "<C-x>g",
72 doc: "Open magit-status for the current repo",
73 cmd: "magit-status"
74 },
75 keymap_entry! {
76 mode: Normal, chord: "<C-c>g",
77 doc: "Open magit dispatch transient (repo-level)",
78 cmd: "magit-dispatch"
79 },
80 keymap_entry! {
81 mode: Normal, chord: "<C-c>f",
82 doc: "Open magit file-dispatch transient",
83 cmd: "magit-file-dispatch"
84 },
85 ]
86 })
87}
88
89impl Mode for MagitGlobalMode {
90 type Guard = ();
91
92 fn id(&self) -> ModeId {
93 Self::mode_id()
94 }
95
96 fn kind(&self) -> ModeKind {
97 ModeKind::Minor
98 }
99
100 fn activation_policy(&self) -> ActivationPolicy {
101 ActivationPolicy::Universal
104 }
105
106 fn options(&self) -> OptionOverrideSet {
107 OptionOverrideSet::new()
108 }
109
110 fn required_capabilities(&self) -> CapabilitySet {
111 CapabilitySet::empty()
112 }
113
114 fn keymap(&self) -> Keymap {
115 Keymap::from_entries(magit_global_keymap_entries())
116 }
117
118 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
122 global_action_handler_contributions()
123 }
124
125 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
126 Box::pin(async { Ok(()) })
127 }
128}
129
130pub(crate) fn open_repo_view_from_action(
149 ctx: &ActionContext<'_>,
150 view: &str,
151 mode_id: &str,
152) -> Effect {
153 open_repo_view_from_action_with(ctx, view, mode_id, None)
154}
155
156pub(crate) fn open_repo_view_from_action_with(
163 ctx: &ActionContext<'_>,
164 view: &str,
165 mode_id: &str,
166 rest: Option<&str>,
167) -> Effect {
168 let store = ctx.services.get::<lattice_mode::BufferStoreHandle>();
169 let scopes = ctx.services.get::<crate::repo_scope::RepoScopesHandle>();
170 match (store, scopes) {
171 (Some(store), Some(scopes)) => Effect::OpenSyntheticBuffer {
172 name: crate::repo_scope::repo_view_name_with(
173 view,
174 rest,
175 &store,
176 &scopes,
177 lattice_core::BufferId(ctx.buffer_id.raw() as u32),
178 ),
179 mode_id: mode_id.to_string(),
180 content: None,
181 cursor: None,
182 activate_minor: None,
183 },
184 _ => Effect::OpenSyntheticBuffer {
188 name: match rest {
189 Some(rest) => crate::workdir::magit_buffer_name_with(view, "", rest),
190 None => crate::workdir::magit_buffer_name(view, ""),
191 },
192 mode_id: mode_id.to_string(),
193 content: None,
194 cursor: None,
195 activate_minor: None,
196 },
197 }
198}
199
200fn global_action_handler_contributions() -> Vec<ActionHandlerContribution> {
201 let mut contributions = Vec::new();
202
203 macro_rules! open_repo {
206 ($action_name:expr, $view:expr, $mode_id:expr) => {
207 contributions.push(ActionHandlerContribution {
208 action_name: $action_name,
209 handler: Arc::new(|ctx: &ActionContext<'_>| {
210 Some(open_repo_view_from_action(ctx, $view, $mode_id))
211 }),
212 });
213 };
214 }
215
216 contributions.push(ActionHandlerContribution {
228 action_name: "action:magit-global-status",
229 handler: Arc::new(|ctx: &ActionContext<'_>| {
230 Some(open_repo_view_from_action(
231 ctx,
232 "status",
233 "magit-status-mode",
234 ))
235 }),
236 });
237 contributions.push(ActionHandlerContribution {
247 action_name: "action:magit-project-diff",
248 handler: Arc::new(|ctx: &ActionContext<'_>| {
249 Some(Effect::AppAction(
250 lattice_grammar::app_effect::AppEffect::OpenProviderView {
251 provider: crate::providers::project_diff::PROVIDER_NAME.to_string(),
252 args: ctx.args.clone(),
253 },
254 ))
255 }),
256 });
257 open_repo!("action:magit-global-commit", "commit", "magit-commit-mode");
258 macro_rules! open_view_with_args {
263 ($action_name:expr, $view:expr, $mode_id:expr, $flags:expr) => {
264 contributions.push(ActionHandlerContribution {
265 action_name: $action_name,
266 handler: Arc::new(|ctx: &ActionContext<'_>| {
267 let effect = open_repo_view_from_action(ctx, $view, $mode_id);
273 let extra = crate::magit_core_mode::view_argv($flags, &ctx.args);
274 if !extra.is_empty()
275 && let Effect::OpenSyntheticBuffer { ref name, .. } = effect
276 && let Some(reqs) = ctx
277 .services
278 .get::<crate::magit_diff_mode::ViewArgsRequestsHandle>()
279 {
280 reqs.put(name.clone(), extra);
281 }
282 Some(effect)
283 }),
284 });
285 };
286 }
287 open_view_with_args!(
288 "action:magit-global-log",
289 "log",
290 "magit-log-mode",
291 crate::magit_log_mode::LOG_ARGS
292 );
293 open_repo!("action:magit-global-branch", "branch", "magit-branch-mode");
294 open_repo!("action:magit-global-stash", "stash", "magit-stash-mode");
295 open_repo!("action:magit-global-remote", "remote", "magit-remote-mode");
300 open_repo!(
302 "action:magit-global-submodule",
303 "submodule",
304 "magit-submodule-mode"
305 );
306 open_repo!(
308 "action:magit-global-refs",
309 crate::magit_refs_mode::REFS_VIEW,
310 "magit-refs-mode"
311 );
312 open_repo!("action:magit-global-rebase", "rebase", "magit-rebase-mode");
313 open_repo!("action:magit-global-amend", "amend", "magit-commit-mode");
314 open_repo!("action:magit-global-reword", "reword", "magit-commit-mode");
317 open_view_with_args!(
318 "action:magit-global-diff",
319 "diff",
320 "magit-diff-mode",
321 crate::magit_diff_mode::DIFF_ARGS
322 );
323
324 macro_rules! remote_op {
341 ($action_name:expr, $op:expr) => {
342 contributions.push(ActionHandlerContribution {
343 action_name: $action_name,
344 handler: Arc::new(|ctx: &ActionContext<'_>| {
345 Some(spawn_remote_op(
349 crate::repo_scope::action_workdir(ctx),
350 $op,
351 &ctx.args,
352 ))
353 }),
354 });
355 };
356 }
357 macro_rules! remote_op_to {
361 ($action_name:expr, $op:expr, $target:expr) => {
362 contributions.push(ActionHandlerContribution {
363 action_name: $action_name,
364 handler: Arc::new(|ctx: &ActionContext<'_>| {
365 let prompted =
369 ctx.args
370 .as_list()
371 .and_then(|l| l.last())
372 .and_then(|v| match v {
373 lattice_grammar::ArgValue::String(s) => Some(s.clone()),
374 _ => None,
375 });
376 Some(spawn_remote_op_to(
377 crate::repo_scope::action_workdir(ctx),
378 $op,
379 &ctx.args,
380 $target,
381 prompted,
382 ))
383 }),
384 });
385 };
386 }
387
388 remote_op_to!(
390 "action:magit-global-push-configured",
391 RemoteOp::PUSH,
392 RemoteTarget::Configured
393 );
394 remote_op_to!(
395 "action:magit-global-push-upstream",
396 RemoteOp::PUSH,
397 RemoteTarget::Upstream
398 );
399 remote_op_to!(
400 "action:magit-global-push-elsewhere",
401 RemoteOp::PUSH,
402 RemoteTarget::Prompted
403 );
404 remote_op_to!(
405 "action:magit-global-push-other-branch",
406 RemoteOp::PUSH,
407 RemoteTarget::Prompted
408 );
409 remote_op_to!(
410 "action:magit-global-push-refspecs",
411 RemoteOp::PUSH,
412 RemoteTarget::Prompted
413 );
414 remote_op_to!(
415 "action:magit-global-push-tag",
416 RemoteOp::PUSH,
417 RemoteTarget::Prompted
418 );
419 remote_op_to!(
420 "action:magit-global-push-all-tags",
421 RemoteOp::PUSH,
422 RemoteTarget::AllTags
423 );
424
425 remote_op_to!(
427 "action:magit-global-pull-configured",
428 RemoteOp::PULL,
429 RemoteTarget::Configured
430 );
431 remote_op_to!(
432 "action:magit-global-pull-upstream",
433 RemoteOp::PULL,
434 RemoteTarget::Upstream
435 );
436 remote_op_to!(
437 "action:magit-global-pull-elsewhere",
438 RemoteOp::PULL,
439 RemoteTarget::Prompted
440 );
441
442 remote_op_to!(
444 "action:magit-global-fetch-configured",
445 RemoteOp::FETCH,
446 RemoteTarget::Configured
447 );
448 remote_op_to!(
449 "action:magit-global-fetch-upstream",
450 RemoteOp::FETCH,
451 RemoteTarget::Upstream
452 );
453 remote_op_to!(
454 "action:magit-global-fetch-elsewhere",
455 RemoteOp::FETCH,
456 RemoteTarget::Prompted
457 );
458 remote_op_to!(
459 "action:magit-global-fetch-other-branch",
460 RemoteOp::FETCH,
461 RemoteTarget::Prompted
462 );
463 remote_op_to!(
464 "action:magit-global-fetch-refspecs",
465 RemoteOp::FETCH,
466 RemoteTarget::Prompted
467 );
468 remote_op_to!(
469 "action:magit-global-fetch-all-remotes",
470 RemoteOp::FETCH,
471 RemoteTarget::AllRemotes
472 );
473
474 remote_op!("action:magit-global-pull", RemoteOp::PULL);
475 remote_op!("action:magit-global-push", RemoteOp::PUSH);
476 remote_op!("action:magit-global-fetch", RemoteOp::FETCH);
480 remote_op!("action:magit-global-stash-create", RemoteOp::STASH);
484 remote_op!(
486 "action:magit-global-merge-continue",
487 RemoteOp::MERGE_CONTINUE
488 );
489 remote_op!("action:magit-global-merge-abort", RemoteOp::MERGE_ABORT);
490 remote_op!(
492 "action:magit-global-rebase-continue",
493 RemoteOp::REBASE_CONTINUE
494 );
495 remote_op!("action:magit-global-rebase-skip", RemoteOp::REBASE_SKIP);
496 remote_op!("action:magit-global-rebase-abort", RemoteOp::REBASE_ABORT);
497 remote_op!(
499 "action:magit-global-cherry-pick-continue",
500 RemoteOp::CHERRY_PICK_CONTINUE
501 );
502 remote_op!(
503 "action:magit-global-cherry-pick-skip",
504 RemoteOp::CHERRY_PICK_SKIP
505 );
506 remote_op!(
507 "action:magit-global-cherry-pick-abort",
508 RemoteOp::CHERRY_PICK_ABORT
509 );
510 remote_op!(
511 "action:magit-global-revert-continue",
512 RemoteOp::REVERT_CONTINUE
513 );
514 remote_op!("action:magit-global-revert-skip", RemoteOp::REVERT_SKIP);
515 remote_op!("action:magit-global-revert-abort", RemoteOp::REVERT_ABORT);
516 remote_op!(
519 "action:magit-global-stash-keep-index",
520 RemoteOp::STASH_KEEP_INDEX
521 );
522 remote_op!("action:magit-global-stash-staged", RemoteOp::STASH_STAGED);
523 macro_rules! snapshot_op {
525 ($action_name:expr, $label:expr, $extra:expr) => {
526 contributions.push(ActionHandlerContribution {
527 action_name: $action_name,
528 handler: Arc::new(|ctx: &ActionContext<'_>| {
529 Some(spawn_git_sequence(
530 crate::repo_scope::action_workdir(ctx),
531 $label,
532 stash_snapshot_steps($extra),
533 ))
534 }),
535 });
536 };
537 }
538 snapshot_op!(
539 "action:magit-global-stash-snapshot",
540 "snapshot all changes into a stash",
541 &[]
542 );
543 snapshot_op!(
544 "action:magit-global-stash-snapshot-index",
545 "snapshot staged changes into a stash",
546 &["--staged"]
547 );
548 snapshot_op!(
549 "action:magit-global-stash-snapshot-worktree",
550 "snapshot unstaged changes into a stash",
551 &["--keep-index"]
552 );
553 contributions.push(ActionHandlerContribution {
566 action_name: "action:magit-global-bisect-start",
567 handler: Arc::new(|_ctx: &ActionContext<'_>| {
568 Some(prompt_seeded(
569 "Bisect — known BAD revision: ",
570 "action:magit-global-bisect-start-good",
571 "HEAD".to_string(),
572 ))
573 }),
574 });
575 contributions.push(ActionHandlerContribution {
576 action_name: "action:magit-global-bisect-start-good",
577 handler: Arc::new(|ctx: &ActionContext<'_>| {
578 let bad = ctx.prompt_value?.trim().to_string();
579 if bad.is_empty() {
580 return None;
581 }
582 Some(Effect::OpenPrompt {
583 prompt: format!("Bisect {bad} back to — known GOOD revision: "),
584 initial: String::new(),
585 on_submit_action: "action:magit-global-bisect-start-finish".to_string(),
586 buffer_name: Some(bisect_start_buffer_name(&bad)),
587 })
588 }),
589 });
590 contributions.push(ActionHandlerContribution {
591 action_name: "action:magit-global-bisect-start-finish",
592 handler: Arc::new(|ctx: &ActionContext<'_>| {
593 let good = ctx.prompt_value?.trim().to_string();
594 let bad = ctx
595 .services
596 .get::<BufferStoreHandle>()?
597 .name_for(lattice_core::BufferId(ctx.buffer_id.0 as u32))
598 .and_then(|n| bad_from_bisect_start_buffer_name(&n))?;
599 if good.is_empty() {
600 return None;
601 }
602 spawn_bisect(ctx, "start", move |repo| {
603 lattice_vcs::Bisect::start(repo, Some(&bad), Some(&good)).map(Some)
604 });
605 Some(Effect::Echo {
606 level: EchoLevel::Info,
607 text: "bisecting\u{2026}".to_string(),
608 })
609 }),
610 });
611
612 macro_rules! bisect_mark {
613 ($action_name:expr, $verb:literal, $call:expr) => {
614 contributions.push(ActionHandlerContribution {
615 action_name: $action_name,
616 handler: Arc::new(|ctx: &ActionContext<'_>| {
617 spawn_bisect(ctx, $verb, $call);
621 None
622 }),
623 });
624 };
625 }
626 bisect_mark!("action:magit-global-bisect-good", "good", |repo| {
627 lattice_vcs::Bisect::good(repo, None).map(Some)
628 });
629 bisect_mark!("action:magit-global-bisect-bad", "bad", |repo| {
630 lattice_vcs::Bisect::bad(repo, None).map(Some)
631 });
632 bisect_mark!("action:magit-global-bisect-skip", "skip", |repo| {
633 lattice_vcs::Bisect::skip(repo, None).map(Some)
634 });
635 bisect_mark!("action:magit-global-bisect-reset", "reset", |repo| {
636 lattice_vcs::Bisect::reset(repo).map(|()| None)
637 });
638
639 contributions.push(ActionHandlerContribution {
642 action_name: "action:magit-global-tag",
643 handler: Arc::new(|_ctx: &ActionContext<'_>| {
644 Some(prompt_for("Tag name: ", "action:magit-global-tag-finish"))
645 }),
646 });
647 contributions.push(ActionHandlerContribution {
648 action_name: "action:magit-global-tag-finish",
649 handler: Arc::new(|ctx: &ActionContext<'_>| {
650 let name = ctx.prompt_value?.trim();
651 (!name.is_empty()).then(|| {
655 spawn_git(
656 crate::repo_scope::action_workdir(ctx),
657 tag_argv(name),
658 &format!("tag HEAD as {name}"),
659 )
660 })
661 }),
662 });
663 contributions.push(ActionHandlerContribution {
664 action_name: "action:magit-global-gitignore",
665 handler: Arc::new(|_ctx: &ActionContext<'_>| {
666 Some(prompt_for(
667 "Ignore pattern: ",
668 "action:magit-global-gitignore-finish",
669 ))
670 }),
671 });
672 contributions.push(ActionHandlerContribution {
673 action_name: "action:magit-global-gitignore-finish",
674 handler: Arc::new(|ctx: &ActionContext<'_>| {
675 let pattern = ctx.prompt_value?.trim();
676 (!pattern.is_empty()).then(|| {
677 spawn_gitignore(crate::repo_scope::action_workdir(ctx), pattern.to_string())
678 })
679 }),
680 });
681 contributions.push(ActionHandlerContribution {
685 action_name: "action:magit-global-file-untrack",
686 handler: Arc::new(|ctx: &ActionContext<'_>| {
687 let (_workdir, rel) = active_target(ctx)?;
688 Some(spawn_git(
692 crate::repo_scope::action_workdir(ctx),
693 untrack_argv(&rel.to_string_lossy()),
694 &format!("untrack {}", rel.display()),
695 ))
696 }),
697 });
698 contributions.push(ActionHandlerContribution {
699 action_name: "action:magit-global-file-delete",
700 handler: Arc::new(|ctx: &ActionContext<'_>| {
701 let (_workdir, rel) = active_target(ctx)?;
702 Some(crate::confirm::ask_target(
705 format!("Delete {}?", rel.display()),
706 "action:magit-global-file-delete-execute",
707 rel.to_string_lossy().into_owned(),
708 ))
709 }),
710 });
711 contributions.push(ActionHandlerContribution {
712 action_name: "action:magit-global-file-delete-execute",
713 handler: Arc::new(|ctx: &ActionContext<'_>| {
714 let path = match crate::confirm::carried_target(ctx) {
715 Some(carried) => carried,
716 None => active_target(ctx)?.1.to_string_lossy().into_owned(),
717 };
718 Some(spawn_git(
719 crate::repo_scope::action_workdir(ctx),
720 delete_argv(&path),
721 &format!("delete {path}"),
722 ))
723 }),
724 });
725 contributions.push(ActionHandlerContribution {
726 action_name: "action:magit-global-file-rename",
727 handler: Arc::new(|ctx: &ActionContext<'_>| {
728 let (_workdir, rel) = active_target(ctx)?;
729 let from = rel.to_string_lossy().into_owned();
730 Some(Effect::OpenPrompt {
731 prompt: "Rename to: ".to_string(),
732 initial: from.clone(),
735 on_submit_action: "action:magit-global-file-rename-finish".to_string(),
736 buffer_name: Some(format!("*magit:rename:{from}*")),
740 })
741 }),
742 });
743 contributions.push(ActionHandlerContribution {
744 action_name: "action:magit-global-file-rename-finish",
745 handler: Arc::new(|ctx: &ActionContext<'_>| {
746 let to = ctx.prompt_value?.trim().to_string();
747 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
748 let from = ctx
749 .services
750 .get::<BufferStoreHandle>()?
751 .name_for(buffer_id)
752 .and_then(|n| rename_source_from_prompt_buffer_name(&n))?;
753 (!to.is_empty() && to != from).then(|| {
758 spawn_git(
759 crate::repo_scope::action_workdir(ctx),
760 rename_argv(&from, &to),
761 &format!("rename {from} to {to}"),
762 )
763 })
764 }),
765 });
766
767 contributions.push(ActionHandlerContribution {
772 action_name: "action:magit-global-file-checkout",
773 handler: Arc::new(|ctx: &ActionContext<'_>| {
774 let (_workdir, rel) = active_target(ctx)?;
775 let path = rel.to_string_lossy().into_owned();
776 Some(Effect::OpenPicker {
783 source: crate::picker_sources::REVISION_PICK_SOURCE.to_string(),
784 args: vec![format!("magit-file-checkout {{}} {path}")],
785 root: None,
786 fill_action: None,
787 query: None,
788 })
789 }),
790 });
791 contributions.push(ActionHandlerContribution {
792 action_name: "action:magit-global-file-checkout-finish",
793 handler: Arc::new(|ctx: &ActionContext<'_>| {
794 let rev = ctx.prompt_value?.trim().to_string();
795 if rev.is_empty() {
796 return None;
797 }
798 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
799 let path = ctx
800 .services
801 .get::<BufferStoreHandle>()?
802 .name_for(buffer_id)
803 .and_then(|n| checkout_target_from_prompt_buffer_name(&n))?;
804 Some(crate::confirm::ask_with(
808 format!("Checkout {path} from {rev}, discarding its uncommitted changes?"),
809 "action:magit-global-file-checkout-execute",
810 lattice_grammar::Args::List(vec![
811 lattice_grammar::ArgValue::String(rev),
812 lattice_grammar::ArgValue::String(path),
813 ]),
814 ))
815 }),
816 });
817 contributions.push(ActionHandlerContribution {
818 action_name: "action:magit-global-file-checkout-execute",
819 handler: Arc::new(|ctx: &ActionContext<'_>| {
820 let rev = ctx.arg_str(0)?;
825 let path = ctx.arg_str(1)?;
826 Some(spawn_git(
827 crate::repo_scope::action_workdir(ctx),
828 checkout_file_argv(rev, path),
829 &format!("restore {path} from {}", short_rev(rev)),
830 ))
831 }),
832 });
833
834 contributions.push(ActionHandlerContribution {
836 action_name: "action:magit-global-init",
837 handler: Arc::new(|_ctx: &ActionContext<'_>| {
838 let cwd = std::env::current_dir()
843 .map(|p| p.to_string_lossy().into_owned())
844 .unwrap_or_else(|_| ".".to_string());
845 Some(prompt_seeded(
846 "Initialize repository in: ",
847 "action:magit-global-init-finish",
848 cwd,
849 ))
850 }),
851 });
852 contributions.push(ActionHandlerContribution {
853 action_name: "action:magit-global-init-finish",
854 handler: Arc::new(|ctx: &ActionContext<'_>| {
855 let dir = ctx.prompt_value?.trim();
856 (!dir.is_empty()).then(|| {
857 spawn_git(
858 crate::repo_scope::action_workdir(ctx),
859 init_argv(dir),
860 &format!("create a repository in {dir}"),
861 )
862 })
863 }),
864 });
865 contributions.push(ActionHandlerContribution {
866 action_name: "action:magit-global-merge",
867 handler: Arc::new(|_ctx: &ActionContext<'_>| {
868 Some(Effect::OpenPicker {
872 source: crate::picker_sources::BRANCH_PICK_SOURCE.to_string(),
873 args: vec!["magit-merge".to_string()],
874 root: None,
875 fill_action: None,
876 query: None,
877 })
878 }),
879 });
880 macro_rules! prompted_op_open {
883 ($entry:expr, $prompt:expr, $finish:expr, $view:expr, $name:expr, $mode_id:expr) => {
884 contributions.push(ActionHandlerContribution {
885 action_name: $entry,
886 handler: Arc::new(|_ctx: &ActionContext<'_>| Some(prompt_for($prompt, $finish))),
887 });
888 contributions.push(ActionHandlerContribution {
889 action_name: $finish,
890 handler: Arc::new(|ctx: &ActionContext<'_>| {
891 let value = ctx.prompt_value?.trim();
892 if value.is_empty() {
893 return None;
894 }
895 Some(open_repo_view_from_action_with(
899 ctx,
900 $view,
901 $mode_id,
902 Some(&($name)(value)),
903 ))
904 }),
905 });
906 };
907 }
908 macro_rules! picked_from {
918 ($entry:expr, $source:expr, $ex_command:expr) => {
919 contributions.push(ActionHandlerContribution {
920 action_name: $entry,
921 handler: Arc::new(|_ctx: &ActionContext<'_>| {
922 Some(Effect::OpenPicker {
923 source: $source.to_string(),
924 args: vec![$ex_command.to_string()],
925 root: None,
926 fill_action: None,
927 query: None,
928 })
929 }),
930 });
931 };
932 }
933 macro_rules! picked_op {
934 ($entry:expr, $ex_command:expr) => {
935 contributions.push(ActionHandlerContribution {
936 action_name: $entry,
937 handler: Arc::new(|_ctx: &ActionContext<'_>| {
938 Some(Effect::OpenPicker {
939 source: crate::picker_sources::BRANCH_PICK_SOURCE.to_string(),
940 args: vec![$ex_command.to_string()],
941 root: None,
942 fill_action: None,
943 query: None,
944 })
945 }),
946 });
947 };
948 }
949 picked_op!(
950 "action:magit-global-merge-no-commit",
951 "magit-merge-no-commit"
952 );
953 picked_op!("action:magit-global-merge-squash", "magit-merge-squash");
954 macro_rules! spinoff_op {
961 ($entry:expr, $finish:expr, $prompt:expr, $checkout:expr, $label:expr) => {
962 contributions.push(ActionHandlerContribution {
963 action_name: $entry,
964 handler: Arc::new(|_ctx: &ActionContext<'_>| Some(prompt_for($prompt, $finish))),
965 });
966 contributions.push(ActionHandlerContribution {
967 action_name: $finish,
968 handler: Arc::new(|ctx: &ActionContext<'_>| {
969 let branch = ctx.prompt_value?.trim().to_string();
970 if branch.is_empty() {
971 return None;
972 }
973 let label = format!("{} {branch}", $label);
974 Some(spawn_computed(
975 crate::repo_scope::action_workdir(ctx),
976 label.clone(),
977 label,
978 move |wd| crate::cherry_move::branch_spinoff(wd, &branch, $checkout),
979 ))
980 }),
981 });
982 };
983 }
984 spinoff_op!(
985 "action:magit-global-branch-spinoff",
986 "action:magit-global-branch-spinoff-finish",
987 "Spin off branch: ",
988 true,
989 "spin off new branch"
990 );
991 spinoff_op!(
992 "action:magit-global-branch-spinout",
993 "action:magit-global-branch-spinout-finish",
994 "Spin out branch: ",
995 false,
996 "spin out new branch"
997 );
998
999 macro_rules! cherry_move_finish {
1003 ($finish:expr, $label:literal, $body:expr) => {
1004 contributions.push(ActionHandlerContribution {
1005 action_name: $finish,
1006 handler: Arc::new(|ctx: &ActionContext<'_>| {
1007 let branch = ctx.prompt_value?.trim().to_string();
1008 let commit = take_first_input()?;
1009 if branch.is_empty() {
1010 return None;
1011 }
1012 let label = format!($label, commit = short_rev(&commit), branch = branch);
1013 Some(spawn_computed(
1014 crate::repo_scope::action_workdir(ctx),
1015 label.clone(),
1016 label,
1017 move |wd| {
1018 let f: fn(&std::path::Path, &str, &str) -> Result<(), String> = $body;
1019 f(wd, &commit, &branch)
1020 },
1021 ))
1022 }),
1023 });
1024 };
1025 }
1026 cherry_move_finish!(
1027 "action:magit-cherry-harvest-finish",
1028 "harvest {commit} from {branch}",
1029 |wd, commit, branch| {
1030 let current = crate::cherry_move::current_branch_of(wd)
1032 .ok_or_else(|| "not on a branch".to_string())?;
1033 crate::cherry_move::cherry_move(wd, commit, Some(branch), ¤t, None, true)
1034 }
1035 );
1036 cherry_move_finish!(
1037 "action:magit-cherry-donate-finish",
1038 "donate {commit} to {branch}",
1039 |wd, commit, branch| {
1040 let current = crate::cherry_move::current_branch_of(wd)
1043 .ok_or_else(|| "not on a branch".to_string())?;
1044 crate::cherry_move::cherry_move(wd, commit, Some(¤t), branch, None, false)
1045 }
1046 );
1047 cherry_move_finish!(
1048 "action:magit-cherry-spinout-finish",
1049 "spin {commit} out to new branch {branch}",
1050 |wd, commit, branch| {
1051 let current = crate::cherry_move::current_branch_of(wd)
1052 .ok_or_else(|| "not on a branch".to_string())?;
1053 let start = crate::cherry_move::spin_start_point(wd, commit);
1058 crate::cherry_move::cherry_move(wd, commit, Some(¤t), branch, Some(&start), false)
1059 }
1060 );
1061 cherry_move_finish!(
1062 "action:magit-cherry-spinoff-finish",
1063 "spin {commit} off to new branch {branch}",
1064 |wd, commit, branch| {
1065 let current = crate::cherry_move::current_branch_of(wd)
1066 .ok_or_else(|| "not on a branch".to_string())?;
1067 let start = crate::cherry_move::spin_start_point(wd, commit);
1068 crate::cherry_move::cherry_move(wd, commit, Some(¤t), branch, Some(&start), true)
1069 }
1070 );
1071
1072 contributions.push(ActionHandlerContribution {
1074 action_name: "action:magit-global-fetch-submodules",
1075 handler: Arc::new(|ctx: &ActionContext<'_>| {
1076 Some(spawn_git(
1077 crate::repo_scope::action_workdir(ctx),
1078 ["fetch", "--recurse-submodules"]
1079 .iter()
1080 .map(|s| s.to_string())
1081 .collect(),
1082 "fetch, including submodules",
1083 ))
1084 }),
1085 });
1086
1087 prompted_op_open!(
1090 "action:magit-global-merge-preview",
1091 "Preview merge with branch: ",
1092 "action:magit-global-merge-preview-finish",
1093 "diff",
1094 |branch: &str| crate::magit_diff_mode::diff_view_rest(
1095 &crate::magit_diff_mode::DiffScope::MergePreview(branch.to_string()),
1096 None
1097 ),
1098 "magit-diff-mode"
1099 );
1100
1101 picked_op!("action:magit-global-merge-into", "magit-merge-into");
1105 contributions.push(ActionHandlerContribution {
1106 action_name: "action:magit-global-merge-into-finish",
1107 handler: Arc::new(|ctx: &ActionContext<'_>| {
1108 let target = ctx.prompt_value?.trim().to_string();
1109 if target.is_empty() {
1110 return None;
1111 }
1112 let Some(current) = current_branch(&crate::repo_scope::action_workdir(ctx)) else {
1115 return Some(Effect::Echo {
1116 level: lattice_grammar::EchoLevel::Error,
1117 text: "magit: not on a branch".to_string(),
1118 });
1119 };
1120 if current == target {
1121 return Some(Effect::Echo {
1122 level: lattice_grammar::EchoLevel::Error,
1123 text: "magit: cannot merge a branch into itself".to_string(),
1124 });
1125 }
1126 Some(spawn_git_sequence(
1127 crate::repo_scope::action_workdir(ctx),
1128 format!("merge {current} into {target} and delete it"),
1129 merge_into_steps(¤t, &target),
1130 ))
1131 }),
1132 });
1133
1134 picked_from!(
1136 "action:magit-global-tag-prune",
1137 crate::picker_sources::REMOTE_PICK_SOURCE,
1138 "magit-tag-prune"
1139 );
1140
1141 picked_op!(
1143 "action:magit-global-rebase-onto-elsewhere",
1144 "magit-rebase-onto"
1145 );
1146 picked_op!(
1147 "action:magit-global-rebase-autosquash",
1148 "magit-rebase-autosquash"
1149 );
1150 picked_op!("action:magit-global-merge-absorb", "magit-merge-absorb");
1152 macro_rules! two_input_op {
1155 ($entry:expr, $p1:expr, $mid:expr, $p2:expr, $finish:expr, $argv:expr, $label:expr) => {
1156 contributions.push(ActionHandlerContribution {
1157 action_name: $entry,
1158 handler: Arc::new(|_ctx: &ActionContext<'_>| Some(prompt_for($p1, $mid))),
1159 });
1160 contributions.push(ActionHandlerContribution {
1161 action_name: $mid,
1162 handler: Arc::new(|ctx: &ActionContext<'_>| {
1163 let first = ctx.prompt_value?.trim();
1164 if first.is_empty() {
1168 return None;
1169 }
1170 stash_first_input(first.to_string());
1171 Some(prompt_for($p2, $finish))
1172 }),
1173 });
1174 contributions.push(ActionHandlerContribution {
1175 action_name: $finish,
1176 handler: Arc::new(|ctx: &ActionContext<'_>| {
1177 let second = ctx.prompt_value?.trim();
1178 let first = take_first_input()?;
1182 if second.is_empty() {
1183 return None;
1184 }
1185 let label: fn(&str, &str) -> String = $label;
1186 Some(spawn_git(
1187 crate::repo_scope::action_workdir(ctx),
1188 $argv(&first, second),
1189 &label(&first, second),
1190 ))
1191 }),
1192 });
1193 };
1194 }
1195 two_input_op!(
1196 "action:magit-global-reset-file",
1197 "Reset file from commit: ",
1198 "action:magit-global-reset-file-path",
1199 "File path: ",
1200 "action:magit-global-reset-file-finish",
1201 reset_file_argv,
1202 |commit, path| format!("restore {path} from {}", short_rev(commit))
1203 );
1204 two_input_op!(
1207 "action:magit-global-rebase-subset",
1208 "Rebase onto (new base): ",
1209 "action:magit-global-rebase-subset-upstream",
1210 "Commits after (upstream): ",
1211 "action:magit-global-rebase-subset-finish",
1212 rebase_subset_argv,
1213 |base, upstream| format!("rebase the commits after {upstream} onto {base}")
1214 );
1215 two_input_op!(
1217 "action:magit-global-tag-release",
1218 "Release tag name: ",
1219 "action:magit-global-tag-release-message",
1220 "Tag message: ",
1221 "action:magit-global-tag-release-finish",
1222 tag_release_argv,
1223 |name, _message| format!("create release tag {name}")
1224 );
1225 two_input_op!(
1226 "action:magit-global-stash-branch",
1227 "New branch name: ",
1228 "action:magit-global-stash-branch-stash",
1229 "Stash (e.g. stash@{0}): ",
1230 "action:magit-global-stash-branch-finish",
1231 stash_branch_argv,
1232 |branch, stash| format!("create branch {branch} from {stash}")
1233 );
1234
1235 macro_rules! config_op {
1242 ($action_name:expr, $finish:expr, $config_key:expr, $label:expr) => {
1243 contributions.push(ActionHandlerContribution {
1244 action_name: $action_name,
1245 handler: Arc::new(|ctx: &ActionContext<'_>| {
1246 let current = crate::git_config::value_of(
1254 &crate::repo_scope::action_workdir(ctx),
1255 $config_key,
1256 )
1257 .unwrap_or_default();
1258 Some(prompt_seeded(
1259 concat!($label, " (", $config_key, "): "),
1260 $finish,
1261 current,
1262 ))
1263 }),
1264 });
1265 contributions.push(ActionHandlerContribution {
1266 action_name: $finish,
1267 handler: Arc::new(|ctx: &ActionContext<'_>| {
1268 let value = ctx.prompt_value?.trim().to_string();
1272 Some(crate::git_config::set(
1273 crate::repo_scope::action_workdir(ctx),
1274 $config_key,
1275 &value,
1276 ))
1277 }),
1278 });
1279 };
1280 }
1281 config_op!(
1282 "action:magit-config-pull-rebase",
1283 "action:magit-config-pull-rebase-finish",
1284 "pull.rebase",
1285 "Rebase on pull"
1286 );
1287 config_op!(
1288 "action:magit-config-push-default",
1289 "action:magit-config-push-default-finish",
1290 "remote.pushDefault",
1291 "Default push target"
1292 );
1293 config_op!(
1294 "action:magit-config-fetch-prune",
1295 "action:magit-config-fetch-prune-finish",
1296 "fetch.prune",
1297 "Prune on fetch"
1298 );
1299 config_op!(
1300 "action:magit-config-tag-sign",
1301 "action:magit-config-tag-sign-finish",
1302 "tag.gpgSign",
1303 "Sign tags"
1304 );
1305 config_op!(
1306 "action:magit-config-notes-ref",
1307 "action:magit-config-notes-ref-finish",
1308 "core.notesRef",
1309 "Notes ref"
1310 );
1311
1312 macro_rules! rebase_onto {
1316 ($action_name:expr, $target:expr, $what:expr) => {
1317 contributions.push(ActionHandlerContribution {
1318 action_name: $action_name,
1319 handler: Arc::new(|ctx: &ActionContext<'_>| {
1320 Some(spawn_git(
1321 crate::repo_scope::action_workdir(ctx),
1322 rebase_onto_argv($target),
1323 $what,
1324 ))
1325 }),
1326 });
1327 };
1328 }
1329 rebase_onto!(
1330 "action:magit-global-rebase-onto-push",
1331 "@{push}",
1332 "rebase onto the push branch"
1333 );
1334 rebase_onto!(
1335 "action:magit-global-rebase-onto-upstream",
1336 "@{upstream}",
1337 "rebase onto the upstream"
1338 );
1339
1340 contributions.push(ActionHandlerContribution {
1348 action_name: "action:magit-global-commit-extend",
1349 handler: Arc::new(|ctx: &ActionContext<'_>| {
1350 Some(spawn_git(
1351 crate::repo_scope::action_workdir(ctx),
1352 ["commit", "--amend", "--no-edit"]
1353 .iter()
1354 .map(|s| s.to_string())
1355 .collect(),
1356 "extend the last commit",
1357 ))
1358 }),
1359 });
1360
1361 contributions.push(ActionHandlerContribution {
1369 action_name: "action:magit-global-branch-reset",
1370 handler: Arc::new(|_ctx: &ActionContext<'_>| {
1371 Some(Effect::OpenPicker {
1375 source: crate::picker_sources::BRANCH_PICK_SOURCE.to_string(),
1376 args: vec!["magit-branch-reset".to_string()],
1377 root: None,
1378 fill_action: None,
1379 query: None,
1380 })
1381 }),
1382 });
1383 contributions.push(ActionHandlerContribution {
1384 action_name: "action:magit-global-branch-reset-finish",
1385 handler: Arc::new(|ctx: &ActionContext<'_>| {
1386 let target = ctx.prompt_value?.trim();
1387 if target.is_empty() {
1388 return None;
1389 }
1390 Some(crate::confirm::ask_target(
1391 format!("git reset --hard {target} — discard uncommitted changes?"),
1392 "action:magit-global-branch-reset-execute",
1393 target.to_string(),
1394 ))
1395 }),
1396 });
1397 contributions.push(ActionHandlerContribution {
1398 action_name: "action:magit-global-branch-reset-execute",
1399 handler: Arc::new(|ctx: &ActionContext<'_>| {
1400 let target = crate::confirm::carried_target(ctx)?;
1401 Some(spawn_git(
1402 crate::repo_scope::action_workdir(ctx),
1403 vec!["reset".to_string(), "--hard".to_string(), target.clone()],
1404 &format!("hard-reset the branch to {}", short_rev(&target)),
1405 ))
1406 }),
1407 });
1408
1409 picked_op!("action:magit-global-merge-edit", "magit-merge-edit");
1414 contributions.push(ActionHandlerContribution {
1415 action_name: "action:magit-global-merge-edit-finish",
1416 handler: Arc::new(|ctx: &ActionContext<'_>| {
1417 let branch = ctx.prompt_value?.trim();
1418 if branch.is_empty() {
1419 return None;
1420 }
1421 Some(open_repo_view_from_action_with(
1422 ctx,
1423 "merge-edit",
1424 "magit-commit-mode",
1425 Some(branch),
1426 ))
1427 }),
1428 });
1429
1430 picked_from!(
1431 "action:magit-global-tag-delete",
1432 crate::picker_sources::TAG_PICK_SOURCE,
1433 "magit-tag-delete"
1434 );
1435 contributions.push(ActionHandlerContribution {
1436 action_name: "action:magit-global-merge-finish",
1437 handler: Arc::new(|ctx: &ActionContext<'_>| {
1438 let branch = ctx.prompt_value?.trim();
1439 (!branch.is_empty()).then(|| {
1444 spawn_git(
1445 crate::repo_scope::action_workdir(ctx),
1446 merge_argv(branch),
1447 &merge_label("merge", branch),
1448 )
1449 })
1450 }),
1451 });
1452 remote_op!("action:magit-global-stage-all", RemoteOp::STAGE_ALL);
1453 remote_op!("action:magit-global-unstage-all", RemoteOp::UNSTAGE_ALL);
1454
1455 macro_rules! file_open {
1466 ($action_name:expr, $view:expr, $mode_id:expr) => {
1467 contributions.push(ActionHandlerContribution {
1468 action_name: $action_name,
1469 handler: Arc::new(|ctx: &ActionContext<'_>| {
1470 let (_workdir, rel) = active_target(ctx)?;
1471 Some(open_repo_view_from_action_with(
1472 ctx,
1473 $view,
1474 $mode_id,
1475 Some(&rel.display().to_string()),
1476 ))
1477 }),
1478 });
1479 };
1480 }
1481
1482 macro_rules! file_mutate {
1491 ($action_name:expr, $verb:expr, $doing:expr, $body:expr) => {
1492 contributions.push(ActionHandlerContribution {
1493 action_name: $action_name,
1494 handler: Arc::new(|ctx: &ActionContext<'_>| {
1495 let (workdir, rel) = active_target(ctx)?;
1496 let shown = rel.display().to_string();
1497 let label = format!("{} {shown}", $verb);
1498 tokio::task::spawn(async move {
1499 let scope_dir = workdir.clone();
1500 let result = tokio::task::spawn_blocking(move || {
1501 let repo = Repository::discover(&workdir)
1502 .map_err(|e| format!("not a git repository: {e}"))?;
1503 #[allow(clippy::redundant_closure_call)]
1504 ($body)(&repo, &rel)
1505 })
1506 .await
1507 .unwrap_or_else(|e| Err(e.to_string()));
1508 finish_task(&scope_dir, &label, result.map(|()| String::new()));
1509 });
1510 Some(Effect::Echo {
1511 level: lattice_grammar::EchoLevel::Info,
1512 text: format!(concat!("magit: ", $doing, " {}\u{2026}"), shown),
1513 })
1514 }),
1515 });
1516 };
1517 }
1518
1519 file_mutate!(
1520 "action:magit-global-file-stage",
1521 "stage",
1522 "staging",
1523 |repo: &Repository, rel: &std::path::Path| -> Result<(), String> {
1524 lattice_vcs::Index::stage_path(repo, rel).map_err(|e| e.to_string())
1525 }
1526 );
1527 file_mutate!(
1528 "action:magit-global-file-unstage",
1529 "unstage",
1530 "unstaging",
1531 |repo: &Repository, rel: &std::path::Path| -> Result<(), String> {
1532 lattice_vcs::Index::unstage_path(repo, rel).map_err(|e| e.to_string())
1533 }
1534 );
1535 contributions.push(ActionHandlerContribution {
1538 action_name: "action:magit-global-file-discard",
1539 handler: Arc::new(|ctx: &ActionContext<'_>| {
1540 let (_workdir, rel) = active_target(ctx)?;
1541 Some(crate::confirm::ask_with(
1547 format!("Discard changes to {}?", rel.display()),
1548 "action:magit-global-file-discard-execute",
1549 lattice_grammar::Args::List(vec![lattice_grammar::ArgValue::String(
1550 rel.to_string_lossy().into_owned(),
1551 )]),
1552 ))
1553 }),
1554 });
1555 file_mutate!(
1556 "action:magit-global-file-discard-execute",
1557 "discard changes to",
1558 "discarding changes to",
1559 |repo: &Repository, rel: &std::path::Path| -> Result<(), String> {
1560 repo.run_git(["checkout", "--", &rel.to_string_lossy()])
1561 .map(|_| ())
1562 .map_err(|e| e.to_string())
1563 }
1564 );
1565
1566 file_open!("action:magit-global-file-diff", "diff", "magit-diff-mode");
1567 file_open!("action:magit-global-file-log", "log", "magit-log-mode");
1568 contributions.push(ActionHandlerContribution {
1572 action_name: "action:magit-global-file-blame",
1573 handler: Arc::new(|_ctx: &ActionContext<'_>| {
1574 Some(Effect::ToggleMode {
1575 mode_name: crate::MagitBlameMode::mode_id().as_str().to_string(),
1576 })
1577 }),
1578 });
1579
1580 contributions.push(ActionHandlerContribution {
1587 action_name: "action:magit-global-branch-checkout",
1588 handler: Arc::new(|_ctx: &ActionContext<'_>| {
1589 Some(Effect::OpenPicker {
1590 source: crate::picker_sources::BRANCH_CHECKOUT_SOURCE.to_string(),
1591 args: Vec::new(),
1592 root: None,
1593 fill_action: None,
1594 query: None,
1595 })
1596 }),
1597 });
1598 contributions.push(ActionHandlerContribution {
1599 action_name: "action:magit-global-branch-create",
1600 handler: Arc::new(|_ctx: &ActionContext<'_>| {
1601 Some(Effect::OpenPicker {
1606 source: "magit-branch-pick-base".to_string(),
1607 args: Vec::new(),
1608 root: None,
1609 fill_action: None,
1610 query: None,
1611 })
1612 }),
1613 });
1614
1615 contributions.push(ActionHandlerContribution {
1628 action_name: "action:magit-global-file-at-revision",
1629 handler: Arc::new(|ctx: &ActionContext<'_>| {
1630 let (_workdir, rel) = active_target(ctx)?;
1631 let path = rel.to_string_lossy().into_owned();
1632 Some(Effect::OpenPicker {
1642 source: crate::picker_sources::REVISION_PICK_SOURCE.to_string(),
1643 args: vec![format!("magit-find-file {{}} {path}")],
1644 root: None,
1645 fill_action: None,
1646 query: None,
1647 })
1648 }),
1649 });
1650 contributions.push(ActionHandlerContribution {
1651 action_name: "action:magit-global-file-at-revision-finish",
1652 handler: Arc::new(|ctx: &ActionContext<'_>| {
1653 let rev = ctx.prompt_value?.trim().to_string();
1654 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
1655 let store = ctx.services.get::<BufferStoreHandle>()?;
1656 let path = store
1657 .name_for(buffer_id)
1658 .and_then(|n| path_from_prompt_buffer_name(&n, "*magit:show-at:"))?;
1659 if rev.is_empty() {
1660 return None;
1661 }
1662 Some(Effect::OpenSyntheticBuffer {
1663 name: crate::magit_file_revision_mode::blob_buffer_name(
1664 &crate::repo_scope::label_of_buffer(&store, buffer_id),
1665 &rev,
1666 std::path::Path::new(&path),
1667 ),
1668 mode_id: crate::magit_file_revision_mode::MagitFileRevisionMode::mode_id()
1669 .to_string(),
1670 content: None,
1671 cursor: None,
1672 activate_minor: None,
1673 })
1674 }),
1675 });
1676
1677 contributions.push(ActionHandlerContribution {
1690 action_name: "action:magit-global-file-visit-live",
1691 handler: Arc::new(|ctx: &ActionContext<'_>| {
1692 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
1693 let parsed = ctx
1694 .services
1695 .get::<BufferStoreHandle>()?
1696 .name_for(buffer_id)
1697 .and_then(|n| crate::magit_file_revision_mode::parse_buffer_name(&n));
1698 Some(match parsed {
1699 Some((_git_ref, path)) => {
1700 let workdir = crate::repo_scope::action_workdir(ctx);
1701 let full = workdir.join(&path);
1702 if full.exists() {
1703 Effect::OpenBufferAt {
1704 path: Some(full),
1705 position: ctx.cursor,
1706 force: false,
1707 content: None,
1708 activate_minor: None,
1709 }
1710 } else {
1711 Effect::Echo {
1715 level: lattice_grammar::EchoLevel::Warn,
1716 text: format!(
1717 "magit: {} no longer exists in the working tree",
1718 path.display()
1719 ),
1720 }
1721 }
1722 }
1723 None => Effect::None,
1743 })
1744 }),
1745 });
1746
1747 contributions.push(ActionHandlerContribution {
1765 action_name: "action:magit-global-file-blame-reverse",
1766 handler: Arc::new(|ctx: &ActionContext<'_>| {
1767 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
1768 let store = ctx.services.get::<BufferStoreHandle>()?;
1769 let label = crate::repo_scope::label_of_buffer(&store, buffer_id);
1772 let parsed = store
1773 .name_for(buffer_id)
1774 .and_then(|n| crate::magit_file_revision_mode::parse_buffer_name(&n))
1775 .filter(|(git_ref, _)| git_ref != "staged");
1776 Some(match parsed {
1777 Some((git_ref, path)) => {
1786 let name =
1787 crate::magit_file_revision_mode::blob_buffer_name(&label, &git_ref, &path);
1788 if let Some(requests) = ctx
1789 .services
1790 .get::<crate::magit_blame_mode::BlameRequestsHandle>()
1791 {
1792 requests.put(
1793 name,
1794 crate::magit_blame_mode::BlameDirection::Reverse,
1795 git_ref.clone(),
1796 );
1797 }
1798 Effect::ToggleMode {
1799 mode_name: crate::MagitBlameMode::mode_id().as_str().to_string(),
1800 }
1801 }
1802 None => Effect::Echo {
1806 level: lattice_grammar::EchoLevel::Error,
1807 text: "magit: reverse blame needs a revision — open the file at one first \
1808 (<CR> on a log entry, then gj/gk to walk)"
1809 .to_string(),
1810 },
1811 })
1812 }),
1813 });
1814
1815 macro_rules! prompted {
1821 ($open:expr, $prompt:expr, $initial:expr, $finish:expr, $buffer:expr) => {
1822 contributions.push(ActionHandlerContribution {
1823 action_name: $open,
1824 handler: Arc::new(|_ctx: &ActionContext<'_>| {
1825 Some(Effect::OpenPrompt {
1826 prompt: $prompt.to_string(),
1827 initial: $initial.to_string(),
1828 on_submit_action: $finish.to_string(),
1829 buffer_name: Some($buffer.to_string()),
1830 })
1831 }),
1832 });
1833 };
1834 }
1835
1836 for op in [
1837 SubtreeOp::ADD,
1838 SubtreeOp::MERGE,
1839 SubtreeOp::PULL,
1840 SubtreeOp::PUSH,
1841 SubtreeOp::SPLIT,
1842 ] {
1843 contributions.push(ActionHandlerContribution {
1844 action_name: match op.sub {
1845 "add" => "action:magit-global-subtree-add",
1846 "merge" => "action:magit-global-subtree-merge",
1847 "pull" => "action:magit-global-subtree-pull",
1848 "push" => "action:magit-global-subtree-push",
1849 _ => "action:magit-global-subtree-split",
1850 },
1851 handler: Arc::new(move |_ctx: &ActionContext<'_>| {
1852 Some(Effect::OpenPrompt {
1853 prompt: format!("{} {}: ", op.what, op.usage()),
1854 initial: String::new(),
1855 on_submit_action: "action:magit-global-subtree-finish".to_string(),
1856 buffer_name: Some(format!("*magit:subtree:{}*", op.sub)),
1860 })
1861 }),
1862 });
1863 }
1864 contributions.push(ActionHandlerContribution {
1865 action_name: "action:magit-global-subtree-finish",
1866 handler: Arc::new(|ctx: &ActionContext<'_>| {
1867 let line = ctx.prompt_value?.trim().to_string();
1868 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
1869 let sub = ctx
1870 .services
1871 .get::<BufferStoreHandle>()?
1872 .name_for(buffer_id)
1873 .and_then(|n| path_from_prompt_buffer_name(&n, "*magit:subtree:"))?;
1874 let op = match sub.as_str() {
1875 "add" => SubtreeOp::ADD,
1876 "merge" => SubtreeOp::MERGE,
1877 "pull" => SubtreeOp::PULL,
1878 "push" => SubtreeOp::PUSH,
1879 "split" => SubtreeOp::SPLIT,
1880 _ => return None,
1881 };
1882 if line.is_empty() {
1883 return None;
1884 }
1885 Some(spawn_subtree_op(
1886 crate::repo_scope::action_workdir(ctx),
1887 op,
1888 &line,
1889 ))
1890 }),
1891 });
1892
1893 prompted!(
1894 "action:magit-global-am-apply",
1895 "Apply patches (paths, add -3 for three-way): ",
1896 "",
1897 "action:magit-global-am-apply-finish",
1898 "*magit:am*"
1899 );
1900 contributions.push(ActionHandlerContribution {
1901 action_name: "action:magit-global-am-apply-finish",
1902 handler: Arc::new(|ctx: &ActionContext<'_>| {
1903 let line = ctx.prompt_value?.trim().to_string();
1904 if line.is_empty() {
1905 return None;
1906 }
1907 let Some(argv) = am_argv(&line, am_wants_three_way(&line)) else {
1908 return Some(Effect::Echo {
1909 level: lattice_grammar::EchoLevel::Error,
1910 text: "magit: usage — :magit-am <patch>… [-3]".to_string(),
1911 });
1912 };
1913 Some(spawn_git(
1914 crate::repo_scope::action_workdir(ctx),
1915 argv.clone(),
1916 &am_label(&argv),
1917 ))
1918 }),
1919 });
1920
1921 prompted!(
1922 "action:magit-global-format-patch",
1923 "Create patches for range: ",
1924 "@{upstream}..HEAD",
1925 "action:magit-global-format-patch-finish",
1926 "*magit:format-patch*"
1927 );
1928 contributions.push(ActionHandlerContribution {
1929 action_name: "action:magit-global-format-patch-finish",
1930 handler: Arc::new(|ctx: &ActionContext<'_>| {
1931 let range = ctx.prompt_value?.trim().to_string();
1932 let root = Some(crate::repo_scope::action_workdir(ctx))
1937 .filter(|p| !p.as_os_str().is_empty())
1938 .map(|p| p.to_string_lossy().into_owned())
1939 .unwrap_or_default();
1940 let Some(argv) = format_patch_argv(&range, (!root.is_empty()).then_some(root.as_str()))
1941 else {
1942 return Some(Effect::Echo {
1943 level: lattice_grammar::EchoLevel::Error,
1944 text: "magit: usage — :magit-format-patch <range>".to_string(),
1945 });
1946 };
1947 Some(spawn_git(
1948 crate::repo_scope::action_workdir(ctx),
1949 argv.clone(),
1950 &format_patch_label(&argv),
1951 ))
1952 }),
1953 });
1954
1955 macro_rules! am_op {
1956 ($action_name:expr, $op:expr) => {
1957 contributions.push(ActionHandlerContribution {
1958 action_name: $action_name,
1959 handler: Arc::new(|ctx: &ActionContext<'_>| {
1960 Some(spawn_remote_op(
1961 crate::repo_scope::action_workdir(ctx),
1962 $op,
1963 &lattice_grammar::Args::None,
1964 ))
1965 }),
1966 });
1967 };
1968 }
1969 am_op!("action:magit-global-am-continue", RemoteOp::AM_CONTINUE);
1970 am_op!("action:magit-global-am-skip", RemoteOp::AM_SKIP);
1971 am_op!("action:magit-global-am-abort", RemoteOp::AM_ABORT);
1972
1973 prompted!(
1977 "action:magit-global-cherries",
1978 "Cherries against upstream: ",
1979 "@{upstream}",
1980 "action:magit-global-cherries-finish",
1981 "*magit:cherries*"
1982 );
1983 contributions.push(ActionHandlerContribution {
1984 action_name: "action:magit-global-cherries-finish",
1985 handler: Arc::new(|ctx: &ActionContext<'_>| {
1986 let upstream = ctx.prompt_value?.trim().to_string();
1987 if upstream.is_empty() {
1988 return None;
1989 }
1990 Some(open_repo_view_from_action_with(
1991 ctx,
1992 crate::magit_cherry_mode::CHERRY_VIEW,
1993 crate::MagitCherryMode::mode_id().as_str(),
1994 Some(&crate::magit_cherry_mode::cherry_view_rest(
1995 &upstream, "HEAD",
1996 )),
1997 ))
1998 }),
1999 });
2000
2001 contributions.push(ActionHandlerContribution {
2008 action_name: "action:magit-global-note-edit",
2009 handler: Arc::new(|ctx: &ActionContext<'_>| {
2010 let at_cursor =
2011 crate::buffer_state::view_for(ctx).and_then(|v| v.commit_at_cursor(ctx.cursor));
2012 Some(match at_cursor {
2013 Some(commit) => open_repo_view_from_action_with(
2014 ctx,
2015 crate::magit_notes_mode::NOTE_VIEW,
2016 crate::MagitNotesMode::mode_id().as_str(),
2017 Some(&commit),
2018 ),
2019 None => Effect::OpenPicker {
2020 source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
2021 args: vec!["magit-note-edit".to_string()],
2022 root: None,
2023 fill_action: None,
2024 query: None,
2025 },
2026 })
2027 }),
2028 });
2029 contributions.push(ActionHandlerContribution {
2030 action_name: "action:magit-global-note-remove",
2031 handler: Arc::new(|ctx: &ActionContext<'_>| {
2032 let at_cursor =
2033 crate::buffer_state::view_for(ctx).and_then(|v| v.commit_at_cursor(ctx.cursor));
2034 Some(match at_cursor {
2035 Some(commit) => spawn_note_remove(crate::repo_scope::action_workdir(ctx), commit),
2040 None => Effect::OpenPicker {
2041 source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
2042 args: vec!["magit-note-remove".to_string()],
2043 root: None,
2044 fill_action: None,
2045 query: None,
2046 },
2047 })
2048 }),
2049 });
2050
2051 contributions.push(ActionHandlerContribution {
2056 action_name: "action:magit-global-note-prune",
2057 handler: Arc::new(|_ctx: &ActionContext<'_>| {
2058 Some(crate::confirm::ask(
2059 "Drop every note whose commit no longer exists?".to_string(),
2060 "action:magit-global-note-prune-execute",
2061 ))
2062 }),
2063 });
2064 contributions.push(ActionHandlerContribution {
2065 action_name: "action:magit-global-note-prune-execute",
2066 handler: Arc::new(|ctx: &ActionContext<'_>| {
2067 Some(spawn_note_prune(crate::repo_scope::action_workdir(ctx)))
2068 }),
2069 });
2070
2071 macro_rules! notes_merge_op {
2072 ($action_name:expr, $op:expr) => {
2073 contributions.push(ActionHandlerContribution {
2074 action_name: $action_name,
2075 handler: Arc::new(|ctx: &ActionContext<'_>| {
2076 Some(spawn_remote_op(
2077 crate::repo_scope::action_workdir(ctx),
2078 $op,
2079 &lattice_grammar::Args::None,
2080 ))
2081 }),
2082 });
2083 };
2084 }
2085 notes_merge_op!(
2086 "action:magit-global-note-merge-commit",
2087 RemoteOp::NOTES_MERGE_COMMIT
2088 );
2089 notes_merge_op!(
2090 "action:magit-global-note-merge-abort",
2091 RemoteOp::NOTES_MERGE_ABORT
2092 );
2093
2094 picked_from!(
2095 "action:magit-global-note-merge",
2096 crate::picker_sources::REF_PICK_SOURCE,
2097 "magit-note-merge"
2098 );
2099 contributions.push(ActionHandlerContribution {
2100 action_name: "action:magit-global-note-merge-finish",
2101 handler: Arc::new(|ctx: &ActionContext<'_>| {
2102 let spec = ctx.prompt_value?.trim().to_string();
2103 if spec.is_empty() {
2104 return None;
2105 }
2106 Some(spawn_note_merge(
2107 crate::repo_scope::action_workdir(ctx),
2108 &spec,
2109 ))
2110 }),
2111 });
2112
2113 contributions.push(ActionHandlerContribution {
2122 action_name: "action:magit-global-clone",
2123 handler: Arc::new(|_ctx: &ActionContext<'_>| {
2124 Some(Effect::OpenPrompt {
2125 prompt: "Clone repository: ".to_string(),
2126 initial: String::new(),
2127 on_submit_action: "action:magit-global-clone-dest".to_string(),
2128 buffer_name: Some("*magit:clone*".to_string()),
2129 })
2130 }),
2131 });
2132 contributions.push(ActionHandlerContribution {
2133 action_name: "action:magit-global-clone-dest",
2134 handler: Arc::new(|ctx: &ActionContext<'_>| {
2135 let url = ctx.prompt_value?.trim().to_string();
2136 if url.is_empty() {
2137 return None;
2138 }
2139 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2142 let initial = match default_clone_dest(&url) {
2143 name if name.is_empty() => String::new(),
2144 name => cwd.join(name).to_string_lossy().into_owned(),
2145 };
2146 Some(Effect::OpenPrompt {
2147 prompt: "Clone into: ".to_string(),
2148 initial,
2149 on_submit_action: "action:magit-global-clone-finish".to_string(),
2150 buffer_name: Some(format!("*magit:clone-from:{url}*")),
2154 })
2155 }),
2156 });
2157 contributions.push(ActionHandlerContribution {
2158 action_name: "action:magit-global-clone-finish",
2159 handler: Arc::new(|ctx: &ActionContext<'_>| {
2160 let dest = ctx.prompt_value?.trim().to_string();
2161 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2162 let url = ctx
2163 .services
2164 .get::<BufferStoreHandle>()?
2165 .name_for(buffer_id)
2166 .and_then(|n| path_from_prompt_buffer_name(&n, "*magit:clone-from:"))?;
2167 if dest.is_empty() {
2168 return None;
2169 }
2170 Some(spawn_clone(url, dest))
2171 }),
2172 });
2173
2174 contributions.push(ActionHandlerContribution {
2189 action_name: "action:magit-global-log-merged",
2190 handler: Arc::new(|ctx: &ActionContext<'_>| {
2191 let at_cursor =
2192 crate::buffer_state::view_for(ctx).and_then(|v| v.commit_at_cursor(ctx.cursor));
2193 Some(match at_cursor {
2194 Some(commit) => open_repo_view_from_action_with(
2195 ctx,
2196 crate::magit_revision_mode::MERGED_VIEW,
2197 "magit-revision-mode",
2198 Some(&commit),
2199 ),
2200 None => Effect::OpenPicker {
2201 source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
2202 args: vec!["magit-log-merged".to_string()],
2203 root: None,
2204 fill_action: None,
2205 query: None,
2206 },
2207 })
2208 }),
2209 });
2210
2211 contributions.push(ActionHandlerContribution {
2220 action_name: "action:magit-global-edit-line-commit",
2221 handler: Arc::new(|ctx: &ActionContext<'_>| {
2222 let (_workdir, rel) = active_target(ctx)?;
2223 Some(open_repo_view_from_action_with(
2224 ctx,
2225 "rebase-edit",
2226 crate::magit_rebase_mode::MagitRebaseMode::mode_id().as_str(),
2227 Some(&crate::magit_rebase_mode::edit_line_rest(
2229 ctx.cursor.line + 1,
2230 &rel.to_string_lossy(),
2231 )),
2232 ))
2233 }),
2234 });
2235
2236 contributions.push(ActionHandlerContribution {
2243 action_name: "action:magit-branch-create-finish",
2244 handler: Arc::new(|ctx: &ActionContext<'_>| {
2245 let name = ctx.prompt_value?.trim().to_string();
2246 if name.is_empty() {
2247 return Some(Effect::Echo {
2248 level: lattice_grammar::EchoLevel::Error,
2249 text: "magit: branch name is empty".to_string(),
2250 });
2251 }
2252 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2253 let base = ctx
2254 .services
2255 .get::<BufferStoreHandle>()?
2256 .name_for(buffer_id)
2257 .and_then(|n| base_branch_from_prompt_buffer_name(&n))?;
2258 let workdir = crate::repo_scope::action_workdir(ctx);
2260 let label = format!("create and check out branch {name} from {base}");
2261 let echo = format!("magit: creating branch {name}\u{2026}");
2262 spawn_repo_op(workdir, label, move |repo| {
2263 lattice_vcs::Branch::create(repo, &name, true, Some(&base))
2264 });
2265 Some(Effect::Echo {
2266 level: lattice_grammar::EchoLevel::Info,
2267 text: echo,
2268 })
2269 }),
2270 });
2271
2272 contributions.push(ActionHandlerContribution {
2305 action_name: "action:magit-global-branch-checkout-rev",
2306 handler: Arc::new(|_ctx: &ActionContext<'_>| {
2307 Some(Effect::OpenPicker {
2308 source: crate::picker_sources::REVISION_PICK_SOURCE.to_string(),
2309 args: vec!["magit-checkout".to_string()],
2310 root: None,
2311 fill_action: None,
2312 query: None,
2313 })
2314 }),
2315 });
2316 contributions.push(ActionHandlerContribution {
2317 action_name: "action:magit-global-branch-checkout-rev-finish",
2318 handler: Arc::new(|ctx: &ActionContext<'_>| {
2319 let rev = ctx.prompt_value?.trim().to_string();
2320 if rev.is_empty() {
2321 return Some(Effect::Echo {
2322 level: lattice_grammar::EchoLevel::Error,
2323 text: "magit: revision is empty".to_string(),
2324 });
2325 }
2326 let workdir = crate::repo_scope::action_workdir(ctx);
2328 let label = format!("check out {}", short_rev(&rev));
2329 let echo = format!("magit: checking out {rev}\u{2026}");
2330 spawn_repo_op(workdir, label, move |repo| {
2331 lattice_vcs::Branch::checkout(repo, &rev)
2332 });
2333 Some(Effect::Echo {
2334 level: lattice_grammar::EchoLevel::Info,
2335 text: echo,
2336 })
2337 }),
2338 });
2339
2340 contributions.push(ActionHandlerContribution {
2344 action_name: "action:magit-global-branch-create-no-checkout",
2345 handler: Arc::new(|_ctx: &ActionContext<'_>| {
2346 Some(Effect::OpenPicker {
2347 source: crate::picker_sources::BRANCH_CREATE_NO_CHECKOUT_SOURCE.to_string(),
2348 args: Vec::new(),
2349 root: None,
2350 fill_action: None,
2351 query: None,
2352 })
2353 }),
2354 });
2355 contributions.push(ActionHandlerContribution {
2356 action_name: "action:magit-branch-create-no-checkout-finish",
2357 handler: Arc::new(|ctx: &ActionContext<'_>| {
2358 let name = ctx.prompt_value?.trim().to_string();
2359 if name.is_empty() {
2360 return Some(Effect::Echo {
2361 level: lattice_grammar::EchoLevel::Error,
2362 text: "magit: branch name is empty".to_string(),
2363 });
2364 }
2365 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2366 let base = ctx
2367 .services
2368 .get::<BufferStoreHandle>()?
2369 .name_for(buffer_id)
2370 .and_then(|n| {
2371 branch_from_prompt_buffer_name(&n, BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX)
2372 })?;
2373 let workdir = crate::repo_scope::action_workdir(ctx);
2375 let label = format!("create branch {name} from {base}");
2376 let echo = format!("magit: creating branch {name}\u{2026}");
2377 spawn_repo_op(workdir, label, move |repo| {
2378 lattice_vcs::Branch::create(repo, &name, false, Some(&base))
2379 });
2380 Some(Effect::Echo {
2381 level: lattice_grammar::EchoLevel::Info,
2382 text: echo,
2383 })
2384 }),
2385 });
2386
2387 contributions.push(ActionHandlerContribution {
2392 action_name: "action:magit-global-branch-rename",
2393 handler: Arc::new(|_ctx: &ActionContext<'_>| {
2394 Some(Effect::OpenPicker {
2395 source: crate::picker_sources::BRANCH_RENAME_SOURCE.to_string(),
2396 args: Vec::new(),
2397 root: None,
2398 fill_action: None,
2399 query: None,
2400 })
2401 }),
2402 });
2403 contributions.push(ActionHandlerContribution {
2404 action_name: "action:magit-branch-rename-finish",
2405 handler: Arc::new(|ctx: &ActionContext<'_>| {
2406 let new_name = ctx.prompt_value?.trim().to_string();
2407 if new_name.is_empty() {
2408 return Some(Effect::Echo {
2409 level: lattice_grammar::EchoLevel::Error,
2410 text: "magit: branch name is empty".to_string(),
2411 });
2412 }
2413 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2414 let old = ctx
2415 .services
2416 .get::<BufferStoreHandle>()?
2417 .name_for(buffer_id)
2418 .and_then(|n| branch_from_prompt_buffer_name(&n, BRANCH_RENAME_PROMPT_PREFIX))?;
2419 if old == new_name {
2420 return Some(Effect::Echo {
2424 level: lattice_grammar::EchoLevel::Info,
2425 text: format!("magit: {old} unchanged"),
2426 });
2427 }
2428 let workdir = crate::repo_scope::action_workdir(ctx);
2430 let label = format!("rename branch {old} to {new_name}");
2431 let echo = format!("magit: renaming branch {old}\u{2026}");
2432 spawn_repo_op(workdir, label, move |repo| {
2433 lattice_vcs::Branch::rename(repo, &old, &new_name)
2434 });
2435 Some(Effect::Echo {
2436 level: lattice_grammar::EchoLevel::Info,
2437 text: echo,
2438 })
2439 }),
2440 });
2441
2442 contributions.push(ActionHandlerContribution {
2447 action_name: "action:magit-global-branch-delete",
2448 handler: Arc::new(|_ctx: &ActionContext<'_>| {
2449 Some(Effect::OpenPicker {
2450 source: crate::picker_sources::BRANCH_DELETE_SOURCE.to_string(),
2451 args: Vec::new(),
2452 root: None,
2453 fill_action: None,
2454 query: None,
2455 })
2456 }),
2457 });
2458 contributions.push(ActionHandlerContribution {
2459 action_name: "action:magit-global-branch-delete-execute",
2460 handler: Arc::new(|ctx: &ActionContext<'_>| {
2461 let name = crate::confirm::carried_target(ctx)?;
2465 let workdir = crate::repo_scope::action_workdir(ctx);
2467 let label = format!("delete branch {name}");
2468 let echo = format!("magit: deleting branch {name}\u{2026}");
2469 spawn_repo_op(workdir, label, move |repo| {
2470 lattice_vcs::Branch::delete(repo, &name)
2471 });
2472 Some(Effect::Echo {
2473 level: lattice_grammar::EchoLevel::Info,
2474 text: echo,
2475 })
2476 }),
2477 });
2478
2479 contributions
2480}
2481
2482fn spawn_repo_op(
2490 workdir: std::path::PathBuf,
2491 label: String,
2492 op: impl FnOnce(&Repository) -> lattice_vcs::Result<()> + Send + 'static,
2493) {
2494 tokio::task::spawn(async move {
2495 let scope_dir = workdir.clone();
2496 let result = tokio::task::spawn_blocking(move || {
2497 let repo =
2498 Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
2499 op(&repo).map(|()| String::new()).map_err(|e| e.to_string())
2500 })
2501 .await
2502 .unwrap_or_else(|e| Err(e.to_string()));
2503 finish_task(&scope_dir, &label, result);
2504 });
2505}
2506
2507fn active_target(ctx: &ActionContext<'_>) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
2530 match ctx.arg_str(FILE_TARGET_SLOT) {
2531 Some(rel) => {
2532 let workdir = crate::repo_scope::action_workdir(ctx);
2537 (!workdir.as_os_str().is_empty()).then(|| (workdir, std::path::PathBuf::from(rel)))
2538 }
2539 None => active_file(ctx),
2540 }
2541}
2542
2543pub(crate) const FILE_TARGET_SLOT: usize = 0;
2547
2548fn active_file(ctx: &ActionContext<'_>) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
2549 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2550 let path = ctx
2551 .services
2552 .get::<BufferStoreHandle>()?
2553 .path_for(buffer_id)?;
2554 crate::workdir::workdir_for_file(&path)
2557}
2558
2559fn base_branch_from_prompt_buffer_name(buffer_name: &str) -> Option<String> {
2565 branch_from_prompt_buffer_name(buffer_name, BRANCH_CREATE_PROMPT_PREFIX)
2566}
2567
2568pub(crate) const BRANCH_CREATE_PROMPT_PREFIX: &str = "*magit:branch-create-from:";
2575pub(crate) const BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX: &str =
2576 "*magit:branch-create-nocheckout-from:";
2577pub(crate) const BRANCH_RENAME_PROMPT_PREFIX: &str = "*magit:branch-rename:";
2578
2579fn branch_from_prompt_buffer_name(buffer_name: &str, prefix: &str) -> Option<String> {
2585 let s = buffer_name.strip_prefix(prefix)?;
2586 let s = s.strip_suffix("*")?;
2587 (!s.is_empty()).then(|| s.to_string())
2588}
2589
2590#[cfg(test)]
2597pub(crate) fn branch_from_prompt_buffer_name_for_test(
2598 buffer_name: &str,
2599 prefix: &str,
2600) -> Option<String> {
2601 branch_from_prompt_buffer_name(buffer_name, prefix)
2602}
2603
2604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2606pub enum RemoteArgKind {
2607 Flag,
2609 Value {
2613 prompt: &'static str,
2615 },
2616 ValueJoined {
2628 prompt: &'static str,
2630 },
2631}
2632
2633#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2640pub struct RemoteFlag {
2641 pub name: &'static str,
2643 pub arg: &'static str,
2646 pub key: &'static str,
2648 pub doc: &'static str,
2650 pub kind: RemoteArgKind,
2652}
2653
2654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2674pub enum RemoteTarget {
2675 Configured,
2679 Upstream,
2686 AllRemotes,
2688 AllTags,
2690 Prompted,
2694}
2695
2696impl RemoteTarget {
2697 pub fn argv(self, resolved: Option<&str>) -> Vec<String> {
2704 match self {
2705 Self::Configured => Vec::new(),
2706 Self::AllRemotes => vec!["--all".to_string()],
2707 Self::AllTags => vec!["--tags".to_string()],
2708 Self::Upstream | Self::Prompted => resolved
2712 .map(str::trim)
2713 .filter(|v| !v.is_empty())
2714 .map(|v| v.split_whitespace().map(str::to_string).collect())
2715 .unwrap_or_default(),
2716 }
2717 }
2718}
2719
2720pub fn resolve_upstream(workdir: &std::path::Path) -> Option<String> {
2728 let output = std::process::Command::new("git")
2732 .args([
2733 "rev-parse",
2734 "--abbrev-ref",
2735 "--symbolic-full-name",
2736 "@{upstream}",
2737 ])
2738 .env("GIT_TERMINAL_PROMPT", "0")
2739 .current_dir(workdir)
2740 .output()
2741 .ok()
2742 .filter(|o| o.status.success())?;
2743 let full = String::from_utf8_lossy(&output.stdout).into_owned();
2744 let (remote, branch) = full.trim().split_once('/')?;
2748 if remote.is_empty() || branch.is_empty() {
2749 return None;
2750 }
2751 Some(format!("{remote} {branch}"))
2752}
2753
2754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2755pub struct RemoteOp {
2756 pub what: &'static str,
2761 pub doing: &'static str,
2765 pub args: &'static [&'static str],
2767 pub flags: &'static [RemoteFlag],
2769}
2770
2771impl RemoteOp {
2772 pub const PULL: Self = Self {
2773 what: "pull",
2774 doing: "pulling",
2775 args: &["pull", "--ff-only"],
2780 flags: &[
2781 RemoteFlag {
2782 name: "rebase",
2783 arg: "--rebase",
2784 key: "-r",
2785 doc: "Rebase local commits onto the fetched head instead of merging",
2786 kind: RemoteArgKind::Flag,
2787 },
2788 RemoteFlag {
2789 name: "autostash",
2790 arg: "--autostash",
2791 key: "-a",
2792 doc: "Stash local changes for the pull and reapply them after",
2793 kind: RemoteArgKind::Flag,
2794 },
2795 ],
2796 };
2797 pub const PUSH: Self = Self {
2798 what: "push",
2799 doing: "pushing",
2800 args: &["push"],
2801 flags: &[
2802 RemoteFlag {
2803 name: "force-with-lease",
2804 arg: "--force-with-lease",
2809 key: "-f",
2810 doc: "Force-push, but refuse if the remote moved since your last fetch",
2811 kind: RemoteArgKind::Flag,
2812 },
2813 RemoteFlag {
2814 name: "set-upstream",
2815 arg: "--set-upstream",
2816 key: "-u",
2817 doc: "Set the pushed branch's upstream to the remote branch",
2818 kind: RemoteArgKind::Flag,
2819 },
2820 RemoteFlag {
2831 name: "no-verify",
2832 arg: "--no-verify",
2833 key: "-h",
2834 doc: "Skip the pre-push hook",
2835 kind: RemoteArgKind::Flag,
2836 },
2837 RemoteFlag {
2838 name: "dry-run",
2839 arg: "--dry-run",
2840 key: "-n",
2841 doc: "Show what would be pushed without sending anything",
2842 kind: RemoteArgKind::Flag,
2843 },
2844 ],
2845 };
2846 pub const FETCH: Self = Self {
2847 what: "fetch",
2848 doing: "fetching",
2849 args: &["fetch"],
2850 flags: &[
2851 RemoteFlag {
2852 name: "all",
2853 arg: "--all",
2854 key: "-a",
2855 doc: "Fetch from every remote, not just the default",
2856 kind: RemoteArgKind::Flag,
2857 },
2858 RemoteFlag {
2859 name: "prune",
2860 arg: "--prune",
2861 key: "-p",
2862 doc: "Delete local refs whose remote branch is gone",
2863 kind: RemoteArgKind::Flag,
2864 },
2865 RemoteFlag {
2870 name: "tags",
2871 arg: "--tags",
2872 key: "-t",
2873 doc: "Fetch all tags as well as the branches being fetched",
2874 kind: RemoteArgKind::Flag,
2875 },
2876 ],
2877 };
2878 pub const CHERRY_PICK_CONTINUE: Self = Self {
2897 what: "continue cherry-pick",
2898 doing: "continuing cherry-pick",
2899 args: &["cherry-pick", "--continue"],
2900 flags: &[],
2901 };
2902 pub const CHERRY_PICK_SKIP: Self = Self {
2903 what: "skip commit in cherry-pick",
2904 doing: "skipping commit in cherry-pick",
2905 args: &["cherry-pick", "--skip"],
2906 flags: &[],
2907 };
2908 pub const CHERRY_PICK_ABORT: Self = Self {
2909 what: "abort cherry-pick",
2910 doing: "aborting cherry-pick",
2911 args: &["cherry-pick", "--abort"],
2912 flags: &[],
2913 };
2914 pub const REVERT_CONTINUE: Self = Self {
2915 what: "continue revert",
2916 doing: "continuing revert",
2917 args: &["revert", "--continue"],
2918 flags: &[],
2919 };
2920 pub const REVERT_SKIP: Self = Self {
2921 what: "skip commit in revert",
2922 doing: "skipping commit in revert",
2923 args: &["revert", "--skip"],
2924 flags: &[],
2925 };
2926 pub const REVERT_ABORT: Self = Self {
2927 what: "abort revert",
2928 doing: "aborting revert",
2929 args: &["revert", "--abort"],
2930 flags: &[],
2931 };
2932 pub const MERGE_CONTINUE: Self = Self {
2937 what: "conclude merge",
2938 doing: "concluding merge",
2939 args: &["merge", "--continue"],
2940 flags: &[],
2941 };
2942 pub const MERGE_ABORT: Self = Self {
2944 what: "abort merge",
2945 doing: "aborting merge",
2946 args: &["merge", "--abort"],
2947 flags: &[],
2948 };
2949 pub const REBASE_CONTINUE: Self = Self {
2950 what: "continue rebase",
2951 doing: "continuing rebase",
2952 args: &["rebase", "--continue"],
2953 flags: &[],
2954 };
2955 pub const REBASE_SKIP: Self = Self {
2956 what: "skip commit in rebase",
2957 doing: "skipping commit in rebase",
2958 args: &["rebase", "--skip"],
2959 flags: &[],
2960 };
2961 pub const REBASE_ABORT: Self = Self {
2964 what: "abort rebase",
2965 doing: "aborting rebase",
2966 args: &["rebase", "--abort"],
2967 flags: &[],
2968 };
2969
2970 pub const NOTES_PRUNE: Self = Self {
2974 what: "prune notes",
2975 doing: "pruning notes",
2976 args: &["notes", "prune"],
2977 flags: &[RemoteFlag {
2978 name: "dry-run",
2979 arg: "--dry-run",
2980 key: "-n",
2981 doc: "Report what would be dropped without dropping it",
2982 kind: RemoteArgKind::Flag,
2983 }],
2984 };
2985 pub const NOTES_MERGE_COMMIT: Self = Self {
2986 what: "conclude notes merge",
2987 doing: "concluding notes merge",
2988 args: &["notes", "merge", "--commit"],
2989 flags: &[],
2990 };
2991 pub const NOTES_MERGE_ABORT: Self = Self {
2992 what: "abort notes merge",
2993 doing: "aborting notes merge",
2994 args: &["notes", "merge", "--abort"],
2995 flags: &[],
2996 };
2997
2998 pub const AM_CONTINUE: Self = Self {
3005 what: "continue applying patches",
3006 doing: "continuing to apply patches",
3007 args: &["am", "--continue"],
3008 flags: &[],
3009 };
3010 pub const AM_SKIP: Self = Self {
3011 what: "skip patch",
3012 doing: "skipping patch",
3013 args: &["am", "--skip"],
3014 flags: &[],
3015 };
3016 pub const AM_ABORT: Self = Self {
3017 what: "abort applying patches",
3018 doing: "aborting patch application",
3019 args: &["am", "--abort"],
3020 flags: &[],
3021 };
3022
3023 pub const STAGE_ALL: Self = Self {
3033 what: "stage all tracked changes",
3034 doing: "staging all tracked changes",
3035 args: &["add", "--update"],
3036 flags: &[],
3037 };
3038 pub const UNSTAGE_ALL: Self = Self {
3046 what: "unstage everything",
3047 doing: "unstaging everything",
3048 args: &["reset", "--quiet"],
3049 flags: &[],
3050 };
3051 pub const STASH_KEEP_INDEX: Self = Self {
3054 what: "stash, keeping the index",
3055 doing: "stashing, keeping the index",
3056 args: &["stash", "push", "--keep-index"],
3057 flags: &[],
3058 };
3059 pub const STASH_STAGED: Self = Self {
3061 what: "stash staged changes",
3062 doing: "stashing staged changes",
3063 args: &["stash", "push", "--staged"],
3064 flags: &[],
3065 };
3066 pub const STASH: Self = Self {
3067 what: "stash changes",
3068 doing: "stashing changes",
3069 args: &["stash", "push"],
3070 flags: &[
3071 RemoteFlag {
3072 name: "include-untracked",
3073 arg: "--include-untracked",
3074 key: "-u",
3075 doc: "Stash untracked files too, not just tracked changes",
3076 kind: RemoteArgKind::Flag,
3077 },
3078 RemoteFlag {
3082 name: "message",
3083 arg: "-m",
3084 key: "-m",
3085 doc: "Label the stash so you can recognise it later",
3086 kind: RemoteArgKind::Value {
3087 prompt: "Stash message",
3088 },
3089 },
3090 ],
3091 };
3092
3093 pub fn argv(&self, args: &lattice_grammar::Args) -> Vec<String> {
3097 let mut argv: Vec<String> = self.args.iter().map(|s| (*s).to_string()).collect();
3098 if self.rebase_selected(args) {
3105 argv.retain(|a| a != "--ff-only");
3106 }
3107 for (i, flag) in self.flags.iter().enumerate() {
3108 let slot = args.as_list().and_then(|l| l.get(i));
3109 match flag.kind {
3110 RemoteArgKind::Flag => {
3111 if matches!(slot, Some(lattice_grammar::ArgValue::Bool(true))) {
3112 argv.push(flag.arg.to_string());
3113 }
3114 }
3115 RemoteArgKind::Value { .. } => {
3119 if let Some(lattice_grammar::ArgValue::String(v)) = slot
3120 && !v.is_empty()
3121 {
3122 if !flag.arg.is_empty() {
3123 argv.push(flag.arg.to_string());
3124 }
3125 argv.push(v.clone());
3126 }
3127 }
3128 RemoteArgKind::ValueJoined { .. } => {
3129 if let Some(lattice_grammar::ArgValue::String(v)) = slot
3130 && !v.is_empty()
3131 {
3132 argv.push(format!("{}{v}", flag.arg));
3133 }
3134 }
3135 }
3136 }
3137 argv
3138 }
3139
3140 pub fn label(&self, argv: &[String]) -> String {
3149 let has = |flag: &str| argv.iter().any(|a| a == flag);
3150 let words = self.args.iter().take_while(|a| !a.starts_with('-')).count();
3152 let mut rest: Vec<&str> = Vec::new();
3153 let mut message = None;
3154 let mut tail = argv.iter().skip(words);
3155 while let Some(a) = tail.next() {
3156 if a == "-m" {
3157 message = tail.next().map(String::as_str);
3158 } else if !a.starts_with('-') {
3159 rest.push(a);
3160 }
3161 }
3162 let dest = match rest.as_slice() {
3165 [remote, branch] if !remote.contains(['/', ':']) && !branch.contains(':') => {
3166 format!("{remote}/{branch}")
3167 }
3168 other => other.join(" "),
3169 };
3170 let mut label = match self.args.first().copied() {
3171 Some("push") => {
3172 let verb = if has("--force-with-lease") {
3173 "force-push"
3174 } else {
3175 "push"
3176 };
3177 let mut s = if has("--tags") {
3178 format!("{verb} tags")
3179 } else {
3180 verb.to_string()
3181 };
3182 if !dest.is_empty() {
3183 s.push_str(&format!(" to {dest}"));
3184 }
3185 s
3186 }
3187 Some("fetch") if has("--all") => "fetch all remotes".to_string(),
3188 Some("fetch") | Some("pull") if !dest.is_empty() => format!("{} {dest}", self.what),
3189 Some("pull") if has("--rebase") => "pull and rebase".to_string(),
3190 _ => self.what.to_string(),
3191 };
3192 if let Some(message) = message.filter(|m| !m.is_empty()) {
3193 label.push_str(&format!(" \u{201c}{message}\u{201d}"));
3194 }
3195 if has("--dry-run") {
3196 label.push_str(" (dry run)");
3197 }
3198 label
3199 }
3200
3201 fn rebase_selected(&self, args: &lattice_grammar::Args) -> bool {
3204 self.flags
3205 .iter()
3206 .position(|f| f.name == "rebase")
3207 .and_then(|i| args.as_list().and_then(|l| l.get(i)))
3208 .is_some_and(|v| matches!(v, lattice_grammar::ArgValue::Bool(true)))
3209 }
3210
3211 pub fn preview(&self, value_of: &dyn Fn(&str) -> Option<String>) -> String {
3215 let mut out = String::from("git");
3216 for a in self.args {
3217 out.push(' ');
3218 out.push_str(a);
3219 }
3220 for flag in self.flags {
3221 let Some(v) = value_of(flag.name) else {
3222 continue;
3223 };
3224 match flag.kind {
3225 RemoteArgKind::Flag => {
3226 if v == "true" {
3227 out.push(' ');
3228 out.push_str(flag.arg);
3229 }
3230 }
3231 RemoteArgKind::Value { .. } => {
3232 if !v.is_empty() {
3233 if !flag.arg.is_empty() {
3234 out.push(' ');
3235 out.push_str(flag.arg);
3236 }
3237 out.push_str(&format!(" {v:?}"));
3240 }
3241 }
3242 RemoteArgKind::ValueJoined { .. } => {
3243 if !v.is_empty() {
3244 out.push(' ');
3245 out.push_str(flag.arg);
3246 out.push_str(&v);
3247 }
3248 }
3249 }
3250 }
3251 out
3252 }
3253
3254 pub fn arg_specs(&self) -> Vec<lattice_grammar::ArgSpec> {
3257 self.flags
3258 .iter()
3259 .map(|f| {
3260 let kind = match f.kind {
3261 RemoteArgKind::Flag => lattice_grammar::ArgKind::Bool,
3262 RemoteArgKind::Value { .. } | RemoteArgKind::ValueJoined { .. } => {
3263 lattice_grammar::ArgKind::String
3264 }
3265 };
3266 lattice_grammar::ArgSpec::optional(f.name, kind, f.doc)
3267 })
3268 .collect()
3269 }
3270}
3271
3272static PENDING_FIRST_INPUT: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
3291
3292pub(crate) fn stash_pending_commit(value: String) {
3295 stash_first_input(value);
3296}
3297
3298pub(crate) fn prompt_for_pub(prompt: &str, finish_action: &str) -> Effect {
3301 prompt_for(prompt, finish_action)
3302}
3303
3304fn stash_first_input(value: String) {
3305 if let Ok(mut slot) = PENDING_FIRST_INPUT.lock() {
3306 *slot = Some(value);
3307 }
3308}
3309
3310fn take_first_input() -> Option<String> {
3311 PENDING_FIRST_INPUT.lock().ok().and_then(|mut s| s.take())
3312}
3313
3314fn prompt_for(prompt: &str, finish_action: &str) -> Effect {
3315 prompt_seeded(prompt, finish_action, String::new())
3316}
3317
3318fn prompt_seeded(prompt: &str, finish_action: &str, initial: String) -> Effect {
3322 Effect::OpenPrompt {
3323 prompt: prompt.to_string(),
3324 initial,
3325 on_submit_action: finish_action.to_string(),
3326 buffer_name: None,
3327 }
3328}
3329
3330static EVENT_BUS: std::sync::OnceLock<std::sync::Arc<lattice_runtime::EventBus>> =
3345 std::sync::OnceLock::new();
3346
3347pub(crate) fn set_event_bus(bus: std::sync::Arc<lattice_runtime::EventBus>) {
3348 let _ = EVENT_BUS.set(bus);
3349}
3350
3351pub(crate) fn event_bus() -> Option<std::sync::Arc<lattice_runtime::EventBus>> {
3354 EVENT_BUS.get().cloned()
3355}
3356
3357pub(crate) fn invalidates_a_magit_view(event: &lattice_protocol::event::Event) -> bool {
3368 matches!(
3369 event,
3370 lattice_protocol::event::Event::BackgroundTaskFinished { source, .. }
3371 if source == "magit"
3372 )
3373}
3374
3375#[derive(Debug, Clone, PartialEq, Eq)]
3383pub(crate) enum TaskResult {
3384 Done(String),
3385 Stopped(String),
3387 Failed(String),
3388}
3389
3390impl From<Result<String, String>> for TaskResult {
3391 fn from(result: Result<String, String>) -> Self {
3392 if let Some(reason) = crate::git_report::stopped_reason(&result) {
3393 let detail = match result {
3394 Ok(out) | Err(out) => out,
3395 };
3396 return TaskResult::Stopped(format!("{reason}\n{detail}"));
3397 }
3398 match result {
3399 Ok(out) => TaskResult::Done(out),
3400 Err(err) => TaskResult::Failed(err),
3401 }
3402 }
3403}
3404
3405pub(crate) fn task_scope(workdir: &std::path::Path) -> Option<String> {
3415 use std::collections::{HashMap, HashSet};
3416 use std::sync::{Mutex, OnceLock};
3417 static SEEN: OnceLock<Mutex<HashMap<String, HashSet<std::path::PathBuf>>>> = OnceLock::new();
3418
3419 if workdir.as_os_str().is_empty() {
3420 return None;
3421 }
3422 let base = crate::workdir::repo_label(workdir);
3423 let shared = SEEN
3424 .get_or_init(Default::default)
3425 .lock()
3426 .map(|mut seen| {
3427 let dirs = seen.entry(base.clone()).or_default();
3428 dirs.insert(workdir.to_path_buf());
3429 dirs.len() > 1
3430 })
3431 .unwrap_or(false);
3432 Some(if shared {
3433 crate::workdir::qualified_repo_label(workdir)
3434 } else {
3435 base
3436 })
3437}
3438
3439pub(crate) fn finish_task(workdir: &std::path::Path, label: &str, result: impl Into<TaskResult>) {
3455 use lattice_protocol::event::{Event, TaskOutcome};
3456 let outcome = match result.into() {
3457 TaskResult::Done(out) => {
3458 tracing::debug!(target: "lattice_magit", "magit: {label} succeeded: {out}");
3462 TaskOutcome::Succeeded {
3463 summary: first_line(&out),
3464 }
3465 }
3466 TaskResult::Stopped(why) => {
3467 tracing::debug!(target: "lattice_magit", "magit: {label} stopped: {why}");
3470 TaskOutcome::Stopped {
3471 message: first_line(&why),
3472 }
3473 }
3474 TaskResult::Failed(err) => {
3475 tracing::error!(target: "lattice_magit", "magit: {label} failed: {err}");
3478 TaskOutcome::Failed {
3479 message: first_line(&err),
3480 }
3481 }
3482 };
3483 if let Some(bus) = EVENT_BUS.get() {
3484 bus.publish(Event::BackgroundTaskFinished {
3485 source: "magit".to_string(),
3486 scope: task_scope(workdir),
3487 label: label.to_string(),
3488 outcome,
3489 });
3490 }
3491}
3492
3493fn first_line(text: &str) -> String {
3495 text.lines().next().unwrap_or_default().trim().to_string()
3496}
3497
3498pub struct GitStep {
3500 pub name: &'static str,
3503 pub argv: Vec<String>,
3504}
3505
3506pub fn spawn_git_sequence(
3522 workdir: std::path::PathBuf,
3523 label: impl Into<String>,
3524 steps: Vec<GitStep>,
3525) -> Effect {
3526 let label = label.into();
3527 let shown = steps
3528 .first()
3529 .map(|s| format!("git {}", s.argv.join(" ")))
3530 .unwrap_or_else(|| label.clone());
3531 let scope_dir = workdir.clone();
3532 tokio::task::spawn(async move {
3533 let result = tokio::task::spawn_blocking(move || {
3534 let mut last = String::new();
3535 for step in steps {
3536 match run_remote_op(&workdir, &step.argv) {
3537 Ok(out) => last = out,
3538 Err(e) => return Err(format!("{}: {e}", step.name)),
3542 }
3543 }
3544 Ok(last)
3545 })
3546 .await
3547 .unwrap_or_else(|e| Err(e.to_string()));
3548 finish_task(&scope_dir, &label, result);
3549 });
3550 Effect::Echo {
3551 level: lattice_grammar::EchoLevel::Info,
3552 text: format!("magit: {shown}"),
3553 }
3554}
3555
3556pub fn spawn_rebase_verb(workdir: std::path::PathBuf, verb: &'static str, commit: &str) -> Effect {
3561 spawn_rebase_verb_with(workdir, verb, commit, None)
3562}
3563
3564pub(crate) fn rebase_verb_label(verb: &str, commit: &str) -> String {
3566 let commit = short_rev(commit);
3567 match verb {
3568 "edit" => format!("rebase to edit {commit}"),
3569 "drop" => format!("drop {commit} from history"),
3570 "reword" => format!("reword {commit}"),
3571 other => format!("{other} {commit} in a rebase"),
3572 }
3573}
3574
3575pub fn spawn_rebase_verb_with(
3576 workdir: std::path::PathBuf,
3577 verb: &'static str,
3578 commit: &str,
3579 message: Option<String>,
3580) -> Effect {
3581 let label = rebase_verb_label(verb, commit);
3582 let commit = commit.to_string();
3583 let shown = format!("git rebase ({verb} {commit})");
3584 let scope_dir = workdir.clone();
3585 tokio::task::spawn(async move {
3586 let result = tokio::task::spawn_blocking(move || {
3587 crate::magit_rebase_mode::rebase_one_commit(&workdir, &commit, verb, message.as_deref())
3588 .map(|()| String::new())
3589 })
3590 .await
3591 .unwrap_or_else(|e| Err(e.to_string()));
3592 let result = match result {
3595 Ok(_) if verb == "edit" => {
3596 TaskResult::Stopped("amend, then continue the rebase".to_string())
3597 }
3598 other => other.into(),
3599 };
3600 finish_task(&scope_dir, &label, result);
3601 });
3602 Effect::Echo {
3603 level: lattice_grammar::EchoLevel::Info,
3604 text: format!("magit: {shown}"),
3605 }
3606}
3607
3608pub fn spawn_computed<F>(workdir: std::path::PathBuf, label: String, shown: String, f: F) -> Effect
3618where
3619 F: FnOnce(&std::path::Path) -> Result<(), String> + Send + 'static,
3620{
3621 let scope_dir = workdir.clone();
3622 tokio::task::spawn(async move {
3623 let result = tokio::task::spawn_blocking(move || f(&workdir).map(|()| String::new()))
3624 .await
3625 .unwrap_or_else(|e| Err(e.to_string()));
3626 finish_task(&scope_dir, &label, result);
3627 });
3628 Effect::Echo {
3629 level: lattice_grammar::EchoLevel::Info,
3630 text: format!("magit: {shown}"),
3631 }
3632}
3633
3634pub fn spawn_git(workdir: std::path::PathBuf, argv: Vec<String>, what: &str) -> Effect {
3635 let shown = format!("git {}", argv.join(" "));
3636 let logged = shown.clone();
3637 let what = what.to_string();
3638 let scope_dir = workdir.clone();
3639 tokio::task::spawn(async move {
3640 let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
3641 .await
3642 .unwrap_or_else(|e| Err(e.to_string()));
3643 let _ = logged;
3646 finish_task(&scope_dir, &what, result);
3647 });
3648 Effect::Echo {
3649 level: lattice_grammar::EchoLevel::Info,
3650 text: format!("magit: {shown}"),
3651 }
3652}
3653
3654pub(crate) fn format_patch_argv(range: &str, output_dir: Option<&str>) -> Option<Vec<String>> {
3663 let range = range.trim();
3664 if range.is_empty() {
3665 return None;
3666 }
3667 let mut argv = vec!["format-patch".to_string()];
3668 if let Some(dir) = output_dir {
3669 argv.push("-o".to_string());
3670 argv.push(dir.to_string());
3671 }
3672 argv.push(range.to_string());
3673 Some(argv)
3674}
3675
3676pub(crate) fn am_argv(files: &str, three_way: bool) -> Option<Vec<String>> {
3684 let files: Vec<&str> = files
3685 .split_whitespace()
3686 .filter(|f| !f.starts_with("--"))
3687 .collect();
3688 if files.is_empty() {
3689 return None;
3690 }
3691 let mut argv = vec!["am".to_string()];
3692 if three_way {
3693 argv.push("--3way".to_string());
3694 }
3695 argv.extend(files.iter().map(|f| f.to_string()));
3696 Some(argv)
3697}
3698
3699pub(crate) fn am_wants_three_way(line: &str) -> bool {
3702 line.split_whitespace().any(|w| w == "--3way" || w == "-3")
3703}
3704
3705#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3713pub struct SubtreeOp {
3714 pub what: &'static str,
3716 pub sub: &'static str,
3718 pub ex_command: &'static str,
3720 pub takes_repo: bool,
3723 pub takes_ref: bool,
3725}
3726
3727impl SubtreeOp {
3728 pub const ADD: Self = Self {
3729 what: "subtree add",
3730 sub: "add",
3731 ex_command: "magit-subtree-add",
3732 takes_repo: true,
3733 takes_ref: true,
3734 };
3735 pub const MERGE: Self = Self {
3736 what: "subtree merge",
3737 sub: "merge",
3738 ex_command: "magit-subtree-merge",
3739 takes_repo: false,
3740 takes_ref: true,
3741 };
3742 pub const PULL: Self = Self {
3743 what: "subtree pull",
3744 sub: "pull",
3745 ex_command: "magit-subtree-pull",
3746 takes_repo: true,
3747 takes_ref: true,
3748 };
3749 pub const PUSH: Self = Self {
3750 what: "subtree push",
3751 sub: "push",
3752 ex_command: "magit-subtree-push",
3753 takes_repo: true,
3754 takes_ref: true,
3755 };
3756 pub const SPLIT: Self = Self {
3757 what: "subtree split",
3758 sub: "split",
3759 ex_command: "magit-subtree-split",
3760 takes_repo: false,
3761 takes_ref: false,
3762 };
3763
3764 pub fn usage(&self) -> &'static str {
3766 match (self.takes_repo, self.takes_ref) {
3767 (true, true) => "<prefix> <repository> <ref>",
3768 (false, true) => "<prefix> <ref>",
3769 _ => "<prefix>",
3770 }
3771 }
3772}
3773
3774pub(crate) fn subtree_argv(op: SubtreeOp, line: &str) -> Option<Vec<String>> {
3785 let mut words: Vec<&str> = line.split_whitespace().collect();
3786 let squash = matches!(words.last(), Some(&"--squash") | Some(&"squash"))
3787 && matches!(op.sub, "add" | "pull");
3788 if squash {
3789 words.pop();
3790 }
3791 let wanted = 1 + usize::from(op.takes_repo) + usize::from(op.takes_ref);
3792 if words.len() != wanted {
3793 return None;
3794 }
3795 let mut argv = vec![
3796 "subtree".to_string(),
3797 op.sub.to_string(),
3798 format!("--prefix={}", words[0]),
3799 ];
3800 if squash {
3801 argv.push("--squash".to_string());
3802 }
3803 argv.extend(words[1..].iter().map(|w| w.to_string()));
3804 Some(argv)
3805}
3806
3807pub fn spawn_subtree_op(workdir: std::path::PathBuf, op: SubtreeOp, line: &str) -> Effect {
3814 let Some(argv) = subtree_argv(op, line) else {
3815 return Effect::Echo {
3816 level: lattice_grammar::EchoLevel::Error,
3817 text: format!("magit: usage — :{} {}", op.ex_command, op.usage()),
3818 };
3819 };
3820 let what = op.what;
3821 let prefix = argv
3823 .get(2)
3824 .and_then(|a| a.strip_prefix("--prefix="))
3825 .unwrap_or_default();
3826 let label = format!("{} subtree {prefix}", op.sub);
3827 let scope_dir = workdir.clone();
3828 tokio::task::spawn(async move {
3829 let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
3830 .await
3831 .unwrap_or_else(|e| Err(e.to_string()));
3832 finish_task(&scope_dir, &label, result);
3834 });
3835 Effect::Echo {
3836 level: lattice_grammar::EchoLevel::Info,
3837 text: format!("magit: {what}…"),
3838 }
3839}
3840
3841pub(crate) fn note_merge_argv(spec: &str) -> Option<Vec<String>> {
3853 let spec = spec.trim();
3854 let (git_ref, strategy) = match spec.split_once(char::is_whitespace) {
3855 Some((r, s)) => (r, lattice_vcs::NoteMergeStrategy::parse(s)?),
3856 None => (spec, lattice_vcs::NoteMergeStrategy::Manual),
3857 };
3858 if git_ref.is_empty() {
3859 return None;
3860 }
3861 Some(vec![
3862 "notes".to_string(),
3863 "merge".to_string(),
3864 format!("--strategy={}", strategy.as_str()),
3865 git_ref.to_string(),
3866 ])
3867}
3868
3869pub(crate) fn spawn_note_remove(workdir: std::path::PathBuf, commit: String) -> Effect {
3871 spawn_git(
3872 workdir,
3873 vec![
3874 "notes".to_string(),
3875 "remove".to_string(),
3876 "--ignore-missing".to_string(),
3877 commit.clone(),
3878 ],
3879 &format!("remove the note on {}", short_rev(&commit)),
3880 )
3881}
3882
3883pub(crate) fn spawn_note_prune(workdir: std::path::PathBuf) -> Effect {
3889 spawn_remote_op(workdir, RemoteOp::NOTES_PRUNE, &lattice_grammar::Args::None)
3890}
3891
3892pub(crate) fn spawn_note_merge(workdir: std::path::PathBuf, spec: &str) -> Effect {
3894 let label = format!(
3895 "merge notes from {}",
3896 spec.split_whitespace().next().unwrap_or(spec)
3897 );
3898 let Some(argv) = note_merge_argv(spec) else {
3899 return Effect::Echo {
3900 level: lattice_grammar::EchoLevel::Error,
3901 text: "magit: usage — <notes-ref> [manual|ours|theirs|union|cat_sort_uniq]".to_string(),
3902 };
3903 };
3904 let scope_dir = workdir.clone();
3905 tokio::task::spawn(async move {
3906 let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
3907 .await
3908 .unwrap_or_else(|e| Err(e.to_string()));
3909 finish_task(&scope_dir, &label, result);
3911 });
3912 Effect::Echo {
3913 level: lattice_grammar::EchoLevel::Info,
3914 text: format!("magit: merging notes from {spec}…"),
3915 }
3916}
3917
3918pub(crate) fn clone_argv(url: &str, dest: &str) -> Vec<String> {
3929 vec![
3930 "clone".to_string(),
3931 "--".to_string(),
3932 url.to_string(),
3933 dest.to_string(),
3934 ]
3935}
3936
3937pub(crate) fn default_clone_dest(url: &str) -> String {
3948 let trimmed = url.trim().trim_end_matches('/');
3949 let last = trimmed.rsplit(['/', ':']).next().unwrap_or_default().trim();
3950 last.strip_suffix(".git").unwrap_or(last).to_string()
3951}
3952
3953pub fn spawn_clone(url: String, dest: String) -> Effect {
3969 let argv = clone_argv(&url, &dest);
3970 let label = format!("clone into {dest}");
3971 let scope_dir = std::path::PathBuf::from(&dest);
3972 tokio::task::spawn(async move {
3973 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
3974 let result = tokio::task::spawn_blocking(move || run_remote_op(&cwd, &argv))
3975 .await
3976 .unwrap_or_else(|e| Err(e.to_string()));
3977 finish_task(&scope_dir, &label, result);
3979 });
3980 Effect::Echo {
3981 level: lattice_grammar::EchoLevel::Info,
3982 text: format!("magit: cloning into {dest}…"),
3983 }
3984}
3985
3986pub fn spawn_gitignore(workdir: std::path::PathBuf, pattern: String) -> Effect {
3998 let shown = pattern.clone();
3999 let label = format!("ignore {pattern}");
4000 let scope_dir = workdir.clone();
4001 tokio::task::spawn(async move {
4002 let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
4003 if workdir.as_os_str().is_empty() {
4008 return Err("not a git repository".to_string());
4009 }
4010 let path = workdir.join(".gitignore");
4011 let existing = std::fs::read_to_string(&path).unwrap_or_default();
4012 let Some(out) = gitignore_append(&existing, &pattern) else {
4013 return Ok("it was already ignored".to_string());
4014 };
4015 std::fs::write(&path, out).map_err(|e| e.to_string())?;
4016 Ok(String::new())
4017 })
4018 .await
4019 .unwrap_or_else(|e| Err(e.to_string()));
4020 finish_task(&scope_dir, &label, result);
4022 });
4023 Effect::Echo {
4024 level: lattice_grammar::EchoLevel::Info,
4025 text: format!("magit: ignoring {shown}"),
4026 }
4027}
4028
4029pub(crate) fn untrack_argv(path: &str) -> Vec<String> {
4041 vec![
4042 "rm".into(),
4043 "--cached".into(),
4044 "--".into(),
4045 path.to_string(),
4046 ]
4047}
4048
4049pub(crate) fn delete_argv(path: &str) -> Vec<String> {
4050 vec!["rm".into(), "--".into(), path.to_string()]
4051}
4052
4053pub(crate) fn rename_argv(from: &str, to: &str) -> Vec<String> {
4054 vec!["mv".into(), "--".into(), from.to_string(), to.to_string()]
4055}
4056
4057pub(crate) fn checkout_file_argv(rev: &str, path: &str) -> Vec<String> {
4064 vec![
4065 "checkout".into(),
4066 rev.to_string(),
4067 "--".into(),
4068 path.to_string(),
4069 ]
4070}
4071
4072pub(crate) fn rename_source_from_prompt_buffer_name(buffer_name: &str) -> Option<String> {
4079 path_from_prompt_buffer_name(buffer_name, "*magit:rename:")
4080}
4081
4082pub(crate) fn checkout_target_from_prompt_buffer_name(buffer_name: &str) -> Option<String> {
4084 path_from_prompt_buffer_name(buffer_name, "*magit:checkout:")
4085}
4086
4087pub(crate) const BISECT_START_PREFIX: &str = "*magit:bisect-start:";
4091
4092pub(crate) fn bisect_start_buffer_name(bad: &str) -> String {
4093 format!("{BISECT_START_PREFIX}{bad}*")
4094}
4095
4096pub(crate) fn bad_from_bisect_start_buffer_name(buffer_name: &str) -> Option<String> {
4097 path_from_prompt_buffer_name(buffer_name, BISECT_START_PREFIX)
4098}
4099
4100fn spawn_bisect(
4108 ctx: &ActionContext<'_>,
4109 what: &'static str,
4110 op: impl FnOnce(&lattice_vcs::Repository) -> lattice_vcs::Result<Option<lattice_vcs::BisectStep>>
4111 + Send
4112 + 'static,
4113) {
4114 let Some(views) = ctx.services.get::<crate::buffer_state::MagitViewsHandle>() else {
4115 return;
4116 };
4117 let workdir = crate::repo_scope::action_workdir(ctx);
4118 tokio::task::spawn(async move {
4119 let wd = workdir.clone();
4120 let outcome =
4121 tokio::task::spawn_blocking(move || match lattice_vcs::Repository::discover(&wd) {
4122 Ok(repo) => op(&repo),
4123 Err(e) => Err(lattice_vcs::VcsError::Bisect(format!(
4124 "no repository at {}: {e}",
4125 wd.display()
4126 ))),
4127 })
4128 .await;
4129 let label = bisect_label(what);
4133 let result = match outcome {
4134 Ok(Ok(step)) => Ok(step.map(|s| bisect_summary(&s)).unwrap_or_default()),
4135 Ok(Err(e)) => Err(e.to_string()),
4136 Err(e) => Err(format!("panicked: {e}")),
4137 };
4138 finish_task(&workdir, &label, result);
4139 for view in views.all() {
4140 let _ = view.refresh();
4141 }
4142 });
4143}
4144
4145fn bisect_label(what: &str) -> String {
4147 match what {
4148 "start" => "start bisecting".to_string(),
4149 "reset" => "end the bisect".to_string(),
4150 mark => format!("mark the bisect commit {mark}"),
4151 }
4152}
4153
4154fn bisect_summary(step: &lattice_vcs::BisectStep) -> String {
4160 use lattice_vcs::BisectStep;
4161 match step {
4162 BisectStep::Found { commit, subject } => {
4163 format!("first bad commit is {} {subject}", short_rev(commit))
4164 }
4165 BisectStep::Testing {
4166 commit,
4167 subject,
4168 revisions_left,
4169 steps,
4170 } => {
4171 let mut s = format!("now testing {} {subject}", short_rev(commit));
4172 if let Some(left) = revisions_left {
4173 s.push_str(&format!(", {left} left"));
4174 }
4175 if let Some(steps) = steps {
4176 s.push_str(&format!(" (about {steps} more)"));
4177 }
4178 s
4179 }
4180 BisectStep::Other(line) => line.clone(),
4181 }
4182}
4183
4184fn path_from_prompt_buffer_name(buffer_name: &str, prefix: &str) -> Option<String> {
4185 let s = buffer_name.strip_prefix(prefix)?;
4186 let s = s.strip_suffix('*')?;
4187 (!s.is_empty()).then(|| s.to_string())
4188}
4189
4190pub(crate) fn tag_argv(name: &str) -> Vec<String> {
4200 vec!["tag".into(), name.to_string()]
4201}
4202
4203pub(crate) fn init_argv(dir: &str) -> Vec<String> {
4204 vec!["init".into(), dir.to_string()]
4205}
4206
4207pub(crate) fn merge_label(kind: &str, branch: &str) -> String {
4215 match kind {
4216 "no-commit" => format!("merge {branch} without committing"),
4217 "squash" => format!("squash {branch} into the index"),
4218 _ => format!("merge {branch}"),
4219 }
4220}
4221
4222pub(crate) fn am_label(argv: &[String]) -> String {
4224 let files: Vec<&str> = argv
4225 .iter()
4226 .skip(1)
4227 .map(String::as_str)
4228 .filter(|a| !a.starts_with('-'))
4229 .collect();
4230 match files.as_slice() {
4231 [one] => format!("apply patch {one}"),
4232 many => format!("apply {} patches", many.len()),
4233 }
4234}
4235
4236pub(crate) fn format_patch_label(argv: &[String]) -> String {
4238 let range = argv.last().map(String::as_str).unwrap_or_default();
4239 format!("write patches for {range}")
4240}
4241
4242pub(crate) fn merge_argv(branch: &str) -> Vec<String> {
4243 vec!["merge".into(), "--no-edit".into(), branch.to_string()]
4244}
4245
4246pub(crate) fn merge_no_commit_argv(branch: &str) -> Vec<String> {
4252 vec!["merge".into(), "--no-commit".into(), branch.to_string()]
4253}
4254
4255pub(crate) fn merge_squash_argv(branch: &str) -> Vec<String> {
4258 vec!["merge".into(), "--squash".into(), branch.to_string()]
4259}
4260
4261pub(crate) fn reset_file_argv(commit: &str, path: &str) -> Vec<String> {
4269 vec![
4270 "checkout".into(),
4271 commit.to_string(),
4272 "--".into(),
4273 path.to_string(),
4274 ]
4275}
4276
4277pub(crate) fn tag_release_argv(name: &str, message: &str) -> Vec<String> {
4284 vec![
4285 "tag".into(),
4286 "-a".into(),
4287 name.to_string(),
4288 "-m".into(),
4289 message.to_string(),
4290 ]
4291}
4292
4293pub(crate) fn tag_prune_argv(remote: &str) -> Vec<String> {
4300 vec![
4301 "fetch".into(),
4302 "--prune".into(),
4303 "--prune-tags".into(),
4304 remote.to_string(),
4305 ]
4306}
4307
4308pub(crate) fn merge_into_steps(current: &str, target: &str) -> Vec<GitStep> {
4320 vec![
4321 GitStep {
4322 name: "checkout the target branch",
4323 argv: vec!["checkout".into(), target.to_string()],
4324 },
4325 GitStep {
4326 name: "merge",
4327 argv: merge_argv(current),
4328 },
4329 GitStep {
4330 name: "delete the merged branch",
4331 argv: vec!["branch".into(), "-d".into(), current.to_string()],
4332 },
4333 ]
4334}
4335
4336pub(crate) fn current_branch(workdir: &std::path::Path) -> Option<String> {
4338 let out = std::process::Command::new("git")
4339 .args(["rev-parse", "--abbrev-ref", "HEAD"])
4340 .current_dir(workdir)
4341 .output()
4342 .ok()?;
4343 if !out.status.success() {
4344 return None;
4345 }
4346 let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
4347 (!branch.is_empty() && branch != "HEAD").then_some(branch)
4350}
4351
4352pub(crate) fn rebase_onto_argv(target: &str) -> Vec<String> {
4362 vec!["rebase".into(), target.to_string()]
4363}
4364
4365pub(crate) fn rebase_subset_argv(newbase: &str, upstream: &str) -> Vec<String> {
4372 vec![
4373 "rebase".into(),
4374 "--onto".into(),
4375 newbase.to_string(),
4376 upstream.to_string(),
4377 ]
4378}
4379
4380pub(crate) fn rebase_autosquash_argv(upstream: &str) -> Vec<String> {
4389 vec![
4390 "rebase".into(),
4391 "-i".into(),
4392 "--autosquash".into(),
4393 upstream.to_string(),
4394 ]
4395}
4396
4397pub(crate) fn stash_branch_argv(branch: &str, stash: &str) -> Vec<String> {
4403 vec![
4404 "stash".into(),
4405 "branch".into(),
4406 branch.to_string(),
4407 stash.to_string(),
4408 ]
4409}
4410
4411pub(crate) fn stash_snapshot_steps(extra: &[&str]) -> Vec<GitStep> {
4418 let mut push: Vec<String> = vec!["stash".into(), "push".into()];
4419 push.extend(extra.iter().map(|s| (*s).to_string()));
4420 vec![
4421 GitStep {
4422 name: "stash",
4423 argv: push,
4424 },
4425 GitStep {
4426 name: "restore working tree",
4427 argv: vec!["stash".into(), "apply".into()],
4428 },
4429 ]
4430}
4431
4432pub(crate) fn instant_squash_steps(kind: &'static str, commit: &str) -> Vec<GitStep> {
4441 vec![
4442 GitStep {
4443 name: "record the marker commit",
4444 argv: vec![
4445 "commit".into(),
4446 "--no-edit".into(),
4447 format!("--{kind}"),
4448 commit.to_string(),
4449 ],
4450 },
4451 GitStep {
4452 name: "autosquash",
4453 argv: vec![
4454 "rebase".into(),
4455 "-i".into(),
4456 "--autosquash".into(),
4457 "--autostash".into(),
4458 format!("{commit}~1"),
4459 ],
4460 },
4461 ]
4462}
4463
4464pub(crate) fn merge_absorb_steps(branch: &str) -> Vec<GitStep> {
4471 vec![
4472 GitStep {
4473 name: "merge",
4474 argv: merge_argv(branch),
4475 },
4476 GitStep {
4477 name: "delete the merged branch",
4478 argv: vec!["branch".into(), "-d".into(), branch.to_string()],
4479 },
4480 ]
4481}
4482
4483pub(crate) fn tag_delete_argv(name: &str) -> Vec<String> {
4489 vec!["tag".into(), "-d".into(), name.to_string()]
4490}
4491
4492#[cfg(test)]
4506pub(crate) const PROMPTED_OPS: &[(&str, &str)] = &[
4507 ("action:magit-global-tag", "action:magit-global-tag-finish"),
4508 (
4509 "action:magit-global-gitignore",
4510 "action:magit-global-gitignore-finish",
4511 ),
4512 (
4513 "action:magit-global-init",
4514 "action:magit-global-init-finish",
4515 ),
4516];
4517
4518#[cfg(test)]
4527pub(crate) const PICKED_BRANCH_OPS: &[(&str, &str)] = &[
4528 ("action:magit-global-merge", "magit-merge"),
4529 ("action:magit-global-branch-reset", "magit-branch-reset"),
4530 (
4536 "action:magit-global-merge-no-commit",
4537 "magit-merge-no-commit",
4538 ),
4539 ("action:magit-global-merge-squash", "magit-merge-squash"),
4540 (
4541 "action:magit-global-rebase-onto-elsewhere",
4542 "magit-rebase-onto",
4543 ),
4544 (
4545 "action:magit-global-rebase-autosquash",
4546 "magit-rebase-autosquash",
4547 ),
4548 ("action:magit-global-merge-absorb", "magit-merge-absorb"),
4550 ("action:magit-global-merge-edit", "magit-merge-edit"),
4551 ("action:magit-global-merge-into", "magit-merge-into"),
4552];
4553
4554#[cfg(test)]
4563pub(crate) const PICKED_OTHER_OPS: &[(&str, &str, &str)] = &[
4564 (
4565 "action:magit-global-tag-delete",
4566 crate::picker_sources::TAG_PICK_SOURCE,
4567 "magit-tag-delete",
4568 ),
4569 (
4570 "action:magit-global-tag-prune",
4571 crate::picker_sources::REMOTE_PICK_SOURCE,
4572 "magit-tag-prune",
4573 ),
4574 (
4576 "action:magit-global-note-merge",
4577 crate::picker_sources::REF_PICK_SOURCE,
4578 "magit-note-merge",
4579 ),
4580];
4581
4582pub(crate) fn gitignore_append(existing: &str, pattern: &str) -> Option<String> {
4590 if existing.lines().any(|l| l.trim() == pattern.trim()) {
4591 return None;
4592 }
4593 let mut out = existing.to_string();
4594 if !out.is_empty() && !out.ends_with('\n') {
4597 out.push('\n');
4598 }
4599 out.push_str(pattern.trim());
4600 out.push('\n');
4601 Some(out)
4602}
4603
4604pub fn spawn_remote_op_to(
4626 workdir: std::path::PathBuf,
4627 op: RemoteOp,
4628 args: &lattice_grammar::Args,
4629 target: RemoteTarget,
4630 prompted: Option<String>,
4631) -> Effect {
4632 let base = op.argv(args);
4633 let shown = base.join(" ");
4634 let scope_dir = workdir.clone();
4635 tokio::task::spawn(async move {
4636 let fallback = op.label(&base);
4640 let (label, result) = tokio::task::spawn_blocking(move || {
4641 let resolved = match target {
4642 RemoteTarget::Upstream => match resolve_upstream(&workdir) {
4643 Some(v) => Some(v),
4644 None => {
4645 return (
4646 op.label(&base),
4647 Err("no upstream configured for this branch — set one, \
4648 or pick a destination explicitly"
4649 .to_string()),
4650 );
4651 }
4652 },
4653 RemoteTarget::Prompted => match prompted.as_deref() {
4654 Some(v) if !v.trim().is_empty() => Some(v.to_string()),
4655 _ => return (op.label(&base), Err("no destination given".to_string())),
4656 },
4657 _ => None,
4658 };
4659 let mut argv = base;
4660 argv.extend(target.argv(resolved.as_deref()));
4661 (op.label(&argv), run_remote_op(&workdir, &argv))
4662 })
4663 .await
4664 .unwrap_or_else(|e| (fallback, Err(e.to_string())));
4665 finish_task(&scope_dir, &label, result);
4666 });
4667 Effect::Echo {
4668 level: lattice_grammar::EchoLevel::Info,
4669 text: format!("magit: {shown}"),
4670 }
4671}
4672
4673pub fn spawn_remote_op(
4674 workdir: std::path::PathBuf,
4675 op: RemoteOp,
4676 args: &lattice_grammar::Args,
4677) -> Effect {
4678 let argv = op.argv(args);
4679 let shown = argv.join(" ");
4680 let label = op.label(&argv);
4681 let scope_dir = workdir.clone();
4682 tokio::task::spawn(async move {
4683 let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
4684 .await
4685 .unwrap_or_else(|e| Err(e.to_string()));
4686 finish_task(&scope_dir, &label, result);
4697 });
4698 Effect::Echo {
4699 level: lattice_grammar::EchoLevel::Info,
4700 text: format!("magit: {}… (git {shown})", op.doing),
4704 }
4705}
4706
4707#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4715pub struct CommitOp {
4716 pub what: &'static str,
4719 pub label: &'static str,
4724 pub trailing: &'static [&'static str],
4727 pub ex_command: &'static str,
4742 pub args: &'static [&'static str],
4744 pub confirm_action: Option<&'static str>,
4749}
4750
4751impl CommitOp {
4752 pub const REVERT: Self = Self {
4753 what: "revert",
4754 label: "revert",
4755 trailing: &[],
4756 ex_command: "magit-revert",
4757 args: &["revert", "--no-edit"],
4762 confirm_action: None,
4763 };
4764 pub const CHERRY_PICK: Self = Self {
4765 what: "cherry-pick",
4766 label: "cherry-pick",
4767 trailing: &[],
4768 ex_command: "magit-cherry-pick",
4769 args: &["cherry-pick"],
4770 confirm_action: None,
4771 };
4772 pub const RESET_WORKTREE: Self = Self {
4784 what: "restore worktree",
4785 label: "restore the worktree from",
4786 trailing: &["--worktree", "--", "."],
4787 ex_command: "magit-reset-worktree",
4788 args: &["restore", "--source"],
4789 confirm_action: Some("action:magit-reset-worktree-execute"),
4792 };
4793
4794 pub const REVERT_CHANGES: Self = Self {
4802 what: "revert --no-commit",
4803 label: "apply the revert of",
4804 trailing: &[],
4805 ex_command: "magit-revert-changes",
4806 args: &["revert", "--no-commit"],
4807 confirm_action: None,
4808 };
4809 pub const CHERRY_PICK_APPLY: Self = Self {
4812 what: "cherry-pick --no-commit",
4813 label: "apply the changes of",
4814 trailing: &[],
4815 ex_command: "magit-cherry-pick-apply",
4816 args: &["cherry-pick", "--no-commit"],
4817 confirm_action: None,
4818 };
4819 pub const RESET_SOFT: Self = Self {
4820 what: "reset --soft",
4821 label: "soft-reset to",
4822 trailing: &[],
4823 ex_command: "magit-reset-soft",
4824 args: &["reset", "--soft"],
4825 confirm_action: None,
4826 };
4827 pub const RESET_MIXED: Self = Self {
4828 what: "reset --mixed",
4829 label: "reset to",
4830 trailing: &[],
4831 ex_command: "magit-reset-mixed",
4832 args: &["reset", "--mixed"],
4833 confirm_action: None,
4834 };
4835 pub const RESET_HARD: Self = Self {
4836 what: "reset --hard",
4837 label: "hard-reset to",
4838 trailing: &[],
4839 ex_command: "magit-reset-hard",
4840 args: &["reset", "--hard"],
4841 confirm_action: Some("action:magit-reset-hard-execute"),
4843 };
4844
4845 pub const RESET_KEEP: Self = Self {
4850 what: "reset --keep",
4851 label: "reset (keeping changes) to",
4852 trailing: &[],
4853 ex_command: "magit-reset-keep",
4854 args: &["reset", "--keep"],
4855 confirm_action: None,
4856 };
4857 pub const RESET_INDEX: Self = Self {
4861 what: "reset index",
4862 label: "reset the index to",
4863 trailing: &["--"],
4864 ex_command: "magit-reset-index",
4865 args: &["reset"],
4866 confirm_action: None,
4867 };
4868 pub const COMMIT_FIXUP: Self = Self {
4871 what: "commit --fixup",
4872 label: "record a fixup for",
4873 trailing: &[],
4874 ex_command: "magit-commit-fixup",
4875 args: &["commit", "--no-edit", "--fixup"],
4876 confirm_action: None,
4877 };
4878 pub const COMMIT_SQUASH: Self = Self {
4881 what: "commit --squash",
4882 label: "record a squash for",
4883 trailing: &[],
4884 ex_command: "magit-commit-squash",
4885 args: &["commit", "--no-edit", "--squash"],
4886 confirm_action: None,
4887 };
4888
4889 pub fn argv(&self, commit: &str) -> Vec<String> {
4891 let mut argv: Vec<String> = self.args.iter().map(|s| (*s).to_string()).collect();
4892 argv.push(commit.to_string());
4893 argv.extend(self.trailing.iter().map(|s| (*s).to_string()));
4898 argv
4899 }
4900}
4901
4902pub fn spawn_commit_op(op: CommitOp, workdir: std::path::PathBuf, commit: &str) -> Effect {
4908 let argv = op.argv(commit);
4909 let shown = argv.join(" ");
4910 let label = format!("{} {}", op.label, short_rev(commit));
4911 let scope_dir = workdir.clone();
4912 tokio::task::spawn(async move {
4913 let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
4914 .await
4915 .unwrap_or_else(|e| Err(e.to_string()));
4916 finish_task(&scope_dir, &label, result);
4918 });
4919 Effect::Echo {
4920 level: lattice_grammar::EchoLevel::Info,
4921 text: format!("magit: git {shown}"),
4925 }
4926}
4927
4928pub(crate) fn short_rev(rev: &str) -> &str {
4932 let rev = rev.trim();
4933 if rev.len() > 7 && rev.bytes().all(|b| b.is_ascii_hexdigit()) {
4934 &rev[..7]
4935 } else {
4936 rev
4937 }
4938}
4939
4940fn run_remote_op(workdir: &std::path::Path, args: &[String]) -> Result<String, String> {
4945 let output = std::process::Command::new("git")
4946 .args(args)
4947 .env("GIT_TERMINAL_PROMPT", "0")
4948 .env("GIT_EDITOR", "true")
4955 .env("GIT_SEQUENCE_EDITOR", "true")
4962 .current_dir(workdir)
4963 .output();
4964 match output {
4969 Ok(o) => {
4970 let out = String::from_utf8_lossy(&o.stdout);
4971 let err = String::from_utf8_lossy(&o.stderr);
4972 if o.status.success() {
4973 Ok(crate::git_report::success_report(args, &out, &err))
4974 } else {
4975 Err(crate::git_report::failure_report(
4976 &out,
4977 &err,
4978 o.status.code(),
4979 ))
4980 }
4981 }
4982 Err(e) => Err(e.to_string()),
4983 }
4984}
4985
4986#[cfg(test)]
4987mod tests {
4988 use super::*;
4989
4990 #[test]
4998 fn untrack_keeps_the_file_and_delete_never_forces() {
4999 let untrack = untrack_argv("src/main.rs");
5000 assert!(
5001 untrack.contains(&"--cached".to_string()),
5002 "untrack must leave the file on disk — without `--cached` it \
5003 would delete it, and it does not ask: {untrack:?}"
5004 );
5005 let delete = delete_argv("src/main.rs");
5006 assert!(
5007 !delete.iter().any(|a| a == "-f" || a == "--force"),
5008 "delete must let git refuse a modified file rather than \
5009 overriding that refusal: {delete:?}"
5010 );
5011 for argv in [untrack, delete, rename_argv("a", "b")] {
5014 assert!(
5015 argv.contains(&"--".to_string()),
5016 "paths must be separated from flags: {argv:?}"
5017 );
5018 }
5019 }
5020
5021 #[test]
5025 fn a_rename_prompt_carries_its_source_in_its_buffer_name() {
5026 assert_eq!(
5027 rename_source_from_prompt_buffer_name("*magit:rename:src/main.rs*").as_deref(),
5028 Some("src/main.rs")
5029 );
5030 assert!(rename_source_from_prompt_buffer_name("*magit:status*").is_none());
5033 assert!(
5034 rename_source_from_prompt_buffer_name("*magit:rename:*").is_none(),
5035 "an empty source is not a path"
5036 );
5037 }
5038
5039 #[test]
5045 fn checkout_file_names_the_revision_then_separates_the_path() {
5046 let argv = checkout_file_argv("HEAD~2", "main");
5047 assert_eq!(argv, vec!["checkout", "HEAD~2", "--", "main"]);
5048 let sep = argv.iter().position(|a| a == "--").expect("a separator");
5049 assert!(
5050 sep < argv.len() - 1 && argv.iter().position(|a| a == "HEAD~2").unwrap() < sep,
5051 "the revision goes before the separator and the path after: {argv:?}"
5052 );
5053 }
5054
5055 #[test]
5060 fn the_checkout_prompt_carries_its_path_and_only_its_own() {
5061 assert_eq!(
5062 checkout_target_from_prompt_buffer_name("*magit:checkout:src/main.rs*").as_deref(),
5063 Some("src/main.rs")
5064 );
5065 assert!(checkout_target_from_prompt_buffer_name("*magit:rename:src/main.rs*").is_none());
5066 assert!(rename_source_from_prompt_buffer_name("*magit:checkout:src/main.rs*").is_none());
5067 assert!(
5068 checkout_target_from_prompt_buffer_name("*magit:checkout:*").is_none(),
5069 "an empty target is not a path"
5070 );
5071 }
5072
5073 #[test]
5082 fn checkout_execute_declines_when_a_slot_is_missing() {
5083 use lattice_mode::Mode as _;
5084
5085 let handlers = MagitGlobalMode.action_handlers();
5086 let handler = handlers
5087 .iter()
5088 .find(|c| c.action_name == "action:magit-global-file-checkout-execute")
5089 .expect("contributed")
5090 .handler
5091 .clone();
5092 let services = lattice_mode::ServiceRegistry::new();
5093 let events = lattice_runtime::EventBus::new();
5094
5095 for args in [
5096 lattice_grammar::Args::None,
5097 lattice_grammar::Args::List(vec![lattice_grammar::ArgValue::String(
5100 "HEAD".to_string(),
5101 )]),
5102 ] {
5103 let ctx = ActionContext {
5104 buffer_id: lattice_protocol::ids::BufferId::new(1),
5105 cursor: lattice_protocol::position::Position::new(0, 0),
5106 selection: None,
5107 services: &services,
5108 events: &events,
5109 prompt_value: None,
5110 args,
5111 buffer_locals: None,
5112 };
5113 assert!(
5114 handler(&ctx).is_none(),
5115 "a half-carried confirm must run no git command"
5116 );
5117 }
5118 }
5119
5120 #[test]
5128 fn no_prompted_operation_can_open_an_editor() {
5129 assert_eq!(
5130 merge_argv("feature/x"),
5131 vec!["merge", "--no-edit", "feature/x"],
5132 "merge must never be able to open an editor"
5133 );
5134 assert_eq!(tag_argv("v1.2.0"), vec!["tag", "v1.2.0"]);
5137 assert_eq!(init_argv("/tmp/x"), vec!["init", "/tmp/x"]);
5138 for argv in [merge_argv("b"), tag_argv("t"), init_argv("d")] {
5139 assert!(
5140 !argv.iter().any(|a| a == "--edit" || a == "-e"),
5141 "no prompted operation may request an editor: {argv:?}"
5142 );
5143 }
5144 }
5145
5146 #[test]
5152 fn every_subtree_op_carries_its_prefix() {
5153 for (op, line) in [
5154 (SubtreeOp::ADD, "vendor/lib https://host/lib.git main"),
5155 (SubtreeOp::MERGE, "vendor/lib main"),
5156 (SubtreeOp::PULL, "vendor/lib https://host/lib.git main"),
5157 (SubtreeOp::PUSH, "vendor/lib https://host/lib.git main"),
5158 (SubtreeOp::SPLIT, "vendor/lib"),
5159 ] {
5160 let argv = subtree_argv(op, line).expect("well-formed");
5161 assert_eq!(argv[0], "subtree");
5162 assert_eq!(argv[1], op.sub);
5163 assert_eq!(
5164 argv[2], "--prefix=vendor/lib",
5165 "{} lost its prefix: {argv:?}",
5166 op.sub
5167 );
5168 }
5169 }
5170
5171 #[test]
5178 fn the_wrong_argument_count_is_refused() {
5179 assert_eq!(
5180 subtree_argv(SubtreeOp::ADD, "vendor/lib https://host/lib.git"),
5181 None
5182 );
5183 assert_eq!(subtree_argv(SubtreeOp::ADD, "vendor/lib"), None);
5184 assert_eq!(subtree_argv(SubtreeOp::SPLIT, "vendor/lib extra"), None);
5185 assert_eq!(subtree_argv(SubtreeOp::MERGE, ""), None);
5186 }
5187
5188 #[test]
5192 fn squash_is_accepted_only_where_it_means_something() {
5193 let added = subtree_argv(
5194 SubtreeOp::ADD,
5195 "vendor/lib https://host/lib.git main --squash",
5196 )
5197 .expect("valid");
5198 assert!(added.contains(&"--squash".to_string()), "{added:?}");
5199 assert_eq!(
5202 subtree_argv(
5203 SubtreeOp::PUSH,
5204 "vendor/lib https://host/lib.git main --squash"
5205 ),
5206 None,
5207 "push has no --squash; the line must not be silently truncated"
5208 );
5209 }
5210
5211 #[test]
5218 fn three_way_apply_is_opt_in_and_reachable_by_both_spellings() {
5219 assert_eq!(
5220 am_argv("0001.patch", false),
5221 Some(vec!["am".to_string(), "0001.patch".to_string()]),
5222 "no flag unless asked"
5223 );
5224 assert!(am_wants_three_way("0001.patch -3"));
5225 assert!(am_wants_three_way("--3way 0001.patch"));
5226 assert!(!am_wants_three_way("0001.patch"));
5227 let three = am_argv("0001.patch", true).expect("valid");
5228 assert_eq!(three[1], "--3way");
5229 }
5230
5231 #[test]
5235 fn flag_words_are_not_treated_as_patch_files() {
5236 assert_eq!(
5237 am_argv("--3way a.patch b.patch", true),
5238 Some(vec![
5239 "am".to_string(),
5240 "--3way".to_string(),
5241 "a.patch".to_string(),
5242 "b.patch".to_string(),
5243 ])
5244 );
5245 assert_eq!(am_argv("--3way", true), None, "flags alone are not patches");
5246 assert_eq!(am_argv("", false), None);
5247 }
5248
5249 #[test]
5254 fn format_patch_names_its_output_directory() {
5255 let argv = format_patch_argv("@{upstream}..HEAD", Some("/repo")).expect("valid");
5256 assert_eq!(
5257 argv,
5258 vec!["format-patch", "-o", "/repo", "@{upstream}..HEAD"]
5259 );
5260 assert_eq!(
5261 format_patch_argv("HEAD~3..HEAD", None).expect("valid"),
5262 vec!["format-patch", "HEAD~3..HEAD"]
5263 );
5264 assert_eq!(
5265 format_patch_argv(" ", Some("/repo")),
5266 None,
5267 "a range is required"
5268 );
5269 }
5270
5271 #[test]
5276 fn note_merge_argv_defaults_to_manual_and_accepts_a_strategy() {
5277 assert_eq!(
5278 note_merge_argv("refs/notes/other"),
5279 Some(vec![
5280 "notes".to_string(),
5281 "merge".to_string(),
5282 "--strategy=manual".to_string(),
5283 "refs/notes/other".to_string(),
5284 ]),
5285 "no strategy given ⇒ git's default, stated explicitly"
5286 );
5287 assert_eq!(
5288 note_merge_argv("refs/notes/other theirs")
5289 .expect("valid")
5290 .get(2)
5291 .map(String::as_str),
5292 Some("--strategy=theirs")
5293 );
5294 }
5295
5296 #[test]
5303 fn a_misspelled_strategy_is_refused_rather_than_defaulted() {
5304 for bad in ["ref our", "ref Theirs", "ref cat-sort-uniq", "ref x y"] {
5305 assert_eq!(
5306 note_merge_argv(bad),
5307 None,
5308 "{bad:?} must be refused, not merged with the default"
5309 );
5310 }
5311 assert_eq!(note_merge_argv(""), None, "no ref at all");
5312 assert_eq!(note_merge_argv(" "), None);
5313 }
5314
5315 #[test]
5322 fn the_default_destination_is_the_name_git_would_have_picked() {
5323 for (url, expected) in [
5324 ("https://github.com/owner/repo.git", "repo"),
5325 ("https://github.com/owner/repo", "repo"),
5326 ("https://github.com/owner/repo/", "repo"),
5327 ("git@github.com:owner/repo.git", "repo"),
5328 ("git@github.com:repo.git", "repo"),
5329 ("ssh://git@host:22/owner/repo.git", "repo"),
5330 ("/srv/git/bare-repo.git", "bare-repo"),
5331 (" https://host/owner/repo.git ", "repo"),
5332 ] {
5333 assert_eq!(
5334 default_clone_dest(url),
5335 expected,
5336 "the directory `git clone {url}` would create"
5337 );
5338 }
5339 }
5340
5341 #[test]
5345 fn a_url_with_no_usable_last_segment_yields_nothing() {
5346 for url in ["", " ", "/", "https://", ".git"] {
5347 assert_eq!(
5348 default_clone_dest(url),
5349 "",
5350 "{url:?} names no directory, so none may be guessed"
5351 );
5352 }
5353 }
5354
5355 #[test]
5359 fn clone_argv_separates_operands_from_options() {
5360 assert_eq!(
5361 clone_argv("https://host/o/r.git", "/tmp/r"),
5362 vec!["clone", "--", "https://host/o/r.git", "/tmp/r"]
5363 );
5364 let hostile = clone_argv("https://host/o/r.git", "--upload-pack=evil");
5365 assert_eq!(
5366 hostile.iter().position(|a| a == "--"),
5367 Some(1),
5368 "the separator must precede both operands: {hostile:?}"
5369 );
5370 }
5371
5372 #[test]
5377 fn appending_to_gitignore_is_idempotent_and_newline_safe() {
5378 use super::gitignore_append as append;
5379
5380 assert_eq!(
5383 append("target", "*.log").as_deref(),
5384 Some("target\n*.log\n"),
5385 "a missing trailing newline must not fuse two patterns"
5386 );
5387 assert_eq!(append("", "*.log").as_deref(), Some("*.log\n"));
5388 assert_eq!(
5389 append("target\n", "*.log").as_deref(),
5390 Some("target\n*.log\n")
5391 );
5392 assert!(
5395 append("target\n*.log\n", "*.log").is_none(),
5396 "an already-ignored pattern is skipped"
5397 );
5398 assert!(
5399 append("target\n", " target ").is_none(),
5400 "compared as trimmed whole lines"
5401 );
5402 }
5403
5404 #[test]
5410 fn an_empty_prompt_cancels_rather_than_running_the_operation() {
5411 use lattice_mode::Mode as _;
5412
5413 let handlers = MagitGlobalMode.action_handlers();
5414 for (i, (_, action)) in PROMPTED_OPS.iter().enumerate() {
5417 let blank = if i % 2 == 0 { " " } else { "" };
5418 let handler = handlers
5419 .iter()
5420 .find(|c| c.action_name == *action)
5421 .unwrap_or_else(|| panic!("`{action}` is contributed"))
5422 .handler
5423 .clone();
5424 let services = lattice_mode::ServiceRegistry::new();
5425 let events = lattice_runtime::EventBus::new();
5426 let ctx = ActionContext {
5427 buffer_id: lattice_protocol::ids::BufferId::new(1),
5428 cursor: lattice_protocol::position::Position::new(0, 0),
5429 selection: None,
5430 services: &services,
5431 events: &events,
5432 prompt_value: Some(blank),
5433 args: lattice_grammar::Args::None,
5434 buffer_locals: None,
5435 };
5436 assert!(
5437 handler(&ctx).is_none(),
5438 "`{action}` must decline a blank submission rather than run \
5439 git with an empty argument"
5440 );
5441 }
5442 }
5443
5444 #[test]
5447 fn each_prompt_row_targets_a_finish_action_that_exists() {
5448 use lattice_mode::Mode as _;
5449
5450 let handlers = MagitGlobalMode.action_handlers();
5451 let names: Vec<&str> = handlers.iter().map(|c| c.action_name).collect();
5452 let services = lattice_mode::ServiceRegistry::new();
5453 let events = lattice_runtime::EventBus::new();
5454
5455 for (action, expected_finish) in PROMPTED_OPS {
5456 let handler = handlers
5457 .iter()
5458 .find(|c| c.action_name == *action)
5459 .unwrap_or_else(|| panic!("`{action}` is contributed"))
5460 .handler
5461 .clone();
5462 let ctx = ActionContext {
5463 buffer_id: lattice_protocol::ids::BufferId::new(1),
5464 cursor: lattice_protocol::position::Position::new(0, 0),
5465 selection: None,
5466 services: &services,
5467 events: &events,
5468 prompt_value: None,
5469 args: lattice_grammar::Args::None,
5470 buffer_locals: None,
5471 };
5472 match handler(&ctx) {
5473 Some(Effect::OpenPrompt {
5474 on_submit_action, ..
5475 }) => {
5476 assert_eq!(
5477 &on_submit_action.as_str(),
5478 expected_finish,
5479 "`{action}` must submit to its own finish half"
5480 );
5481 assert!(
5482 names.contains(&on_submit_action.as_str()),
5483 "`{action}` opens a prompt submitting to \
5484 `{on_submit_action}`, which no mode contributes — the \
5485 prompt would accept input and do nothing with it"
5486 );
5487 }
5488 other => panic!("`{action}` should open a prompt, got {other:?}"),
5489 }
5490 }
5491 }
5492
5493 #[test]
5502 fn each_branch_row_opens_the_picker_with_a_registered_command() {
5503 use lattice_mode::Mode as _;
5504
5505 let handlers = MagitGlobalMode.action_handlers();
5506 let services = lattice_mode::ServiceRegistry::new();
5507 let events = lattice_runtime::EventBus::new();
5508
5509 let mut registry = lattice_grammar::CommandRegistry::new();
5511 crate::register_action_commands_for_test(&mut registry);
5512 crate::register_ex_commands_for_test(&mut registry);
5513
5514 for (action, ex_command) in PICKED_BRANCH_OPS {
5515 let handler = handlers
5516 .iter()
5517 .find(|c| c.action_name == *action)
5518 .unwrap_or_else(|| panic!("`{action}` is contributed"))
5519 .handler
5520 .clone();
5521 let ctx = ActionContext {
5522 buffer_id: lattice_protocol::ids::BufferId::new(1),
5523 cursor: lattice_protocol::position::Position::new(0, 0),
5524 selection: None,
5525 services: &services,
5526 events: &events,
5527 prompt_value: None,
5528 args: lattice_grammar::Args::None,
5529 buffer_locals: None,
5530 };
5531 match handler(&ctx) {
5532 Some(Effect::OpenPicker { source, args, .. }) => {
5533 assert_eq!(
5534 source,
5535 crate::picker_sources::BRANCH_PICK_SOURCE,
5536 "`{action}` must open the branch picker"
5537 );
5538 assert_eq!(
5539 args.first().map(String::as_str),
5540 Some(*ex_command),
5541 "`{action}` must hand the picked branch to `{ex_command}`"
5542 );
5543 assert!(
5544 registry.id_by_name(ex_command).is_some(),
5545 "`{action}` picks a branch and runs `{ex_command}`, which is \
5546 not a registered ex-command — picking would do nothing"
5547 );
5548 }
5549 other => panic!("`{action}` should open the branch picker, got {other:?}"),
5550 }
5551 }
5552 }
5553
5554 #[test]
5578 fn the_branch_revision_row_is_not_the_local_branch_row() {
5579 use lattice_mode::Mode as _;
5580
5581 let handlers = MagitGlobalMode.action_handlers();
5582 let services = lattice_mode::ServiceRegistry::new();
5583 let events = lattice_runtime::EventBus::new();
5584 let source_of = |action: &str| -> String {
5585 let handler = handlers
5586 .iter()
5587 .find(|c| c.action_name == action)
5588 .unwrap_or_else(|| panic!("`{action}` is contributed"))
5589 .handler
5590 .clone();
5591 let ctx = ActionContext {
5592 buffer_id: lattice_protocol::ids::BufferId::new(1),
5593 cursor: lattice_protocol::position::Position::new(0, 0),
5594 selection: None,
5595 services: &services,
5596 events: &events,
5597 prompt_value: None,
5598 args: lattice_grammar::Args::None,
5599 buffer_locals: None,
5600 };
5601 match handler(&ctx) {
5602 Some(Effect::OpenPicker { source, .. }) => source,
5603 other => panic!("`{action}` should open a picker, got {other:?}"),
5604 }
5605 };
5606
5607 let rev = source_of("action:magit-global-branch-checkout-rev");
5608 let local = source_of("action:magit-global-branch-checkout");
5609 assert_eq!(
5610 rev,
5611 crate::picker_sources::REVISION_PICK_SOURCE,
5612 "`b` is magit's branch/revision row — it must offer tags, \
5613 remote-tracking refs and commits, not just local branches, \
5614 or it is `l` with a different key"
5615 );
5616 assert_eq!(
5617 local,
5618 crate::picker_sources::BRANCH_CHECKOUT_SOURCE,
5619 "`l` is the local-branch row and stays local"
5620 );
5621 assert_ne!(
5622 rev, local,
5623 "`b` and `l` listing the same set makes one of the two rows dead"
5624 );
5625 }
5626
5627 #[test]
5635 fn each_non_branch_row_opens_the_right_picker() {
5636 use lattice_mode::Mode as _;
5637
5638 let handlers = MagitGlobalMode.action_handlers();
5639 let services = lattice_mode::ServiceRegistry::new();
5640 let events = lattice_runtime::EventBus::new();
5641 let mut registry = lattice_grammar::CommandRegistry::new();
5642 crate::register_action_commands_for_test(&mut registry);
5643 crate::register_ex_commands_for_test(&mut registry);
5644
5645 for (action, expected_source, ex_command) in PICKED_OTHER_OPS {
5646 let handler = handlers
5647 .iter()
5648 .find(|c| c.action_name == *action)
5649 .unwrap_or_else(|| panic!("`{action}` is contributed"))
5650 .handler
5651 .clone();
5652 let ctx = ActionContext {
5653 buffer_id: lattice_protocol::ids::BufferId::new(1),
5654 cursor: lattice_protocol::position::Position::new(0, 0),
5655 selection: None,
5656 services: &services,
5657 events: &events,
5658 prompt_value: None,
5659 args: lattice_grammar::Args::None,
5660 buffer_locals: None,
5661 };
5662 match handler(&ctx) {
5663 Some(Effect::OpenPicker { source, args, .. }) => {
5664 assert_eq!(
5665 source, *expected_source,
5666 "`{action}` must list the nouns it operates on"
5667 );
5668 assert_eq!(args.first().map(String::as_str), Some(*ex_command));
5669 assert!(
5670 registry.id_by_name(ex_command).is_some(),
5671 "`{action}` runs `{ex_command}`, which is not registered"
5672 );
5673 }
5674 other => panic!("`{action}` should open a picker, got {other:?}"),
5675 }
5676 }
5677 }
5678
5679 #[test]
5693 fn the_file_revision_rows_open_the_commit_picker_with_a_placeholder() {
5694 use lattice_mode::Mode as _;
5695
5696 let handlers = MagitGlobalMode.action_handlers();
5697 let services = lattice_mode::ServiceRegistry::new();
5698 let events = lattice_runtime::EventBus::new();
5699
5700 for (action, ex_command) in [
5701 ("action:magit-global-file-at-revision", "magit-find-file"),
5702 ("action:magit-global-file-checkout", "magit-file-checkout"),
5703 ] {
5704 let handler = handlers
5705 .iter()
5706 .find(|c| c.action_name == action)
5707 .unwrap_or_else(|| panic!("`{action}` is contributed"))
5708 .handler
5709 .clone();
5710 let args = lattice_grammar::Args::List(vec![lattice_grammar::ArgValue::String(
5713 "src/main.rs".to_string(),
5714 )]);
5715 let ctx = ActionContext {
5716 buffer_id: lattice_protocol::ids::BufferId::new(1),
5717 cursor: lattice_protocol::position::Position::new(0, 0),
5718 selection: None,
5719 services: &services,
5720 events: &events,
5721 prompt_value: None,
5722 args,
5723 buffer_locals: None,
5724 };
5725 match handler(&ctx) {
5726 Some(Effect::OpenPicker { source, args, .. }) => {
5727 assert_eq!(
5728 source,
5729 crate::picker_sources::REVISION_PICK_SOURCE,
5730 "`{action}` picks a REVISION — a branch or tag as much \
5731 as a commit. The commit-only picker cannot reach a \
5732 file that lives on another branch, because it is not \
5733 in this branch's history at all."
5734 );
5735 let line = args.first().map(String::as_str).unwrap_or("");
5736 assert!(
5737 line.starts_with(ex_command),
5738 "`{action}` must run `{ex_command}`, got {line:?}"
5739 );
5740 assert!(
5741 line.contains("{}"),
5742 "`{action}` builds `{line}` — without a `{{}}` the \
5743 revision is appended after the path, which opens a \
5744 file named after a sha"
5745 );
5746 assert!(
5747 line.contains("src/main.rs"),
5748 "`{action}` must carry the target path: {line:?}"
5749 );
5750 }
5751 other => panic!("`{action}` should open the commit picker, got {other:?}"),
5752 }
5753 }
5754 }
5755
5756 #[test]
5764 fn stage_all_stages_tracked_modifications_not_untracked_files() {
5765 let argv = RemoteOp::STAGE_ALL.argv(&lattice_grammar::Args::None);
5766 assert_eq!(argv, vec!["add", "--update"]);
5767 assert!(
5768 !argv.iter().any(|a| a == "--all" || a == "-A"),
5769 "an untracked sweep must be opt-in, never the default: {argv:?}"
5770 );
5771 }
5772
5773 #[test]
5777 fn unstage_all_resets_the_index_and_leaves_the_working_tree() {
5778 let argv = RemoteOp::UNSTAGE_ALL.argv(&lattice_grammar::Args::None);
5779 assert_eq!(argv, vec!["reset", "--quiet"]);
5780 assert!(
5781 !argv.iter().any(|a| a == "--hard" || a == "--merge"),
5782 "a reset that touches the working tree would need MG.12's \
5783 confirm, and would not belong on an unprompted key: {argv:?}"
5784 );
5785 }
5786
5787 #[test]
5791 fn the_repo_wide_index_ops_take_no_arguments() {
5792 assert!(RemoteOp::STAGE_ALL.arg_specs().is_empty());
5793 assert!(RemoteOp::UNSTAGE_ALL.arg_specs().is_empty());
5794 }
5795
5796 #[test]
5798 fn a_commit_op_appends_the_commit_to_its_argv() {
5799 assert_eq!(
5800 CommitOp::CHERRY_PICK.argv("a1b2c3d"),
5801 vec!["cherry-pick", "a1b2c3d"]
5802 );
5803 assert_eq!(
5804 CommitOp::RESET_HARD.argv("a1b2c3d"),
5805 vec!["reset", "--hard", "a1b2c3d"]
5806 );
5807 }
5808
5809 #[test]
5813 fn only_the_destructive_reset_asks_first() {
5814 assert!(CommitOp::RESET_HARD.confirm_action.is_some());
5815 assert!(CommitOp::RESET_SOFT.confirm_action.is_none());
5816 assert!(CommitOp::RESET_MIXED.confirm_action.is_none());
5817 assert!(CommitOp::REVERT.confirm_action.is_none());
5818 assert!(CommitOp::CHERRY_PICK.confirm_action.is_none());
5819 }
5820
5821 #[test]
5825 fn revert_does_not_open_an_editor() {
5826 assert!(
5827 CommitOp::REVERT.args.contains(&"--no-edit"),
5828 "revert must not block on $EDITOR"
5829 );
5830 }
5831
5832 #[test]
5837 fn the_hard_reset_confirm_targets_its_execute_half() {
5838 assert_eq!(
5839 CommitOp::RESET_HARD.confirm_action,
5840 Some("action:magit-reset-hard-execute")
5841 );
5842 }
5843
5844 #[test]
5846 fn argv_appends_exactly_the_enabled_flags_in_schema_order() {
5847 use lattice_grammar::{ArgValue, Args};
5848 let op = RemoteOp::PUSH;
5849 assert_eq!(op.argv(&Args::None), vec!["push"], "no args = bare push");
5850 assert_eq!(
5851 op.argv(&Args::List(vec![
5852 ArgValue::Bool(false),
5853 ArgValue::Bool(false)
5854 ])),
5855 vec!["push"]
5856 );
5857 assert_eq!(
5858 op.argv(&Args::List(vec![
5859 ArgValue::Bool(true),
5860 ArgValue::Bool(false)
5861 ])),
5862 vec!["push", "--force-with-lease"]
5863 );
5864 assert_eq!(
5865 op.argv(&Args::List(vec![
5866 ArgValue::Bool(true),
5867 ArgValue::Bool(true)
5868 ])),
5869 vec!["push", "--force-with-lease", "--set-upstream"]
5870 );
5871 }
5872
5873 #[test]
5877 fn an_argless_invocation_runs_the_unflagged_command() {
5878 use lattice_grammar::Args;
5879 for op in [
5880 RemoteOp::PULL,
5881 RemoteOp::PUSH,
5882 RemoteOp::FETCH,
5883 RemoteOp::STASH,
5884 ] {
5885 assert_eq!(
5886 op.argv(&Args::None),
5887 op.args.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
5888 "`{}` with no args must be the bare command",
5889 op.what
5890 );
5891 }
5892 }
5893
5894 #[test]
5898 fn the_preview_string_matches_the_argv_that_will_run() {
5899 use lattice_grammar::{ArgValue, Args};
5900 let op = RemoteOp::FETCH;
5901 for (all, prune) in [(false, false), (true, false), (false, true), (true, true)] {
5902 let args = Args::List(vec![ArgValue::Bool(all), ArgValue::Bool(prune)]);
5903 let preview = op.preview(&|name| match name {
5904 "all" => Some(all.to_string()),
5905 "prune" => Some(prune.to_string()),
5906 _ => None,
5907 });
5908 let from_argv = format!("git {}", op.argv(&args).join(" "));
5909 assert_eq!(preview, from_argv, "preview must render what runs");
5910 }
5911 }
5912
5913 #[test]
5917 fn force_push_uses_force_with_lease() {
5918 let force = RemoteOp::PUSH.flags[0];
5919 assert_eq!(force.arg, "--force-with-lease");
5920 assert!(
5921 !RemoteOp::PUSH.flags.iter().any(|f| f.arg == "--force"),
5922 "bare --force must not be offered"
5923 );
5924 }
5925
5926 #[test]
5931 fn a_value_argument_contributes_only_when_set() {
5932 use lattice_grammar::{ArgValue, Args};
5933 let op = RemoteOp::STASH;
5934 assert_eq!(
5936 op.argv(&Args::List(vec![
5937 ArgValue::Bool(false),
5938 ArgValue::String(String::new())
5939 ])),
5940 vec!["stash", "push"],
5941 "an empty message must not reach git at all"
5942 );
5943 assert_eq!(
5944 op.argv(&Args::List(vec![
5945 ArgValue::Bool(false),
5946 ArgValue::String("wip: parser".into())
5947 ])),
5948 vec!["stash", "push", "-m", "wip: parser"],
5949 "the message is one argv entry, spaces and all"
5950 );
5951 assert_eq!(
5952 op.argv(&Args::List(vec![
5953 ArgValue::Bool(true),
5954 ArgValue::String("wip".into())
5955 ])),
5956 vec!["stash", "push", "--include-untracked", "-m", "wip"],
5957 "a flag and a value compose in schema order"
5958 );
5959 }
5960
5961 #[test]
5965 fn a_multi_word_message_stays_a_single_argv_entry() {
5966 use lattice_grammar::{ArgValue, Args};
5967 let argv = RemoteOp::STASH.argv(&Args::List(vec![
5968 ArgValue::Bool(false),
5969 ArgValue::String("refactor the parser and fix tests".into()),
5970 ]));
5971 assert_eq!(argv.last().unwrap(), "refactor the parser and fix tests");
5972 assert_eq!(argv.len(), 4, "push + -m + the message, not one word each");
5973 }
5974
5975 #[test]
5978 fn the_preview_quotes_a_value_argument() {
5979 let preview = RemoteOp::STASH.preview(&|name| match name {
5980 "message" => Some("wip: two words".to_string()),
5981 _ => None,
5982 });
5983 assert_eq!(preview, r#"git stash push -m "wip: two words""#);
5984 }
5985
5986 #[test]
5990 fn arg_specs_mirror_the_flag_table_positionally() {
5991 for op in [
5992 RemoteOp::PULL,
5993 RemoteOp::PUSH,
5994 RemoteOp::FETCH,
5995 RemoteOp::STASH,
5996 ] {
5997 let specs = op.arg_specs();
5998 assert_eq!(specs.len(), op.flags.len(), "`{}` schema length", op.what);
5999 for (spec, flag) in specs.iter().zip(op.flags) {
6000 assert_eq!(spec.name.as_ref(), flag.name);
6001 let expected = match flag.kind {
6002 RemoteArgKind::Flag => lattice_grammar::ArgKind::Bool,
6003 RemoteArgKind::Value { .. } | RemoteArgKind::ValueJoined { .. } => {
6004 lattice_grammar::ArgKind::String
6005 }
6006 };
6007 assert_eq!(spec.kind, expected, "`{}` slot `{}`", op.what, flag.name);
6008 }
6009 }
6010 }
6011
6012 #[test]
6013 fn base_branch_from_prompt_buffer_name_extracts_the_stashed_base() {
6014 assert_eq!(
6015 base_branch_from_prompt_buffer_name("*magit:branch-create-from:feature/foo*"),
6016 Some("feature/foo".to_string())
6017 );
6018 }
6019
6020 #[test]
6021 fn base_branch_from_prompt_buffer_name_rejects_an_empty_base() {
6022 assert_eq!(
6023 base_branch_from_prompt_buffer_name("*magit:branch-create-from:*"),
6024 None
6025 );
6026 }
6027
6028 #[test]
6029 fn base_branch_from_prompt_buffer_name_rejects_unrelated_names() {
6030 assert_eq!(base_branch_from_prompt_buffer_name("*magit:status*"), None);
6031 assert_eq!(base_branch_from_prompt_buffer_name("*prompt*"), None);
6032 }
6033}
6034
6035#[cfg(test)]
6036mod task_labels {
6037 use super::{CommitOp, RemoteOp, short_rev};
6038
6039 fn argv(words: &[&str]) -> Vec<String> {
6040 words.iter().map(|w| w.to_string()).collect()
6041 }
6042
6043 #[test]
6045 fn a_push_label_names_its_destination_and_its_kind() {
6046 let p = RemoteOp::PUSH;
6047 assert_eq!(p.label(&argv(&["push"])), "push");
6048 assert_eq!(
6049 p.label(&argv(&["push", "origin", "main"])),
6050 "push to origin/main"
6051 );
6052 assert_eq!(
6053 p.label(&argv(&["push", "--force-with-lease", "origin", "main"])),
6054 "force-push to origin/main"
6055 );
6056 assert_eq!(p.label(&argv(&["push", "--tags"])), "push tags");
6057 assert_eq!(p.label(&argv(&["push", "--dry-run"])), "push (dry run)");
6058 assert_eq!(
6059 p.label(&argv(&["push", "origin", "HEAD:refs/for/main"])),
6060 "push to origin HEAD:refs/for/main",
6061 "a refspec is shown as typed, not joined into a path"
6062 );
6063 }
6064
6065 #[test]
6066 fn fetch_and_pull_labels_name_their_source() {
6067 assert_eq!(
6068 RemoteOp::FETCH.label(&argv(&["fetch", "--all", "--prune"])),
6069 "fetch all remotes"
6070 );
6071 assert_eq!(
6072 RemoteOp::FETCH.label(&argv(&["fetch", "upstream"])),
6073 "fetch upstream"
6074 );
6075 assert_eq!(
6076 RemoteOp::PULL.label(&argv(&["pull", "--rebase"])),
6077 "pull and rebase"
6078 );
6079 assert_eq!(
6080 RemoteOp::PULL.label(&argv(&["pull", "--ff-only", "origin", "dev"])),
6081 "pull origin/dev"
6082 );
6083 }
6084
6085 #[test]
6088 fn a_stash_label_carries_its_message() {
6089 assert_eq!(
6090 RemoteOp::STASH.label(&argv(&["stash", "push"])),
6091 "stash changes"
6092 );
6093 assert_eq!(
6094 RemoteOp::STASH.label(&argv(&["stash", "push", "-u", "-m", "wip"])),
6095 "stash changes \u{201c}wip\u{201d}"
6096 );
6097 }
6098
6099 #[test]
6103 fn remote_op_labels_are_plain_and_distinct() {
6104 let ops = [
6105 RemoteOp::PULL,
6106 RemoteOp::PUSH,
6107 RemoteOp::FETCH,
6108 RemoteOp::CHERRY_PICK_CONTINUE,
6109 RemoteOp::CHERRY_PICK_SKIP,
6110 RemoteOp::CHERRY_PICK_ABORT,
6111 RemoteOp::REVERT_CONTINUE,
6112 RemoteOp::REVERT_SKIP,
6113 RemoteOp::REVERT_ABORT,
6114 RemoteOp::MERGE_CONTINUE,
6115 RemoteOp::MERGE_ABORT,
6116 RemoteOp::REBASE_CONTINUE,
6117 RemoteOp::REBASE_SKIP,
6118 RemoteOp::REBASE_ABORT,
6119 RemoteOp::NOTES_PRUNE,
6120 RemoteOp::NOTES_MERGE_COMMIT,
6121 RemoteOp::NOTES_MERGE_ABORT,
6122 RemoteOp::AM_CONTINUE,
6123 RemoteOp::AM_SKIP,
6124 RemoteOp::AM_ABORT,
6125 RemoteOp::STAGE_ALL,
6126 RemoteOp::UNSTAGE_ALL,
6127 RemoteOp::STASH_KEEP_INDEX,
6128 RemoteOp::STASH_STAGED,
6129 RemoteOp::STASH,
6130 ];
6131 let mut seen = std::collections::HashSet::new();
6132 for op in ops {
6133 assert!(!op.what.contains("--"), "`{}` reads like a flag", op.what);
6134 assert!(!op.doing.contains("--"), "`{}` reads like a flag", op.doing);
6135 assert!(seen.insert(op.what), "`{}` is shared", op.what);
6136 }
6137 }
6138
6139 #[test]
6140 fn commit_op_labels_are_plain_and_distinct() {
6141 let ops = [
6142 CommitOp::REVERT,
6143 CommitOp::CHERRY_PICK,
6144 CommitOp::RESET_WORKTREE,
6145 CommitOp::REVERT_CHANGES,
6146 CommitOp::CHERRY_PICK_APPLY,
6147 CommitOp::RESET_SOFT,
6148 CommitOp::RESET_MIXED,
6149 CommitOp::RESET_HARD,
6150 CommitOp::RESET_KEEP,
6151 CommitOp::RESET_INDEX,
6152 CommitOp::COMMIT_FIXUP,
6153 CommitOp::COMMIT_SQUASH,
6154 ];
6155 let mut seen = std::collections::HashSet::new();
6156 for op in ops {
6157 assert!(!op.label.contains("--"), "`{}` reads like a flag", op.label);
6158 assert!(seen.insert(op.label), "`{}` is shared", op.label);
6159 }
6160 }
6161
6162 #[test]
6165 fn merge_flavours_read_differently() {
6166 use super::merge_label;
6167 let labels = [
6168 merge_label("merge", "feature"),
6169 merge_label("no-commit", "feature"),
6170 merge_label("squash", "feature"),
6171 ];
6172 assert!(labels.iter().all(|l| l.contains("feature")), "{labels:?}");
6173 assert_eq!(
6174 labels
6175 .iter()
6176 .collect::<std::collections::HashSet<_>>()
6177 .len(),
6178 3
6179 );
6180 }
6181
6182 #[test]
6183 fn patch_labels_name_what_they_touch() {
6184 use super::{am_label, format_patch_label};
6185 assert_eq!(
6186 am_label(&argv(&["am", "--3way", "0001-fix.patch"])),
6187 "apply patch 0001-fix.patch"
6188 );
6189 assert_eq!(
6190 am_label(&argv(&["am", "a.patch", "b.patch"])),
6191 "apply 2 patches"
6192 );
6193 assert_eq!(
6194 format_patch_label(&argv(&["format-patch", "-o", "/r", "main..feature"])),
6195 "write patches for main..feature"
6196 );
6197 }
6198
6199 #[test]
6200 fn a_one_commit_rebase_names_the_commit_and_the_verb() {
6201 use super::rebase_verb_label;
6202 let sha = "3f2a1c09b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3";
6203 assert_eq!(rebase_verb_label("edit", sha), "rebase to edit 3f2a1c0");
6204 assert_eq!(rebase_verb_label("drop", sha), "drop 3f2a1c0 from history");
6205 }
6206
6207 #[test]
6210 fn a_bisect_summary_names_the_commit() {
6211 use super::bisect_summary;
6212 use lattice_vcs::BisectStep;
6213 let sha = "4408d81987933a1275554215cacb0eb9b26df0ce".to_string();
6214 assert_eq!(
6215 bisect_summary(&BisectStep::Found {
6216 commit: sha.clone(),
6217 subject: "c5".into()
6218 }),
6219 "first bad commit is 4408d81 c5"
6220 );
6221 assert_eq!(
6222 bisect_summary(&BisectStep::Testing {
6223 commit: sha,
6224 subject: "c6".into(),
6225 revisions_left: Some(1),
6226 steps: Some(1),
6227 }),
6228 "now testing 4408d81 c6, 1 left (about 1 more)"
6229 );
6230 }
6231
6232 #[test]
6233 fn bisect_steps_read_as_steps() {
6234 use super::bisect_label;
6235 assert_eq!(bisect_label("good"), "mark the bisect commit good");
6236 assert_eq!(bisect_label("start"), "start bisecting");
6237 assert_eq!(bisect_label("reset"), "end the bisect");
6238 }
6239
6240 #[test]
6241 fn a_full_sha_is_shortened_and_a_ref_is_not() {
6242 assert_eq!(
6243 short_rev("3f2a1c09b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3"),
6244 "3f2a1c0"
6245 );
6246 assert_eq!(
6247 short_rev("feature/very-long-name"),
6248 "feature/very-long-name"
6249 );
6250 assert_eq!(short_rev("HEAD~2"), "HEAD~2");
6251 }
6252}
6253
6254#[cfg(test)]
6255mod run_remote_op_reports {
6256 use super::{resolve_upstream, run_remote_op};
6257 use std::path::Path;
6258 use std::process::Command;
6259
6260 fn git(dir: &Path, args: &[&str]) {
6261 let ok = Command::new("git")
6262 .args(args)
6263 .current_dir(dir)
6264 .env("GIT_AUTHOR_NAME", "t")
6265 .env("GIT_AUTHOR_EMAIL", "t@t")
6266 .env("GIT_COMMITTER_NAME", "t")
6267 .env("GIT_COMMITTER_EMAIL", "t@t")
6268 .status()
6269 .map(|s| s.success())
6270 .unwrap_or(false);
6271 assert!(ok, "git {args:?}");
6272 }
6273
6274 fn argv(words: &[&str]) -> Vec<String> {
6275 words.iter().map(|w| w.to_string()).collect()
6276 }
6277
6278 fn conflicted() -> tempfile::TempDir {
6280 let dir = tempfile::tempdir().expect("temp dir");
6281 let d = dir.path();
6282 git(d, &["init", "-q", "-b", "main"]);
6283 git(d, &["config", "user.name", "t"]);
6288 git(d, &["config", "user.email", "t@t"]);
6289 git(d, &["config", "commit.gpgsign", "false"]);
6290 std::fs::write(d.join("a.txt"), "base\n").expect("write");
6291 git(d, &["add", "a.txt"]);
6292 git(d, &["commit", "-q", "-m", "base"]);
6293 git(d, &["checkout", "-q", "-b", "other"]);
6294 std::fs::write(d.join("a.txt"), "other\n").expect("write");
6295 git(d, &["commit", "-q", "-am", "other"]);
6296 git(d, &["checkout", "-q", "main"]);
6297 std::fs::write(d.join("a.txt"), "main\n").expect("write");
6298 git(d, &["commit", "-q", "-am", "main"]);
6299 dir
6300 }
6301
6302 #[test]
6305 fn a_real_merge_conflict_reports_the_conflict() {
6306 let dir = conflicted();
6307 let err = run_remote_op(dir.path(), &argv(&["merge", "--no-edit", "other"]))
6308 .expect_err("the merge conflicts");
6309 let first = err.lines().next().unwrap_or_default();
6310 assert!(first.starts_with("CONFLICT"), "{err}");
6311 let outcome = super::TaskResult::from(Err(err));
6314 assert!(
6315 matches!(&outcome, super::TaskResult::Stopped(why) if why.starts_with("CONFLICT")),
6316 "a conflict must be a stop, got {outcome:?}"
6317 );
6318 }
6319
6320 #[test]
6323 fn upstream_resolution_still_reads_a_plain_value() {
6324 let origin = conflicted();
6325 let clone = tempfile::tempdir().expect("temp dir");
6326 git(
6327 clone.path(),
6328 &["clone", "-q", "--", &origin.path().to_string_lossy(), "c"],
6329 );
6330 assert_eq!(
6331 resolve_upstream(&clone.path().join("c")).as_deref(),
6332 Some("origin main")
6333 );
6334 }
6335}
6336
6337#[cfg(test)]
6338mod task_scope_tests {
6339 use super::task_scope;
6340 use std::path::Path;
6341
6342 #[test]
6345 fn a_lone_repository_is_named_by_its_basename() {
6346 assert_eq!(
6347 task_scope(Path::new("/tmp/nc2-lone/nc2-solo")).as_deref(),
6348 Some("nc2-solo")
6349 );
6350 }
6351
6352 #[test]
6355 fn a_shared_basename_is_qualified_by_its_parent() {
6356 let work = Path::new("/tmp/nc2-work/nc2-api");
6357 let oss = Path::new("/tmp/nc2-oss/nc2-api");
6358 assert_eq!(task_scope(work).as_deref(), Some("nc2-api"));
6359 assert_eq!(task_scope(oss).as_deref(), Some("nc2-oss/nc2-api"));
6360 assert_eq!(
6361 task_scope(work).as_deref(),
6362 Some("nc2-work/nc2-api"),
6363 "and the first one stops using the bare name too"
6364 );
6365 }
6366
6367 #[test]
6368 fn an_empty_workdir_has_no_scope() {
6369 assert_eq!(task_scope(Path::new("")), None);
6370 }
6371}
6372
6373#[cfg(test)]
6374mod reactive_refresh {
6375 use super::invalidates_a_magit_view;
6376 use lattice_protocol::event::{Event, TaskOutcome};
6377
6378 fn finished(source: &str) -> Event {
6379 Event::BackgroundTaskFinished {
6380 source: source.to_string(),
6381 scope: None,
6382 label: "push".to_string(),
6383 outcome: TaskOutcome::Succeeded {
6384 summary: "done".to_string(),
6385 },
6386 }
6387 }
6388
6389 #[test]
6394 fn a_magit_task_invalidates_the_view() {
6395 assert!(invalidates_a_magit_view(&finished("magit")));
6396 }
6397
6398 #[test]
6403 fn another_subsystems_task_does_not() {
6404 for source in ["lsp", "compilation", "plugin", ""] {
6405 assert!(
6406 !invalidates_a_magit_view(&finished(source)),
6407 "{source:?} must not trigger a magit refresh"
6408 );
6409 }
6410 }
6411}