1pub mod actions;
12pub mod blame;
13pub mod buffer_io;
14pub mod buffer_state;
15mod cherry_move;
16mod confirm;
17mod git_config;
18mod git_report;
19mod hunk_fold_source;
20pub mod cursor_restore;
23pub mod fold_source;
24pub mod headerline;
25mod highlight;
26pub mod options;
27pub mod hunk;
32mod hunk_syntax;
35pub mod magit_blame_mode;
36pub mod magit_branch_mode;
37pub mod magit_cherry_mode;
38pub mod magit_commit_mode;
39pub mod magit_core_mode;
40pub mod magit_diff_mode;
41pub mod magit_file_revision_mode;
42pub mod magit_global_mode;
43pub mod magit_hunk_mode;
44pub mod magit_log_mode;
45pub mod magit_nav_mode;
46pub mod magit_notes_mode;
47pub mod magit_rebase_mode;
48pub mod magit_refs_mode;
49pub mod magit_remote_mode;
50pub mod magit_revision_mode;
51pub mod magit_stash_mode;
52pub mod magit_stash_show_mode;
53pub mod magit_status_mode;
54pub mod magit_submodule_mode;
55pub mod picker_sources;
56pub mod providers;
57pub mod refresh;
58pub mod repo_scope;
59pub mod sections;
60pub mod transients;
61pub mod workdir;
62
63use std::sync::Arc;
64
65use lattice_grammar::{
66 ActionSpec, ArgSpec, Args, Effect, ExCommandSpec, GrammarResult, LatencyClass, SurfaceForm,
67 registry::CommandRegistry,
68};
69use lattice_mode::SubsystemBoot;
70
71use magit_blame_mode::MagitBlameMode;
72use magit_branch_mode::MagitBranchMode;
73use magit_cherry_mode::MagitCherryMode;
74use magit_commit_mode::MagitCommitMode;
75use magit_core_mode::MagitCoreMode;
76use magit_diff_mode::MagitDiffMode;
77use magit_file_revision_mode::MagitFileRevisionMode;
78use magit_global_mode::MagitGlobalMode;
79use magit_log_mode::MagitLogMode;
80use magit_notes_mode::MagitNotesMode;
81use magit_rebase_mode::MagitRebaseMode;
82use magit_refs_mode::MagitRefsMode;
83use magit_remote_mode::MagitRemoteMode;
84use magit_revision_mode::MagitRevisionMode;
85use magit_stash_mode::MagitStashMode;
86use magit_status_mode::MagitStatusMode;
87use magit_submodule_mode::MagitSubmoduleMode;
88
89pub fn install(boot: &mut impl SubsystemBoot) {
93 magit_global_mode::set_event_bus(boot.event_bus().clone());
97
98 boot.modes_mut()
101 .register(MagitGlobalMode)
102 .expect("magit-global-mode registers without conflict");
103
104 boot.modes_mut()
105 .register(MagitCoreMode)
106 .expect("magit-core-mode registers without conflict");
107
108 crate::providers::project_diff::register_project_diff_mode(boot.modes_mut());
113
114 boot.register_service::<crate::providers::project_diff::ProjectDiffServiceHandle>(
117 std::sync::Arc::new(crate::providers::project_diff::ProjectDiffService::new()),
118 );
119
120 crate::providers::project_diff::register_project_diff_provider(boot.services_mut());
127
128 boot.modes_mut()
131 .register(magit_hunk_mode::MagitHunkMode)
132 .expect("magit-hunk-mode registers without conflict");
133
134 boot.modes_mut()
135 .register(MagitStatusMode)
136 .expect("magit-status-mode registers without conflict");
137
138 boot.modes_mut()
139 .register(MagitCommitMode)
140 .expect("magit-commit-mode registers without conflict");
141
142 boot.modes_mut()
143 .register(magit_nav_mode::MagitNavMode)
144 .expect("magit-nav-mode registers without conflict");
145
146 boot.modes_mut()
147 .register(MagitDiffMode)
148 .expect("magit-diff-mode registers without conflict");
149
150 boot.modes_mut()
151 .register(MagitLogMode)
152 .expect("magit-log-mode registers without conflict");
153
154 boot.modes_mut()
155 .register(MagitBlameMode)
156 .expect("magit-blame-mode registers without conflict");
157
158 boot.modes_mut()
159 .register(MagitStashMode)
160 .expect("magit-stash-mode registers without conflict");
161
162 boot.modes_mut()
163 .register(MagitBranchMode)
164 .expect("magit-branch-mode registers without conflict");
165
166 boot.modes_mut()
167 .register(MagitRemoteMode)
168 .expect("magit-remote-mode registers without conflict");
169
170 boot.modes_mut()
172 .register(MagitRefsMode)
173 .expect("magit-refs-mode registers without conflict");
174
175 boot.modes_mut()
177 .register(MagitNotesMode)
178 .expect("magit-notes-mode registers without conflict");
179
180 boot.modes_mut()
182 .register(MagitCherryMode)
183 .expect("magit-cherry-mode registers without conflict");
184
185 boot.modes_mut()
186 .register(MagitSubmoduleMode)
187 .expect("magit-submodule-mode registers without conflict");
188
189 boot.modes_mut()
190 .register(MagitRebaseMode)
191 .expect("magit-rebase-mode registers without conflict");
192
193 boot.modes_mut()
194 .register(MagitRevisionMode)
195 .expect("magit-revision-mode registers without conflict");
196
197 boot.modes_mut()
198 .register(MagitFileRevisionMode)
199 .expect("magit-file-revision-mode registers without conflict");
200
201 boot.modes_mut()
203 .register(magit_stash_show_mode::MagitStashShowMode)
204 .expect("magit-stash-show-mode registers without conflict");
205
206 register_buffer_state_services(boot);
217
218 cursor_restore::install_cursor_bus(boot);
220
221 let blame_requests: magit_blame_mode::BlameRequestsHandle =
224 Arc::new(magit_blame_mode::BlameRequests::default());
225 boot.register_service::<magit_blame_mode::BlameRequestsHandle>(blame_requests.clone());
226 boot.register_service::<magit_diff_mode::ViewArgsRequestsHandle>(
229 magit_diff_mode::ViewArgsRequestsHandle::default(),
230 );
231 let repo_scopes: repo_scope::RepoScopesHandle = Arc::new(repo_scope::RepoScopes::default());
240 if let Some(resolver) = boot.service::<lattice_core::ProjectResolverHandle>() {
245 repo_scopes.set_resolver((*resolver).clone());
246 } else {
247 tracing::debug!("no project resolver at magit install; `C-x g` falls back to process cwd");
248 }
249 boot.register_service::<repo_scope::RepoScopesHandle>(repo_scopes.clone());
250 if let Some(scope_sources) = boot.service::<lattice_mode::BufferScopeSourceRegistryHandle>() {
258 let source: Arc<dyn lattice_mode::BufferScopeSource> = repo_scopes.clone();
259 scope_sources.rcu(|current| {
260 let mut next = (**current).clone();
261 next.register(source.clone());
262 Arc::new(next)
263 });
264 } else {
265 tracing::debug!(
266 "no buffer-scope registry at magit install; `:files` in a magit buffer \
267 will resolve against the working directory"
268 );
269 }
270 let store = boot.buffer_store().clone();
274 install_repo_scope_cleanup(boot, repo_scopes.clone());
275 register_ex_commands(
276 boot.commands_mut(),
277 blame_requests,
278 store,
279 repo_scopes.clone(),
280 );
281
282 register_action_commands(boot.commands_mut());
285
286 let dispatch_ids = transients::MagitActionIds::resolve(boot.commands_mut());
303 let file_dispatch_ids = dispatch_ids.clone();
304 let other_file_dispatch_ids = dispatch_ids.clone();
305 let view_args_ids_src = dispatch_ids.clone();
306 let root_menu_ids = dispatch_ids.clone();
307 let menu_store = boot.buffer_store().clone();
313 let menu_scopes = repo_scopes.clone();
314 let dispatch_store = menu_store.clone();
315 let dispatch_scopes = menu_scopes.clone();
316 let transient_registry: lattice_picker::TransientSourceRegistryHandle = boot
326 .service::<lattice_picker::TransientSourceRegistryHandle>()
327 .map(|h| (*h).clone())
328 .unwrap_or_else(|| std::sync::Arc::new(lattice_picker::TransientSourceRegistry::new()));
329 for menu in transients::ROOT_MENUS {
338 let ids = root_menu_ids.clone();
339 let source = menu.source;
340 let store = menu_store.clone();
341 let scopes = menu_scopes.clone();
342 transient_registry.register(source, move |ctx| {
343 let workdir = menu_workdir(&store, &scopes, ctx);
352 transients::root_menu_spec(
353 source,
354 &ids,
355 ctx,
356 &transients::DispatchGates::probe_in(&workdir),
357 )
358 .expect("every ROOT_MENUS source has a spec")
359 });
360 }
361 transient_registry.register("magit-dispatch", move |ctx| {
366 let workdir = menu_workdir(&dispatch_store, &dispatch_scopes, ctx);
376 git_config::refresh(workdir.clone());
377 transients::dispatch_transient(&dispatch_ids, ctx, &workdir)
378 });
379 transient_registry.register("magit-file-dispatch", move |_| {
380 transients::file_dispatch_transient(&file_dispatch_ids)
381 });
382 transient_registry.register("magit-other-file-dispatch", move |_| {
387 transients::other_file_dispatch_transient(&other_file_dispatch_ids)
388 });
389 let view_args_ids = view_args_ids_src;
393 transient_registry.register("magit-view-arguments", move |ctx| {
394 transients::view_arguments_transient(&view_args_ids, ctx)
395 });
396}
397
398fn parse_remote_flags(op: magit_global_mode::RemoteOp, line: &str) -> Args {
409 use magit_global_mode::RemoteArgKind;
410 if op.flags.is_empty() {
411 return Args::None;
412 }
413 let given: Vec<&str> = line.split_whitespace().collect();
414 Args::List(
415 op.flags
416 .iter()
417 .map(|f| match f.kind {
418 RemoteArgKind::Flag => lattice_grammar::ArgValue::Bool(given.contains(&f.arg)),
419 RemoteArgKind::ValueJoined { .. } => lattice_grammar::ArgValue::String(
430 given
431 .iter()
432 .find_map(|t| t.strip_prefix(f.arg))
433 .unwrap_or_default()
434 .to_string(),
435 ),
436 RemoteArgKind::Value { .. } => lattice_grammar::ArgValue::String(
437 given
438 .iter()
439 .position(|t| *t == f.arg)
440 .map(|i| given[i + 1..].join(" "))
441 .unwrap_or_default(),
442 ),
443 })
444 .collect(),
445 )
446}
447
448fn register_buffer_state_services(boot: &mut impl SubsystemBoot) {
456 boot.register_service::<magit_branch_mode::BranchStatesHandle>(Arc::new(
457 buffer_state::BufferStates::default(),
458 ));
459 boot.register_service::<magit_stash_mode::StashStatesHandle>(Arc::new(
460 buffer_state::BufferStates::default(),
461 ));
462 boot.register_service::<magit_revision_mode::RevisionStatesHandle>(Arc::new(
463 buffer_state::BufferStates::default(),
464 ));
465 boot.register_service::<magit_blame_mode::BlameStatesHandle>(Arc::new(
466 buffer_state::BufferStates::default(),
467 ));
468 boot.register_service::<magit_commit_mode::CommitStatesHandle>(Arc::new(
469 buffer_state::BufferStates::default(),
470 ));
471 boot.register_service::<magit_rebase_mode::RebaseStatesHandle>(Arc::new(
472 buffer_state::BufferStates::default(),
473 ));
474 boot.register_service::<magit_remote_mode::RemoteStatesHandle>(Arc::new(
476 buffer_state::BufferStates::default(),
477 ));
478 boot.register_service::<magit_refs_mode::RefsStatesHandle>(Arc::new(
480 buffer_state::BufferStates::default(),
481 ));
482 boot.register_service::<magit_notes_mode::NoteStatesHandle>(Arc::new(
484 buffer_state::BufferStates::default(),
485 ));
486 boot.register_service::<magit_cherry_mode::CherryStatesHandle>(Arc::new(
488 buffer_state::BufferStates::default(),
489 ));
490 boot.register_service::<magit_submodule_mode::SubmoduleStatesHandle>(Arc::new(
492 buffer_state::BufferStates::default(),
493 ));
494 boot.register_service::<magit_log_mode::LogStatesHandle>(Arc::new(
495 buffer_state::BufferStates::default(),
496 ));
497 boot.register_service::<magit_diff_mode::DiffStatesHandle>(Arc::new(
498 buffer_state::BufferStates::default(),
499 ));
500 boot.register_service::<actions::StatusStatesHandle>(Arc::new(
501 buffer_state::BufferStates::default(),
502 ));
503 boot.register_service::<magit_stash_show_mode::StashShowStatesHandle>(Arc::new(
506 buffer_state::BufferStates::default(),
507 ));
508 let views: buffer_state::MagitViewsHandle = Arc::new(buffer_state::MagitViews::default());
513 boot.register_service::<buffer_state::MagitViewsHandle>(views.clone());
514
515 subscribe_view_invalidation(boot, views);
537}
538
539fn subscribe_view_invalidation(
545 boot: &mut impl SubsystemBoot,
546 views: buffer_state::MagitViewsHandle,
547) {
548 let bus = boot.event_bus().clone();
549 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
550 bus.subscribe(
551 lattice_runtime::EventFilter::kind(
552 lattice_protocol::event::EventKind::BackgroundTaskFinished,
553 ),
554 lattice_runtime::SubscriptionTarget::Channel(tx),
555 );
556 boot.runtime_handle().spawn(async move {
560 while let Some(event) = rx.recv().await {
561 if !magit_global_mode::invalidates_a_magit_view(&event) {
562 continue;
563 }
564 for view in views.all() {
565 let _ = view.refresh();
570 }
571 }
572 });
573}
574
575#[cfg(test)]
595pub(crate) fn confirm_target_slots(name: &str) -> Option<usize> {
596 CONFIRM_TARGET_ACTIONS
597 .iter()
598 .find(|(n, _, _)| *n == name)
599 .map(|(_, _, slots)| slots.len())
600}
601
602const CONFIRM_TARGET_ACTIONS: &[(&str, &str, &[(&str, &str)])] = &[
603 (
604 "action:magit-discard-execute",
605 "Execute the discard after confirmation",
606 &[
607 ("path", "Repo-relative path, for a file-level discard"),
608 ("patch", "The synthesized patch, for a hunk or region"),
609 ("workdir", "Repository the patch applies in"),
610 ],
611 ),
612 (
613 "action:magit-discard-untracked-execute",
614 "Delete the untracked file after confirmation",
615 &[("path", "Repo-relative path of the untracked file to delete")],
619 ),
620 (
621 "action:magit-discard-batch-execute",
622 "Discard every file in the confirmed selection",
623 &[(
636 "files",
637 "Every selected file as NUL-joined `<t|u><path>` entries",
638 )],
639 ),
640 (
641 "action:magit-global-file-delete-execute",
642 "Delete the file after confirmation",
643 &[("file", "Repo-relative path the prompt named")],
644 ),
645 (
646 "action:magit-global-file-checkout-execute",
647 "Check the file out from the named revision after confirmation",
648 &[
649 ("rev", "Revision the prompt named"),
650 ("file", "Repo-relative path the prompt named"),
651 ],
652 ),
653 (
654 "action:magit-branch-delete-execute",
655 "Delete the branch after confirmation",
656 &[("branch", "Branch the prompt named")],
657 ),
658 (
662 "action:magit-reset-worktree-execute",
663 "Reset the working tree after confirmation",
664 &[("commit", "Commit the prompt named")],
665 ),
666 (
667 "action:magit-global-branch-reset-execute",
668 "Reset the current branch after confirmation",
669 &[("ref", "Ref the prompt named")],
670 ),
671 (
675 "action:magit-global-branch-delete-execute",
676 "Delete the branch the menu named, after confirmation",
677 &[("branch", "Branch the prompt named")],
678 ),
679 (
684 "action:magit-submodule-remove-execute",
685 "Remove the submodule after confirmation",
686 &[("path", "Submodule path the prompt named")],
687 ),
688 (
689 "action:magit-stash-drop-execute",
690 "Drop the stash after confirmation",
691 &[("stash", "Stash index the prompt named")],
692 ),
693 (
694 "action:magit-reset-hard-execute",
695 "Reset --hard after confirmation",
696 &[("commit", "Commit the prompt named")],
697 ),
698];
699
700const FILE_TARGET_ACTIONS: &[(&str, &str)] = &[
708 (
709 "action:magit-global-file-stage",
710 "Stage the file in the current buffer",
711 ),
712 (
713 "action:magit-global-file-unstage",
714 "Unstage the file in the current buffer",
715 ),
716 (
717 "action:magit-global-file-discard",
718 "Discard changes to the file in the current buffer",
719 ),
720 (
721 "action:magit-global-file-diff",
722 "Show diff for the file in the current buffer",
723 ),
724 (
725 "action:magit-global-file-log",
726 "Show commit history for the file in the current buffer",
727 ),
728 (
729 "action:magit-global-file-blame",
730 "Blame the file in the current buffer",
731 ),
732 (
739 "action:magit-global-file-discard-execute",
740 "Execute the file discard after confirmation",
741 ),
742 (
744 "action:magit-global-file-untrack",
745 "Stop tracking the file, keeping it on disk",
746 ),
747 (
748 "action:magit-global-file-delete",
749 "Delete the file (asks first)",
750 ),
751 (
752 "action:magit-global-file-rename",
753 "Rename the file (asks for the new name)",
754 ),
755 (
757 "action:magit-global-file-checkout",
758 "Check the file out from a revision (asks for it, then confirms)",
759 ),
760];
761
762fn find_file_usage() -> Effect {
767 Effect::Echo {
768 level: lattice_grammar::EchoLevel::Error,
769 text: "magit: usage — :magit-find-file <rev> <path> \
770 (or `C-c f v` for the file you are visiting)"
771 .to_string(),
772 }
773}
774
775fn am_usage() -> Effect {
777 Effect::Echo {
778 level: lattice_grammar::EchoLevel::Error,
779 text: "magit: usage — :magit-am <patch>… [-3]".to_string(),
780 }
781}
782
783fn format_patch_usage() -> Effect {
787 Effect::Echo {
788 level: lattice_grammar::EchoLevel::Error,
789 text: "magit: usage — :magit-format-patch <range> (e.g. @{upstream}..HEAD)".to_string(),
790 }
791}
792
793fn cherries_usage() -> Effect {
796 Effect::Echo {
797 level: lattice_grammar::EchoLevel::Error,
798 text: "magit: usage — :magit-cherries <upstream> [<head>]".to_string(),
799 }
800}
801
802fn note_usage(cmd: &str) -> Effect {
806 Effect::Echo {
807 level: lattice_grammar::EchoLevel::Error,
808 text: format!("magit: usage — :{cmd} <commit> (or `C-c g T` to pick one)"),
809 }
810}
811
812fn note_merge_usage() -> Effect {
814 Effect::Echo {
815 level: lattice_grammar::EchoLevel::Error,
816 text: "magit: usage — :magit-note-merge <notes-ref> \
817 [manual|ours|theirs|union|cat_sort_uniq]"
818 .to_string(),
819 }
820}
821
822fn clone_usage() -> Effect {
826 Effect::Echo {
827 level: lattice_grammar::EchoLevel::Error,
828 text: "magit: usage — :magit-clone <url> [<destination>] \
829 (or `C` in the dispatch)"
830 .to_string(),
831 }
832}
833
834fn log_merged_usage() -> Effect {
838 Effect::Echo {
839 level: lattice_grammar::EchoLevel::Error,
840 text: "magit: usage — :magit-log-merged <commit> \
841 (or `C-c f M` to pick one)"
842 .to_string(),
843 }
844}
845
846fn reverse_blame_usage() -> Effect {
850 Effect::Echo {
851 level: lattice_grammar::EchoLevel::Error,
852 text: "magit: usage — :magit-blame-reverse <rev> <path>".to_string(),
853 }
854}
855
856fn menu_workdir(
863 store: &lattice_mode::BufferStoreHandle,
864 scopes: &repo_scope::RepoScopes,
865 ctx: &lattice_picker::TransientContext,
866) -> std::path::PathBuf {
867 ctx.buffer
868 .map(|buffer| repo_scope::workdir_or_cwd(store, scopes, buffer))
869 .or_else(|| {
870 workdir::magit_workdir_from(
872 scopes
873 .discovery_start()
874 .as_deref()
875 .unwrap_or(std::path::Path::new(".")),
876 )
877 })
878 .unwrap_or_default()
879}
880
881fn install_repo_scope_cleanup(boot: &mut impl SubsystemBoot, scopes: repo_scope::RepoScopesHandle) {
889 let Ok(handle) = tokio::runtime::Handle::try_current() else {
890 tracing::debug!(
891 "magit: no tokio runtime in scope; skipping DocumentClosed \
892 repo-scope cleanup (expected in test paths)"
893 );
894 return;
895 };
896 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<lattice_protocol::Event>();
897 boot.event_bus().subscribe(
898 lattice_runtime::EventFilter::kind(lattice_protocol::EventKind::DocumentClosed),
899 lattice_runtime::SubscriptionTarget::Channel(tx),
900 );
901 handle.spawn(async move {
902 while let Some(event) = rx.recv().await {
903 if let lattice_protocol::Event::DocumentClosed { id } = event {
904 scopes.forget_by_document_id(id);
905 }
906 }
907 });
908}
909
910fn register_ex_commands(
924 registry: &mut CommandRegistry,
925 blame_requests: magit_blame_mode::BlameRequestsHandle,
926 store: lattice_mode::BufferStoreHandle,
927 scopes: repo_scope::RepoScopesHandle,
928) {
929 registry.register_ex_command(
948 "magit-status",
949 "Open the Magit status buffer. With no argument, for the repository of \
950 the current buffer; with a path, for the repository containing it.",
951 ExCommandSpec {
952 latency_class: LatencyClass::Reflex,
953 accepts_bang: false,
954 accepts_range: false,
955 parse_args: Arc::new(|line: &str, _bang: bool| {
956 let rest = line.trim();
957 Ok(if rest.is_empty() {
958 Args::None
959 } else {
960 Args::String(rest.to_string())
961 })
962 }),
963 apply: {
964 let store = store.clone();
965 let scopes = scopes.clone();
966 Arc::new(move |ctx| {
967 let at = match &ctx.args {
968 Args::String(p) if !p.trim().is_empty() => Some(std::path::PathBuf::from(
969 lattice_core::home::expand_tilde(p.trim()),
970 )),
971 _ => None,
972 };
973 Ok(repo_scope::open_repo_view_at(
974 "status",
975 "magit-status-mode",
976 &store,
977 &scopes,
978 ctx.buffer_id,
979 at.as_deref(),
980 ))
981 })
982 },
983 args_schema: vec![lattice_grammar::ArgSpec {
984 name: "path".into(),
985 kind: lattice_grammar::ArgKind::String,
986 doc: "a path inside the repository; defaults to the current buffer's".into(),
987 prompt: "Repository: ".into(),
988 default: lattice_grammar::ArgDefault::None,
989 completion: None,
990 picker: None,
991 }],
992 surface_form: SurfaceForm::Keyword,
993 },
994 );
995
996 let mut mk =
1001 |name: &'static str, doc: &'static str, view: &'static str, mode_id: &'static str| {
1002 let store = store.clone();
1003 let scopes = scopes.clone();
1004 registry.register_ex_command(
1005 name,
1006 doc,
1007 ExCommandSpec {
1008 latency_class: LatencyClass::Reflex,
1009 accepts_bang: false,
1010 accepts_range: false,
1011 parse_args: Arc::new(|_line: &str, _bang: bool| Ok(Args::None)),
1012 apply: Arc::new(move |ctx| {
1013 Ok(repo_scope::open_repo_view(
1014 view,
1015 mode_id,
1016 &store,
1017 &scopes,
1018 ctx.buffer_id,
1019 ))
1020 }),
1021 args_schema: Vec::new(),
1022 surface_form: SurfaceForm::Keyword,
1023 },
1024 );
1025 };
1026
1027 mk(
1028 "magit-commit",
1029 "Open the Magit commit buffer for the repository of the current buffer.",
1030 "commit",
1031 "magit-commit-mode",
1032 );
1033 mk(
1034 "magit-branch",
1035 "Open the Magit branch list for the repository of the current buffer.",
1036 "branch",
1037 "magit-branch-mode",
1038 );
1039 mk(
1041 "magit-remote",
1042 "Open the Magit remote list for the repository of the current buffer.",
1043 "remote",
1044 "magit-remote-mode",
1045 );
1046 mk(
1048 "magit-submodule",
1049 "Open the Magit submodule list for the repository of the current buffer.",
1050 "submodule",
1051 "magit-submodule-mode",
1052 );
1053 mk(
1056 "magit-refs",
1057 "Open the Magit refs buffer — every branch, remote-tracking branch and tag.",
1058 magit_refs_mode::REFS_VIEW,
1059 "magit-refs-mode",
1060 );
1061 mk(
1067 "magit-diff",
1068 "Diff the repository of the current buffer against HEAD.",
1069 "diff",
1070 "magit-diff-mode",
1071 );
1072 mk(
1073 "magit-log",
1074 "Open the commit history of the repository of the current buffer.",
1075 "log",
1076 "magit-log-mode",
1077 );
1078 mk(
1079 "magit-stash-list",
1080 "Open the stash list of the repository of the current buffer.",
1081 "stash",
1082 "magit-stash-mode",
1083 );
1084
1085 registry.register_ex_command(
1097 "magit-project-diff",
1098 "Open every changed file in the working tree as one editable diff view. \
1099 Pass `staged` to see the index against HEAD instead (read-only).",
1100 ExCommandSpec {
1101 latency_class: LatencyClass::Reflex,
1102 accepts_bang: false,
1103 accepts_range: false,
1104 parse_args: Arc::new(|line: &str, _bang: bool| {
1109 let trimmed = line.trim();
1110 Ok(if trimmed.is_empty() {
1111 Args::None
1112 } else {
1113 Args::String(trimmed.to_string())
1114 })
1115 }),
1116 apply: Arc::new(|ctx| {
1117 Ok(Effect::AppAction(
1118 lattice_grammar::app_effect::AppEffect::OpenProviderView {
1119 provider: crate::providers::project_diff::PROVIDER_NAME.to_string(),
1120 args: ctx.args.clone(),
1121 },
1122 ))
1123 }),
1124 args_schema: vec![lattice_grammar::args::ArgSpec::optional(
1125 "comparison",
1126 lattice_grammar::args::ArgKind::String,
1127 "`staged` for the index against HEAD; omitted for the working tree",
1128 )],
1129 surface_form: SurfaceForm::Keyword,
1130 },
1131 );
1132 let mut mk_transient = |name: &'static str, doc: &'static str, source: &'static str| {
1138 registry.register_ex_command(
1139 name,
1140 doc,
1141 ExCommandSpec {
1142 latency_class: LatencyClass::Reflex,
1143 accepts_bang: false,
1144 accepts_range: false,
1145 parse_args: Arc::new(|_line: &str, _bang: bool| Ok(Args::None)),
1146 apply: Arc::new(move |_ctx| {
1147 Ok(Effect::OpenTransient {
1148 source: source.to_string(),
1149 args: lattice_grammar::Args::None,
1152 })
1153 }),
1154 args_schema: Vec::new(),
1155 surface_form: SurfaceForm::Keyword,
1156 },
1157 );
1158 };
1159 mk_transient(
1160 "magit-dispatch",
1161 "Open the Magit repo-level dispatch transient.",
1162 "magit-dispatch",
1163 );
1164 mk_transient(
1165 "magit-file-dispatch",
1166 "Open the Magit file-level dispatch transient.",
1167 "magit-file-dispatch",
1168 );
1169 mk_transient(
1170 "magit-other-file-dispatch",
1171 "Open the Magit file-level dispatch transient for a file you name, \
1172 rather than the one you are visiting.",
1173 "magit-other-file-dispatch",
1174 );
1175
1176 let mut mk_op = |name: &'static str, doc: &'static str, op: magit_global_mode::RemoteOp| {
1188 registry.register_ex_command(
1189 name,
1190 doc,
1191 ExCommandSpec {
1192 latency_class: LatencyClass::Reflex,
1193 accepts_bang: false,
1194 accepts_range: false,
1195 parse_args: Arc::new(move |line: &str, _bang: bool| {
1201 Ok(parse_remote_flags(op, line))
1202 }),
1203 apply: {
1204 let store = store.clone();
1205 let scopes = scopes.clone();
1206 Arc::new(move |ctx| {
1207 Ok(magit_global_mode::spawn_remote_op(
1208 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1209 op,
1210 &ctx.args,
1211 ))
1212 })
1213 },
1214 args_schema: op.arg_specs(),
1215 surface_form: SurfaceForm::Keyword,
1216 },
1217 );
1218 };
1219 mk_op(
1220 "magit-fetch",
1221 "Fetch from the default remote without merging.",
1222 magit_global_mode::RemoteOp::FETCH,
1223 );
1224 mk_op(
1225 "magit-pull",
1226 "Pull from the upstream branch (fast-forward only).",
1227 magit_global_mode::RemoteOp::PULL,
1228 );
1229 mk_op(
1230 "magit-push",
1231 "Push the current branch to its upstream.",
1232 magit_global_mode::RemoteOp::PUSH,
1233 );
1234 mk_op(
1238 "magit-stash",
1239 "Stash the working tree's changes.",
1240 magit_global_mode::RemoteOp::STASH,
1241 );
1242 mk_op(
1252 "magit-rebase-continue",
1253 "Resume a rebase that stopped (after amending, or resolving conflicts).",
1254 magit_global_mode::RemoteOp::REBASE_CONTINUE,
1255 );
1256 mk_op(
1257 "magit-rebase-skip",
1258 "Skip the commit a stopped rebase is sitting on.",
1259 magit_global_mode::RemoteOp::REBASE_SKIP,
1260 );
1261 mk_op(
1262 "magit-rebase-abort",
1263 "Abandon a rebase in progress, restoring the branch to where it started.",
1264 magit_global_mode::RemoteOp::REBASE_ABORT,
1265 );
1266 {
1271 let mut mk_prompted =
1272 |name: &'static str,
1273 doc: &'static str,
1274 arg: &'static str,
1275 arg_doc: &'static str,
1276 prompt_action: &'static str,
1277 run: fn(std::path::PathBuf, String) -> Effect| {
1278 let store = store.clone();
1282 let scopes = scopes.clone();
1283 registry.register_ex_command(
1284 name,
1285 doc,
1286 ExCommandSpec {
1287 latency_class: LatencyClass::Reflex,
1288 accepts_bang: false,
1289 accepts_range: false,
1290 parse_args: Arc::new(|line: &str, _bang: bool| {
1291 let trimmed = line.trim();
1292 if trimmed.is_empty() {
1293 Ok(Args::None)
1294 } else {
1295 Ok(Args::String(trimmed.to_string()))
1296 }
1297 }),
1298 apply: Arc::new(move |ctx| {
1299 Ok(match ctx.args {
1300 Args::String(ref v) if !v.trim().is_empty() => run(
1301 repo_scope::active_workdir(&store, &scopes, ctx.buffer_id)
1302 .unwrap_or_default(),
1303 v.trim().to_string(),
1304 ),
1305 _ => Effect::OpenPrompt {
1309 prompt: format!("{arg_doc}: "),
1310 initial: String::new(),
1311 on_submit_action: prompt_action.to_string(),
1312 buffer_name: None,
1313 },
1314 })
1315 }),
1316 args_schema: vec![ArgSpec::optional(
1317 arg,
1318 lattice_grammar::ArgKind::String,
1319 arg_doc,
1320 )],
1321 surface_form: SurfaceForm::Keyword,
1322 },
1323 );
1324 };
1325 mk_prompted(
1326 "magit-tag",
1327 "Tag HEAD. With arg: the tag name; without, asks for it.",
1328 "name",
1329 "Tag name",
1330 "action:magit-global-tag-finish",
1331 |wd, name| {
1332 magit_global_mode::spawn_git(
1333 wd,
1334 magit_global_mode::tag_argv(&name),
1335 &format!("tag HEAD as {name}"),
1336 )
1337 },
1338 );
1339 mk_prompted(
1340 "magit-merge",
1341 "Merge a branch into the current one. With arg: the branch; without, asks.",
1342 "branch",
1343 "Merge branch",
1344 "action:magit-global-merge-finish",
1345 |wd, branch| {
1346 magit_global_mode::spawn_git(
1347 wd,
1348 magit_global_mode::merge_argv(&branch),
1349 &magit_global_mode::merge_label("merge", &branch),
1350 )
1351 },
1352 );
1353 mk_prompted(
1356 "magit-merge-no-commit",
1357 "Merge a branch but stop before committing. With arg: the branch; without, asks.",
1358 "branch",
1359 "Merge branch (no commit)",
1360 "action:magit-global-merge-no-commit-finish",
1361 |wd, branch| {
1362 magit_global_mode::spawn_git(
1363 wd,
1364 magit_global_mode::merge_no_commit_argv(&branch),
1365 &magit_global_mode::merge_label("no-commit", &branch),
1366 )
1367 },
1368 );
1369 mk_prompted(
1370 "magit-merge-squash",
1371 "Squash a branch's changes into the index. With arg: the branch; without, asks.",
1372 "branch",
1373 "Squash branch",
1374 "action:magit-global-merge-squash-finish",
1375 |wd, branch| {
1376 magit_global_mode::spawn_git(
1377 wd,
1378 magit_global_mode::merge_squash_argv(&branch),
1379 &magit_global_mode::merge_label("squash", &branch),
1380 )
1381 },
1382 );
1383 mk_prompted(
1384 "magit-tag-delete",
1385 "Delete a local tag. With arg: the tag; without, asks.",
1386 "name",
1387 "Delete tag",
1388 "action:magit-global-tag-delete-finish",
1389 |wd, name| {
1390 magit_global_mode::spawn_git(
1391 wd,
1392 magit_global_mode::tag_delete_argv(&name),
1393 &format!("delete tag {name}"),
1394 )
1395 },
1396 );
1397 mk_prompted(
1398 "magit-init",
1399 "Initialize a git repository. With arg: the directory; without, asks.",
1400 "directory",
1401 "Initialize repository in",
1402 "action:magit-global-init-finish",
1403 |wd, dir| {
1404 magit_global_mode::spawn_git(
1405 wd,
1406 magit_global_mode::init_argv(&dir),
1407 &format!("create a repository in {dir}"),
1408 )
1409 },
1410 );
1411 mk_prompted(
1412 "magit-gitignore",
1413 "Add a pattern to .gitignore. With arg: the pattern; without, asks for it.",
1414 "pattern",
1415 "Ignore pattern",
1416 "action:magit-global-gitignore-finish",
1417 magit_global_mode::spawn_gitignore,
1418 );
1419 }
1420
1421 {
1422 registry.register_ex_command(
1423 "magit-blame",
1424 "Open git blame annotations for a file. With arg: specifies the file path.",
1425 ExCommandSpec {
1426 latency_class: LatencyClass::Reflex,
1427 accepts_bang: false,
1428 accepts_range: false,
1429 parse_args: Arc::new(|line: &str, _bang: bool| {
1430 let trimmed = line.trim();
1431 if trimmed.is_empty() {
1432 Ok(Args::None)
1433 } else {
1434 Ok(Args::String(trimmed.to_string()))
1435 }
1436 }),
1437 apply: Arc::new(|ctx| {
1443 let toggle = Effect::ToggleMode {
1444 mode_name: "magit-blame-mode".to_string(),
1445 };
1446 Ok(match ctx.args {
1447 Args::String(ref path) if !path.trim().is_empty() => Effect::Many(vec![
1448 Effect::OpenBuffer {
1449 path: Some(std::path::PathBuf::from(path.trim())),
1450 force: false,
1451 },
1452 toggle,
1453 ]),
1454 _ => toggle,
1455 })
1456 }),
1457 args_schema: vec![ArgSpec::optional(
1458 "file",
1459 lattice_grammar::ArgKind::String,
1460 "file path to blame",
1461 )],
1462 surface_form: SurfaceForm::Keyword,
1463 },
1464 );
1465 }
1466 {
1467 for op in [
1481 magit_global_mode::CommitOp::CHERRY_PICK,
1482 magit_global_mode::CommitOp::REVERT,
1483 magit_global_mode::CommitOp::RESET_SOFT,
1484 magit_global_mode::CommitOp::RESET_MIXED,
1485 magit_global_mode::CommitOp::RESET_HARD,
1486 magit_global_mode::CommitOp::RESET_KEEP,
1489 magit_global_mode::CommitOp::RESET_INDEX,
1490 magit_global_mode::CommitOp::COMMIT_FIXUP,
1491 magit_global_mode::CommitOp::COMMIT_SQUASH,
1492 magit_global_mode::CommitOp::REVERT_CHANGES,
1494 magit_global_mode::CommitOp::CHERRY_PICK_APPLY,
1495 magit_global_mode::CommitOp::RESET_WORKTREE,
1497 ] {
1498 let store = store.clone();
1500 let scopes = scopes.clone();
1501 registry.register_ex_command(
1502 op.ex_command,
1503 Box::leak(
1507 format!(
1508 "git {} the named commit. With no argument: pick one.",
1509 op.what
1510 )
1511 .into_boxed_str(),
1512 ),
1513 ExCommandSpec {
1514 latency_class: LatencyClass::Reflex,
1515 accepts_bang: false,
1516 accepts_range: false,
1517 parse_args: Arc::new(|line: &str, _bang: bool| {
1518 Ok(Args::String(line.trim().to_string()))
1519 }),
1520 apply: Arc::new(move |ctx| {
1521 let commit = match ctx.args {
1522 Args::String(ref s) if !s.trim().is_empty() => s.trim().to_string(),
1523 _ => {
1524 return Ok(Effect::OpenPicker {
1525 source: picker_sources::COMMIT_PICK_SOURCE.to_string(),
1526 args: vec![op.ex_command.to_string()],
1527 root: None,
1528 fill_action: None,
1529 query: None,
1530 });
1531 }
1532 };
1533 Ok(match op.confirm_action {
1534 Some(yes) => confirm::ask_target(
1535 format!("git {} {commit} — discard uncommitted changes?", op.what),
1536 yes,
1537 commit,
1538 ),
1539 None => magit_global_mode::spawn_commit_op(
1540 op,
1541 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1542 &commit,
1543 ),
1544 })
1545 }),
1546 args_schema: vec![ArgSpec::optional(
1547 "commit",
1548 lattice_grammar::ArgKind::String,
1549 "commit to act on; omit to pick one",
1550 )],
1551 surface_form: SurfaceForm::Keyword,
1552 },
1553 );
1554 }
1555 }
1556 {
1557 for (name, verb, doc) in [
1573 (
1574 "magit-stash-apply",
1575 "apply",
1576 "Apply the named stash, keeping it on the stack. With no argument: pick one.",
1577 ),
1578 (
1579 "magit-stash-pop",
1580 "pop",
1581 "Apply the named stash and drop it. With no argument: pick one.",
1582 ),
1583 (
1584 "magit-stash-drop",
1585 "drop",
1586 "Delete the named stash without applying it — asks first. With no argument: pick one.",
1587 ),
1588 ] {
1589 registry.register_ex_command(
1590 name,
1591 doc,
1592 ExCommandSpec {
1593 latency_class: LatencyClass::Reflex,
1594 accepts_bang: false,
1595 accepts_range: false,
1596 parse_args: Arc::new(|line: &str, _bang: bool| {
1597 Ok(Args::String(line.trim().to_string()))
1598 }),
1599 apply: {
1600 let store = store.clone();
1601 let scopes = scopes.clone();
1602 Arc::new(move |ctx| {
1603 let idx = match ctx.args {
1604 Args::String(ref s) if !s.trim().is_empty() => {
1605 match s.trim().parse::<usize>() {
1606 Ok(i) => i,
1607 Err(_) => {
1608 return Err(lattice_grammar::CommandError::BadArgs(
1609 format!(
1610 "{name}: expected a stash index, got {s:?}"
1611 ),
1612 ));
1613 }
1614 }
1615 }
1616 _ => {
1617 return Ok(Effect::OpenPicker {
1618 source: picker_sources::STASH_PICK_SOURCE.to_string(),
1619 args: vec![name.to_string()],
1620 root: None,
1621 fill_action: None,
1622 query: None,
1623 });
1624 }
1625 };
1626 if verb == "drop" {
1631 return Ok(confirm::ask_target(
1632 format!("Drop stash@{{{idx}}}?"),
1633 "action:magit-stash-drop-execute",
1634 idx.to_string(),
1635 ));
1636 }
1637 Ok(magit_global_mode::spawn_git(
1638 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1639 vec![
1640 "stash".to_string(),
1641 verb.to_string(),
1642 format!("stash@{{{idx}}}"),
1643 ],
1644 &format!("{verb} stash@{{{idx}}}"),
1645 ))
1646 })
1647 },
1648 args_schema: vec![ArgSpec::optional(
1649 "stash",
1650 lattice_grammar::ArgKind::String,
1651 "stash index to act on; omit to pick one",
1652 )],
1653 surface_form: SurfaceForm::Keyword,
1654 },
1655 );
1656 }
1657 registry.register_ex_command(
1662 "magit-stash-show",
1663 "Show the named stash's patch. With no argument: pick one.",
1664 ExCommandSpec {
1665 latency_class: LatencyClass::Reflex,
1666 accepts_bang: false,
1667 accepts_range: false,
1668 parse_args: Arc::new(|line: &str, _bang: bool| {
1669 Ok(Args::String(line.trim().to_string()))
1670 }),
1671 apply: {
1672 let store = store.clone();
1673 let scopes = scopes.clone();
1674 Arc::new(move |ctx| {
1675 let idx = match ctx.args {
1676 Args::String(ref s) if !s.trim().is_empty() => {
1677 s.trim().parse::<usize>().map_err(|_| {
1678 lattice_grammar::CommandError::BadArgs(format!(
1679 "magit-stash-show: expected a stash index, got {s:?}"
1680 ))
1681 })?
1682 }
1683 _ => {
1684 return Ok(Effect::OpenPicker {
1685 source: picker_sources::STASH_PICK_SOURCE.to_string(),
1686 args: vec!["magit-stash-show".to_string()],
1687 root: None,
1688 fill_action: None,
1689 query: None,
1690 });
1691 }
1692 };
1693 Ok(Effect::OpenSyntheticBuffer {
1694 name: repo_scope::repo_view_name_with(
1695 "stash",
1696 Some(&magit_stash_show_mode::stash_view_rest(idx)),
1697 &store,
1698 &scopes,
1699 ctx.buffer_id,
1700 ),
1701 mode_id: "magit-stash-show-mode".to_string(),
1702 content: None,
1703 cursor: None,
1704 activate_minor: None,
1705 })
1706 })
1707 },
1708 args_schema: vec![ArgSpec::optional(
1709 "stash",
1710 lattice_grammar::ArgKind::String,
1711 "stash index to show; omit to pick one",
1712 )],
1713 surface_form: SurfaceForm::Keyword,
1714 },
1715 );
1716 }
1717 {
1718 for (name, verb, doc) in [
1721 (
1722 "magit-rebase-edit-commit",
1723 "edit",
1724 "Replay history, stopping at the named commit. With no argument: pick one.",
1725 ),
1726 (
1727 "magit-rebase-remove-commit",
1728 "drop",
1729 "Replay history without the named commit. With no argument: pick one.",
1730 ),
1731 ] {
1732 registry.register_ex_command(
1733 name,
1734 doc,
1735 ExCommandSpec {
1736 latency_class: LatencyClass::Reflex,
1737 accepts_bang: false,
1738 accepts_range: false,
1739 parse_args: Arc::new(|line: &str, _bang: bool| {
1740 Ok(Args::String(line.trim().to_string()))
1741 }),
1742 apply: {
1743 let store = store.clone();
1744 let scopes = scopes.clone();
1745 Arc::new(move |ctx| {
1746 let commit = match ctx.args {
1747 Args::String(ref s) if !s.trim().is_empty() => s.trim().to_string(),
1748 _ => {
1749 return Ok(Effect::OpenPicker {
1750 source: picker_sources::COMMIT_PICK_SOURCE.to_string(),
1751 args: vec![name.to_string()],
1752 root: None,
1753 fill_action: None,
1754 query: None,
1755 });
1756 }
1757 };
1758 Ok(magit_global_mode::spawn_rebase_verb(
1759 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1760 verb,
1761 &commit,
1762 ))
1763 })
1764 },
1765 args_schema: vec![ArgSpec::optional(
1766 "commit",
1767 lattice_grammar::ArgKind::String,
1768 "commit to act on; omit to pick one",
1769 )],
1770 surface_form: SurfaceForm::Keyword,
1771 },
1772 );
1773 }
1774 registry.register_ex_command(
1777 "magit-rebase-reword-commit",
1778 "Change the named commit's message. With no argument: pick one.",
1779 ExCommandSpec {
1780 latency_class: LatencyClass::Reflex,
1781 accepts_bang: false,
1782 accepts_range: false,
1783 parse_args: Arc::new(|line: &str, _bang: bool| {
1784 Ok(Args::String(line.trim().to_string()))
1785 }),
1786 apply: {
1787 let store = store.clone();
1788 let scopes = scopes.clone();
1789 Arc::new(move |ctx| {
1790 let commit = match ctx.args {
1791 Args::String(ref s) if !s.trim().is_empty() => s.trim().to_string(),
1792 _ => {
1793 return Ok(Effect::OpenPicker {
1794 source: picker_sources::COMMIT_PICK_SOURCE.to_string(),
1795 args: vec!["magit-rebase-reword-commit".to_string()],
1796 root: None,
1797 fill_action: None,
1798 query: None,
1799 });
1800 }
1801 };
1802 Ok(repo_scope::open_repo_view_with(
1806 "reword-commit",
1807 "magit-commit-mode",
1808 &commit,
1809 &store,
1810 &scopes,
1811 ctx.buffer_id,
1812 ))
1813 })
1814 },
1815 args_schema: vec![ArgSpec::optional(
1816 "commit",
1817 lattice_grammar::ArgKind::String,
1818 "commit to reword; omit to pick one",
1819 )],
1820 surface_form: SurfaceForm::Keyword,
1821 },
1822 );
1823 }
1824 {
1825 registry.register_ex_command(
1830 "magit-augment",
1831 "Record a squash! for the named commit, with a note you write. \
1832 With no argument: pick one.",
1833 ExCommandSpec {
1834 latency_class: LatencyClass::Reflex,
1835 accepts_bang: false,
1836 accepts_range: false,
1837 parse_args: Arc::new(|line: &str, _bang: bool| {
1838 Ok(Args::String(line.trim().to_string()))
1839 }),
1840 apply: {
1841 let store = store.clone();
1842 let scopes = scopes.clone();
1843 Arc::new(move |ctx| {
1844 let commit = match ctx.args {
1845 Args::String(ref s) if !s.trim().is_empty() => s.trim().to_string(),
1846 _ => {
1847 return Ok(Effect::OpenPicker {
1848 source: picker_sources::COMMIT_PICK_SOURCE.to_string(),
1849 args: vec!["magit-augment".to_string()],
1850 root: None,
1851 fill_action: None,
1852 query: None,
1853 });
1854 }
1855 };
1856 Ok(repo_scope::open_repo_view_with(
1857 "augment",
1858 "magit-commit-mode",
1859 &commit,
1860 &store,
1861 &scopes,
1862 ctx.buffer_id,
1863 ))
1864 })
1865 },
1866 args_schema: vec![ArgSpec::optional(
1867 "commit",
1868 lattice_grammar::ArgKind::String,
1869 "commit to augment; omit to pick one",
1870 )],
1871 surface_form: SurfaceForm::Keyword,
1872 },
1873 );
1874 }
1875 {
1876 registry.register_ex_command(
1888 "magit-checkout",
1889 "Check out a branch: `<branch>`.",
1890 ExCommandSpec {
1891 latency_class: LatencyClass::Reflex,
1892 accepts_bang: false,
1893 accepts_range: false,
1894 parse_args: Arc::new(|line: &str, _bang: bool| {
1895 Ok(Args::String(line.trim().to_string()))
1896 }),
1897 apply: {
1898 let store = store.clone();
1899 let scopes = scopes.clone();
1900 Arc::new(move |ctx| {
1901 let Args::String(ref name) = ctx.args else {
1902 return Ok(Effect::Echo {
1903 level: lattice_grammar::EchoLevel::Error,
1904 text: "magit: usage — :magit-checkout <branch>".to_string(),
1905 });
1906 };
1907 let name = name.trim().to_string();
1908 if name.is_empty() {
1909 return Ok(Effect::Echo {
1910 level: lattice_grammar::EchoLevel::Error,
1911 text: "magit: usage — :magit-checkout <branch>".to_string(),
1912 });
1913 }
1914 Ok(magit_global_mode::spawn_git(
1915 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1916 vec!["checkout".to_string(), name.clone()],
1917 &format!("check out {name}"),
1918 ))
1919 })
1920 },
1921 args_schema: vec![
1922 ArgSpec::required(
1923 "branch",
1924 lattice_grammar::ArgKind::String,
1925 "the branch to check out",
1926 )
1927 .with_picker(picker_sources::BRANCH_PICK_SOURCE),
1928 ],
1929 surface_form: SurfaceForm::Keyword,
1930 },
1931 );
1932
1933 type BranchArgv = fn(&str) -> Vec<String>;
1946 const BRANCH_EX_COMMANDS: &[(&str, &str, BranchArgv, &str, bool)] = &[
1947 (
1948 "magit-merge",
1949 "Merge a branch into the current one: `<branch>`.",
1950 magit_global_mode::merge_argv,
1951 "merge {}",
1952 false,
1953 ),
1954 (
1955 "magit-merge-no-commit",
1956 "Merge a branch but stop before committing: `<branch>`.",
1957 magit_global_mode::merge_no_commit_argv,
1958 "merge {} without committing",
1959 false,
1960 ),
1961 (
1962 "magit-merge-squash",
1963 "Squash a branch's changes into the working tree: `<branch>`.",
1964 magit_global_mode::merge_squash_argv,
1965 "squash {} into the index",
1966 false,
1967 ),
1968 (
1969 "magit-rebase-onto",
1970 "Rebase the current branch onto another: `<branch>`.",
1971 magit_global_mode::rebase_onto_argv,
1972 "rebase onto {}",
1973 false,
1974 ),
1975 (
1976 "magit-rebase-autosquash",
1977 "Rebase interactively with --autosquash onto: `<branch>`.",
1978 magit_global_mode::rebase_autosquash_argv,
1979 "autosquash the commits after {}",
1980 false,
1981 ),
1982 (
1983 "magit-branch-reset",
1984 "Reset the current branch to another: `<branch>` (hard).",
1985 magit_global_mode::merge_argv, "hard-reset the branch to {}",
1987 true,
1988 ),
1989 ];
1990 for (name, doc, argv, what, confirms) in BRANCH_EX_COMMANDS {
1991 let (name, argv, what, confirms) = (*name, *argv, *what, *confirms);
1992 registry.register_ex_command(
1993 name,
1994 doc,
1995 ExCommandSpec {
1996 latency_class: LatencyClass::Reflex,
1997 accepts_bang: false,
1998 accepts_range: false,
1999 parse_args: Arc::new(|line: &str, _bang: bool| {
2000 Ok(Args::String(line.trim().to_string()))
2001 }),
2002 apply: {
2003 let store = store.clone();
2004 let scopes = scopes.clone();
2005 Arc::new(move |ctx| {
2006 let Args::String(ref b) = ctx.args else {
2007 return Ok(Effect::Echo {
2008 level: lattice_grammar::EchoLevel::Error,
2009 text: format!("magit: usage — :{name} <branch>"),
2010 });
2011 };
2012 let b = b.trim().to_string();
2013 if b.is_empty() {
2014 return Ok(Effect::Echo {
2015 level: lattice_grammar::EchoLevel::Error,
2016 text: format!("magit: usage — :{name} <branch>"),
2017 });
2018 }
2019 Ok(if confirms {
2020 crate::confirm::ask_target(
2021 format!("git reset --hard {b} — discard uncommitted changes?"),
2022 "action:magit-global-branch-reset-execute",
2023 b,
2024 )
2025 } else {
2026 magit_global_mode::spawn_git(
2027 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2028 argv(&b),
2029 &what.replace("{}", &b),
2031 )
2032 })
2033 })
2034 },
2035 args_schema: vec![
2036 ArgSpec::required(
2037 "branch",
2038 lattice_grammar::ArgKind::String,
2039 "the branch to operate on",
2040 )
2041 .with_picker(picker_sources::BRANCH_PICK_SOURCE),
2042 ],
2043 surface_form: SurfaceForm::Keyword,
2044 },
2045 );
2046 }
2047
2048 for (name, doc) in [
2058 (
2059 "magit-merge-absorb",
2060 "Merge a branch and delete it: `<branch>`.",
2061 ),
2062 (
2063 "magit-merge-edit",
2064 "Merge a branch, editing the merge message: `<branch>`.",
2065 ),
2066 (
2067 "magit-merge-into",
2068 "Merge the current branch INTO another: `<branch>`.",
2069 ),
2070 ] {
2071 let store = store.clone();
2074 let scopes = scopes.clone();
2075 registry.register_ex_command(
2076 name,
2077 doc,
2078 ExCommandSpec {
2079 latency_class: LatencyClass::Reflex,
2080 accepts_bang: false,
2081 accepts_range: false,
2082 parse_args: Arc::new(|line: &str, _bang: bool| {
2083 Ok(Args::String(line.trim().to_string()))
2084 }),
2085 apply: {
2086 let store = store.clone();
2087 let scopes = scopes.clone();
2088 Arc::new(move |ctx| {
2089 let Args::String(ref b) = ctx.args else {
2090 return Ok(Effect::Echo {
2091 level: lattice_grammar::EchoLevel::Error,
2092 text: format!("magit: usage — :{name} <branch>"),
2093 });
2094 };
2095 let b = b.trim().to_string();
2096 if b.is_empty() {
2097 return Ok(Effect::Echo {
2098 level: lattice_grammar::EchoLevel::Error,
2099 text: format!("magit: usage — :{name} <branch>"),
2100 });
2101 }
2102 Ok(match name {
2103 "magit-merge-absorb" => magit_global_mode::spawn_git_sequence(
2104 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2105 format!("merge {b} and delete it"),
2106 magit_global_mode::merge_absorb_steps(&b),
2107 ),
2108 "magit-merge-edit" => repo_scope::open_repo_view_with(
2112 "merge-edit",
2113 "magit-commit-mode",
2114 &b,
2115 &store,
2116 &scopes,
2117 ctx.buffer_id,
2118 ),
2119 _ => match magit_global_mode::current_branch(
2124 &repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2125 ) {
2126 None => Effect::Echo {
2127 level: lattice_grammar::EchoLevel::Error,
2128 text: "magit: not on a branch".to_string(),
2129 },
2130 Some(current) if current == b => Effect::Echo {
2131 level: lattice_grammar::EchoLevel::Error,
2132 text: "magit: cannot merge a branch into itself"
2133 .to_string(),
2134 },
2135 Some(current) => magit_global_mode::spawn_git_sequence(
2136 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2137 format!("merge {current} into {b} and delete it"),
2138 magit_global_mode::merge_into_steps(¤t, &b),
2139 ),
2140 },
2141 })
2142 })
2143 },
2144 args_schema: vec![
2145 ArgSpec::required(
2146 "branch",
2147 lattice_grammar::ArgKind::String,
2148 "the branch to operate on",
2149 )
2150 .with_picker(picker_sources::BRANCH_PICK_SOURCE),
2151 ],
2152 surface_form: SurfaceForm::Keyword,
2153 },
2154 );
2155 }
2156
2157 type RefArgv = fn(&str) -> Vec<String>;
2161 const REF_EX_COMMANDS: &[(&str, &str, RefArgv, &str)] = &[
2162 (
2163 "magit-tag-delete",
2164 "Delete a tag: `<tag>`.",
2165 magit_global_mode::tag_delete_argv,
2166 "delete tag {}",
2167 ),
2168 (
2169 "magit-tag-prune",
2170 "Prune tags gone from a remote: `<remote>`.",
2171 magit_global_mode::tag_prune_argv,
2172 "prune tags gone from {}",
2173 ),
2174 ];
2175 for (name, doc, argv, what) in REF_EX_COMMANDS {
2176 let (name, argv, what) = (*name, *argv, *what);
2177 registry.register_ex_command(
2178 name,
2179 doc,
2180 ExCommandSpec {
2181 latency_class: LatencyClass::Reflex,
2182 accepts_bang: false,
2183 accepts_range: false,
2184 parse_args: Arc::new(|line: &str, _bang: bool| {
2185 Ok(Args::String(line.trim().to_string()))
2186 }),
2187 apply: {
2188 let store = store.clone();
2189 let scopes = scopes.clone();
2190 Arc::new(move |ctx| {
2191 let Args::String(ref v) = ctx.args else {
2192 return Ok(Effect::Echo {
2193 level: lattice_grammar::EchoLevel::Error,
2194 text: format!("magit: usage — :{name} <name>"),
2195 });
2196 };
2197 let v = v.trim().to_string();
2198 if v.is_empty() {
2199 return Ok(Effect::Echo {
2200 level: lattice_grammar::EchoLevel::Error,
2201 text: format!("magit: usage — :{name} <name>"),
2202 });
2203 }
2204 Ok(magit_global_mode::spawn_git(
2205 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2206 argv(&v),
2207 &what.replace("{}", &v),
2208 ))
2209 })
2210 },
2211 args_schema: vec![ArgSpec::required(
2212 "name",
2213 lattice_grammar::ArgKind::String,
2214 "the tag or remote to operate on",
2215 )],
2216 surface_form: SurfaceForm::Keyword,
2217 },
2218 );
2219 }
2220
2221 registry.register_ex_command(
2230 "magit-file-checkout",
2231 "Check out a file from a revision: `<rev> <path>` (discards local changes).",
2232 ExCommandSpec {
2233 latency_class: LatencyClass::Reflex,
2234 accepts_bang: false,
2235 accepts_range: false,
2236 parse_args: Arc::new(|line: &str, _bang: bool| {
2237 Ok(Args::String(line.trim().to_string()))
2238 }),
2239 apply: Arc::new(|ctx| {
2240 let usage = || Effect::Echo {
2241 level: lattice_grammar::EchoLevel::Error,
2242 text: "magit: usage — :magit-file-checkout <rev> <path>".to_string(),
2243 };
2244 let Args::String(ref line) = ctx.args else {
2245 return Ok(usage());
2246 };
2247 let mut it = line.trim().splitn(2, char::is_whitespace);
2248 let (Some(rev), Some(path)) = (it.next(), it.next()) else {
2249 return Ok(usage());
2250 };
2251 let (rev, path) = (rev.trim(), path.trim());
2252 if rev.is_empty() || path.is_empty() {
2253 return Ok(usage());
2254 }
2255 Ok(crate::confirm::ask_with(
2256 format!("Checkout {path} from {rev}, discarding its uncommitted changes?"),
2257 "action:magit-global-file-checkout-execute",
2258 lattice_grammar::Args::List(vec![
2259 lattice_grammar::ArgValue::String(rev.to_string()),
2260 lattice_grammar::ArgValue::String(path.to_string()),
2261 ]),
2262 ))
2263 }),
2264 args_schema: vec![
2265 ArgSpec::required("rev", lattice_grammar::ArgKind::String, "the revision")
2266 .with_picker(picker_sources::REVISION_PICK_SOURCE),
2267 ArgSpec::required("path", lattice_grammar::ArgKind::String, "the file"),
2268 ],
2269 surface_form: SurfaceForm::Keyword,
2270 },
2271 );
2272
2273 registry.register_ex_command(
2280 "magit-note-merge",
2281 "Merge a notes ref into the current notes: `<ref>`.",
2282 ExCommandSpec {
2283 latency_class: LatencyClass::Reflex,
2284 accepts_bang: false,
2285 accepts_range: false,
2286 parse_args: Arc::new(|line: &str, _bang: bool| {
2287 Ok(Args::String(line.trim().to_string()))
2288 }),
2289 apply: {
2290 let store = store.clone();
2291 let scopes = scopes.clone();
2292 Arc::new(move |ctx| {
2293 let Args::String(ref r) = ctx.args else {
2294 return Ok(Effect::Echo {
2295 level: lattice_grammar::EchoLevel::Error,
2296 text: "magit: usage — :magit-note-merge <ref>".to_string(),
2297 });
2298 };
2299 let r = r.trim().to_string();
2300 if r.is_empty() {
2301 return Ok(Effect::Echo {
2302 level: lattice_grammar::EchoLevel::Error,
2303 text: "magit: usage — :magit-note-merge <ref>".to_string(),
2304 });
2305 }
2306 Ok(magit_global_mode::spawn_note_merge(
2307 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2308 &r,
2309 ))
2310 })
2311 },
2312 args_schema: vec![
2313 ArgSpec::required(
2314 "ref",
2315 lattice_grammar::ArgKind::String,
2316 "the notes ref to merge",
2317 )
2318 .with_picker(picker_sources::REF_PICK_SOURCE),
2319 ],
2320 surface_form: SurfaceForm::Keyword,
2321 },
2322 );
2323
2324 registry.register_ex_command(
2328 "magit-find-file",
2329 "Open a file as it was at a revision: `<rev> <path>`.",
2330 ExCommandSpec {
2331 latency_class: LatencyClass::Reflex,
2332 accepts_bang: false,
2333 accepts_range: false,
2334 parse_args: Arc::new(|line: &str, _bang: bool| {
2335 Ok(Args::String(line.trim().to_string()))
2336 }),
2337 apply: {
2338 let store = store.clone();
2339 let scopes = scopes.clone();
2340 Arc::new(move |ctx| {
2341 let Args::String(ref spec) = ctx.args else {
2342 return Ok(find_file_usage());
2343 };
2344 match spec.split_once(char::is_whitespace) {
2345 Some((rev, path)) if !rev.is_empty() && !path.trim().is_empty() => {
2346 Ok(Effect::OpenSyntheticBuffer {
2347 name: repo_scope::repo_view_name_with(
2348 magit_file_revision_mode::FILE_VIEW,
2349 Some(&magit_file_revision_mode::file_view_rest(
2350 rev,
2351 std::path::Path::new(path.trim()),
2352 )),
2353 &store,
2354 &scopes,
2355 ctx.buffer_id,
2356 ),
2357 mode_id: "magit-file-revision-mode".to_string(),
2358 content: None,
2359 cursor: None,
2360 activate_minor: None,
2361 })
2362 }
2363 _ => Ok(find_file_usage()),
2364 }
2365 })
2366 },
2367 args_schema: vec![ArgSpec::required(
2368 "spec",
2369 lattice_grammar::ArgKind::String,
2370 "<rev> <path> — the revision, and the file to show at it",
2371 )],
2372 surface_form: SurfaceForm::Keyword,
2373 },
2374 );
2375 {
2380 let mut mk_subtree = |op: magit_global_mode::SubtreeOp, doc: &'static str| {
2381 let store = store.clone();
2382 let scopes = scopes.clone();
2383 registry.register_ex_command(
2384 op.ex_command,
2385 doc,
2386 ExCommandSpec {
2387 latency_class: LatencyClass::Reflex,
2388 accepts_bang: false,
2389 accepts_range: false,
2390 parse_args: Arc::new(|line: &str, _bang: bool| {
2391 Ok(Args::String(line.trim().to_string()))
2392 }),
2393 apply: Arc::new(move |ctx| {
2394 let line = match ctx.args {
2395 Args::String(ref l) => l.clone(),
2396 _ => String::new(),
2397 };
2398 Ok(magit_global_mode::spawn_subtree_op(
2399 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2400 op,
2401 &line,
2402 ))
2403 }),
2404 args_schema: vec![ArgSpec::required(
2405 "spec",
2406 lattice_grammar::ArgKind::String,
2407 op.usage(),
2408 )],
2409 surface_form: SurfaceForm::Keyword,
2410 },
2411 );
2412 };
2413 mk_subtree(
2414 magit_global_mode::SubtreeOp::ADD,
2415 "Add a repository as a subtree: `<prefix> <repository> <ref> [--squash]`.",
2416 );
2417 mk_subtree(
2418 magit_global_mode::SubtreeOp::MERGE,
2419 "Merge a ref into an existing subtree: `<prefix> <ref>`.",
2420 );
2421 mk_subtree(
2422 magit_global_mode::SubtreeOp::PULL,
2423 "Fetch and merge a subtree's upstream: `<prefix> <repository> <ref> [--squash]`.",
2424 );
2425 mk_subtree(
2426 magit_global_mode::SubtreeOp::PUSH,
2427 "Push a subtree's history to its own repository: `<prefix> <repository> <ref>`.",
2428 );
2429 mk_subtree(
2430 magit_global_mode::SubtreeOp::SPLIT,
2431 "Extract a subtree's history into its own branch: `<prefix>`.",
2432 );
2433 }
2434
2435 registry.register_ex_command(
2436 "magit-am",
2437 "Apply a mailbox of patches: `<patch>… [-3]`.",
2438 ExCommandSpec {
2439 latency_class: LatencyClass::Reflex,
2440 accepts_bang: false,
2441 accepts_range: false,
2442 parse_args: Arc::new(|line: &str, _bang: bool| {
2443 Ok(Args::String(line.trim().to_string()))
2444 }),
2445 apply: {
2446 let store = store.clone();
2447 let scopes = scopes.clone();
2448 Arc::new(move |ctx| {
2449 let Args::String(ref line) = ctx.args else {
2450 return Ok(am_usage());
2451 };
2452 match magit_global_mode::am_argv(
2453 line,
2454 magit_global_mode::am_wants_three_way(line),
2455 ) {
2456 Some(argv) => Ok(magit_global_mode::spawn_git(
2457 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2458 argv.clone(),
2459 &magit_global_mode::am_label(&argv),
2460 )),
2461 None => Ok(am_usage()),
2462 }
2463 })
2464 },
2465 args_schema: vec![ArgSpec::required(
2466 "patches",
2467 lattice_grammar::ArgKind::String,
2468 "<patch>… [-3]",
2469 )],
2470 surface_form: SurfaceForm::Keyword,
2471 },
2472 );
2473 registry.register_ex_command(
2474 "magit-format-patch",
2475 "Write a commit range out as .patch files in the repository root.",
2476 ExCommandSpec {
2477 latency_class: LatencyClass::Reflex,
2478 accepts_bang: false,
2479 accepts_range: false,
2480 parse_args: Arc::new(|line: &str, _bang: bool| {
2481 Ok(Args::String(line.trim().to_string()))
2482 }),
2483 apply: {
2484 let store = store.clone();
2485 let scopes = scopes.clone();
2486 Arc::new(move |ctx| {
2487 let Args::String(ref range) = ctx.args else {
2488 return Ok(format_patch_usage());
2489 };
2490 let root = repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id)
2493 .to_string_lossy()
2494 .into_owned();
2495 match magit_global_mode::format_patch_argv(
2496 range,
2497 (!root.is_empty()).then_some(root.as_str()),
2498 ) {
2499 Some(argv) => Ok(magit_global_mode::spawn_git(
2500 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2501 argv.clone(),
2502 &magit_global_mode::format_patch_label(&argv),
2503 )),
2504 None => Ok(format_patch_usage()),
2505 }
2506 })
2507 },
2508 args_schema: vec![ArgSpec::required(
2509 "range",
2510 lattice_grammar::ArgKind::String,
2511 "the commit range to turn into patches",
2512 )],
2513 surface_form: SurfaceForm::Keyword,
2514 },
2515 );
2516 registry.register_ex_command(
2517 "magit-cherries",
2518 "Show which commits are not upstream yet: `<upstream> [<head>]`.",
2519 ExCommandSpec {
2520 latency_class: LatencyClass::Reflex,
2521 accepts_bang: false,
2522 accepts_range: false,
2523 parse_args: Arc::new(|line: &str, _bang: bool| {
2524 Ok(Args::String(line.trim().to_string()))
2525 }),
2526 apply: {
2527 let store = store.clone();
2528 let scopes = scopes.clone();
2529 Arc::new(move |ctx| {
2530 let Args::String(ref spec) = ctx.args else {
2531 return Ok(cherries_usage());
2532 };
2533 let spec = spec.trim();
2534 if spec.is_empty() {
2535 return Ok(cherries_usage());
2536 }
2537 let (upstream, head) = match spec.split_once(char::is_whitespace) {
2538 Some((u, h)) if !h.trim().is_empty() => (u, h.trim()),
2539 _ => (spec, "HEAD"),
2540 };
2541 Ok(Effect::OpenSyntheticBuffer {
2542 name: repo_scope::repo_view_name_with(
2543 magit_cherry_mode::CHERRY_VIEW,
2544 Some(&magit_cherry_mode::cherry_view_rest(upstream, head)),
2545 &store,
2546 &scopes,
2547 ctx.buffer_id,
2548 ),
2549 mode_id: "magit-cherry-mode".to_string(),
2550 content: None,
2551 cursor: None,
2552 activate_minor: None,
2553 })
2554 })
2555 },
2556 args_schema: vec![ArgSpec::required(
2557 "spec",
2558 lattice_grammar::ArgKind::String,
2559 "<upstream> [<head>] — what to compare against, and what to compare",
2560 )],
2561 surface_form: SurfaceForm::Keyword,
2562 },
2563 );
2564
2565 registry.register_ex_command(
2569 "magit-note-edit",
2570 "Edit the note on a commit — opens an editable buffer.",
2571 ExCommandSpec {
2572 latency_class: LatencyClass::Reflex,
2573 accepts_bang: false,
2574 accepts_range: false,
2575 parse_args: Arc::new(|line: &str, _bang: bool| {
2576 Ok(Args::String(line.trim().to_string()))
2577 }),
2578 apply: {
2579 let store = store.clone();
2580 let scopes = scopes.clone();
2581 Arc::new(move |ctx| {
2582 let Args::String(ref commit) = ctx.args else {
2583 return Ok(note_usage("magit-note-edit"));
2584 };
2585 let commit = commit.trim();
2586 if commit.is_empty() {
2587 return Ok(note_usage("magit-note-edit"));
2588 }
2589 Ok(Effect::OpenSyntheticBuffer {
2590 name: repo_scope::repo_view_name_with(
2591 magit_notes_mode::NOTE_VIEW,
2592 Some(commit),
2593 &store,
2594 &scopes,
2595 ctx.buffer_id,
2596 ),
2597 mode_id: "magit-notes-mode".to_string(),
2598 content: None,
2599 cursor: None,
2600 activate_minor: None,
2601 })
2602 })
2603 },
2604 args_schema: vec![
2605 ArgSpec::required(
2606 "commit",
2607 lattice_grammar::ArgKind::String,
2608 "the commit whose note to edit",
2609 )
2610 .with_picker(picker_sources::COMMIT_PICK_SOURCE),
2611 ],
2612 surface_form: SurfaceForm::Keyword,
2613 },
2614 );
2615 registry.register_ex_command(
2616 "magit-note-remove",
2617 "Remove the note from a commit.",
2618 ExCommandSpec {
2619 latency_class: LatencyClass::Reflex,
2620 accepts_bang: false,
2621 accepts_range: false,
2622 parse_args: Arc::new(|line: &str, _bang: bool| {
2623 Ok(Args::String(line.trim().to_string()))
2624 }),
2625 apply: {
2626 let store = store.clone();
2627 let scopes = scopes.clone();
2628 Arc::new(move |ctx| {
2629 let Args::String(ref commit) = ctx.args else {
2630 return Ok(note_usage("magit-note-remove"));
2631 };
2632 let commit = commit.trim();
2633 if commit.is_empty() {
2634 return Ok(note_usage("magit-note-remove"));
2635 }
2636 Ok(magit_global_mode::spawn_note_remove(
2637 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2638 commit.to_string(),
2639 ))
2640 })
2641 },
2642 args_schema: vec![
2643 ArgSpec::required(
2644 "commit",
2645 lattice_grammar::ArgKind::String,
2646 "the commit whose note to remove",
2647 )
2648 .with_picker(picker_sources::COMMIT_PICK_SOURCE),
2649 ],
2650 surface_form: SurfaceForm::Keyword,
2651 },
2652 );
2653 registry.register_ex_command(
2654 "magit-note-merge",
2655 "Merge a notes ref into this one: `<ref> [manual|ours|theirs|union|cat_sort_uniq]`.",
2656 ExCommandSpec {
2657 latency_class: LatencyClass::Reflex,
2658 accepts_bang: false,
2659 accepts_range: false,
2660 parse_args: Arc::new(|line: &str, _bang: bool| {
2661 Ok(Args::String(line.trim().to_string()))
2662 }),
2663 apply: {
2664 let store = store.clone();
2665 let scopes = scopes.clone();
2666 Arc::new(move |ctx| {
2667 let Args::String(ref spec) = ctx.args else {
2668 return Ok(note_merge_usage());
2669 };
2670 if spec.trim().is_empty() {
2671 return Ok(note_merge_usage());
2672 }
2673 Ok(magit_global_mode::spawn_note_merge(
2674 repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2675 spec,
2676 ))
2677 })
2678 },
2679 args_schema: vec![ArgSpec::required(
2680 "spec",
2681 lattice_grammar::ArgKind::String,
2682 "<notes-ref> [strategy]",
2683 )],
2684 surface_form: SurfaceForm::Keyword,
2685 },
2686 );
2687
2688 registry.register_ex_command(
2693 "magit-clone",
2694 "Clone a repository: `<url> [<destination>]`.",
2695 ExCommandSpec {
2696 latency_class: LatencyClass::Reflex,
2697 accepts_bang: false,
2698 accepts_range: false,
2699 parse_args: Arc::new(|line: &str, _bang: bool| {
2700 Ok(Args::String(line.trim().to_string()))
2701 }),
2702 apply: {
2703 Arc::new(move |ctx| {
2704 let Args::String(ref spec) = ctx.args else {
2705 return Ok(clone_usage());
2706 };
2707 let spec = spec.trim();
2708 if spec.is_empty() {
2709 return Ok(clone_usage());
2710 }
2711 let (url, dest) = match spec.split_once(char::is_whitespace) {
2712 Some((url, dest)) if !dest.trim().is_empty() => {
2713 (url, dest.trim().to_string())
2714 }
2715 _ => (spec, magit_global_mode::default_clone_dest(spec)),
2716 };
2717 if dest.is_empty() {
2718 return Ok(clone_usage());
2722 }
2723 Ok(magit_global_mode::spawn_clone(url.to_string(), dest))
2724 })
2725 },
2726 args_schema: vec![ArgSpec::required(
2727 "spec",
2728 lattice_grammar::ArgKind::String,
2729 "<url> [<destination>] — what to clone, and where",
2730 )],
2731 surface_form: SurfaceForm::Keyword,
2732 },
2733 );
2734
2735 registry.register_ex_command(
2744 "magit-log-merged",
2745 "Show the merge commit that brought <commit> into HEAD.",
2746 ExCommandSpec {
2747 latency_class: LatencyClass::Reflex,
2748 accepts_bang: false,
2749 accepts_range: false,
2750 parse_args: Arc::new(|line: &str, _bang: bool| {
2751 Ok(Args::String(line.trim().to_string()))
2752 }),
2753 apply: {
2754 let store = store.clone();
2755 let scopes = scopes.clone();
2756 Arc::new(move |ctx| {
2757 let Args::String(ref commit) = ctx.args else {
2758 return Ok(log_merged_usage());
2759 };
2760 let commit = commit.trim();
2761 if commit.is_empty() {
2762 return Ok(log_merged_usage());
2763 }
2764 Ok(Effect::OpenSyntheticBuffer {
2765 name: repo_scope::repo_view_name_with(
2766 magit_revision_mode::MERGED_VIEW,
2767 Some(commit),
2768 &store,
2769 &scopes,
2770 ctx.buffer_id,
2771 ),
2772 mode_id: "magit-revision-mode".to_string(),
2773 content: None,
2774 cursor: None,
2775 activate_minor: None,
2776 })
2777 })
2778 },
2779 args_schema: vec![
2780 ArgSpec::required(
2781 "commit",
2782 lattice_grammar::ArgKind::String,
2783 "the commit whose merge to find",
2784 )
2785 .with_picker(picker_sources::COMMIT_PICK_SOURCE),
2786 ],
2787 surface_form: SurfaceForm::Keyword,
2788 },
2789 );
2790 registry.register_ex_command(
2791 "magit-blame-reverse",
2792 "Reverse-blame a file: for each line as of <rev>, the last commit it existed in. \
2793 Takes `<rev> <path>`.",
2794 ExCommandSpec {
2795 latency_class: LatencyClass::Reflex,
2796 accepts_bang: false,
2797 accepts_range: false,
2798 parse_args: Arc::new(|line: &str, _bang: bool| {
2799 Ok(Args::String(line.trim().to_string()))
2800 }),
2801 apply: {
2808 let requests = blame_requests.clone();
2809 let store = store.clone();
2810 let scopes = scopes.clone();
2811 Arc::new(move |ctx| {
2812 let Args::String(ref spec) = ctx.args else {
2813 return Ok(reverse_blame_usage());
2814 };
2815 match spec.split_once(char::is_whitespace) {
2816 Some((rev, path)) if !rev.is_empty() && !path.trim().is_empty() => {
2817 let name = repo_scope::repo_view_name_with(
2823 magit_file_revision_mode::FILE_VIEW,
2824 Some(&magit_file_revision_mode::file_view_rest(
2825 rev,
2826 std::path::Path::new(path.trim()),
2827 )),
2828 &store,
2829 &scopes,
2830 ctx.buffer_id,
2831 );
2832 requests.put(
2833 name.clone(),
2834 magit_blame_mode::BlameDirection::Reverse,
2835 rev.to_string(),
2836 );
2837 Ok(Effect::Many(vec![
2838 Effect::OpenSyntheticBuffer {
2839 name,
2840 mode_id: "magit-file-revision-mode".to_string(),
2841 content: None,
2842 cursor: None,
2843 activate_minor: None,
2844 },
2845 Effect::ToggleMode {
2846 mode_name: "magit-blame-mode".to_string(),
2847 },
2848 ]))
2849 }
2850 _ => Ok(reverse_blame_usage()),
2851 }
2852 })
2853 },
2854 args_schema: vec![ArgSpec::required(
2855 "spec",
2856 lattice_grammar::ArgKind::String,
2857 "<rev> <path> — the revision to walk forward from, and the file",
2858 )],
2859 surface_form: SurfaceForm::Keyword,
2860 },
2861 );
2862 }
2863 {
2864 registry.register_ex_command(
2870 "magit-rebase",
2871 "Start an interactive rebase. With arg: the upstream ref to rebase onto \
2872 (default: the branch's configured upstream).",
2873 ExCommandSpec {
2874 latency_class: LatencyClass::Reflex,
2875 accepts_bang: false,
2876 accepts_range: false,
2877 parse_args: Arc::new(|line: &str, _bang: bool| {
2878 let trimmed = line.trim();
2879 if trimmed.is_empty() {
2880 Ok(Args::None)
2881 } else {
2882 Ok(Args::String(trimmed.to_string()))
2883 }
2884 }),
2885 apply: {
2886 let store = store.clone();
2887 let scopes = scopes.clone();
2888 Arc::new(move |ctx| {
2889 let upstream = match ctx.args {
2893 Args::String(ref u) if !u.trim().is_empty() => Some(u.trim()),
2894 _ => None,
2895 };
2896 Ok(Effect::OpenSyntheticBuffer {
2897 name: repo_scope::repo_view_name_with(
2898 "rebase",
2899 upstream,
2900 &store,
2901 &scopes,
2902 ctx.buffer_id,
2903 ),
2904 mode_id: "magit-rebase-mode".to_string(),
2905 content: None,
2906 cursor: None,
2907 activate_minor: None,
2908 })
2909 })
2910 },
2911 args_schema: vec![ArgSpec::optional(
2912 "upstream",
2913 lattice_grammar::ArgKind::String,
2914 "ref to rebase onto",
2915 )],
2916 surface_form: SurfaceForm::Keyword,
2917 },
2918 );
2919 }
2920 {
2921 registry.register_ex_command(
2928 "magit-branch-create",
2929 "Create a new branch from HEAD and check it out.",
2930 ExCommandSpec {
2931 latency_class: LatencyClass::Reflex,
2932 accepts_bang: false,
2933 accepts_range: false,
2934 parse_args: Arc::new(|line: &str, _bang: bool| {
2935 let trimmed = line.trim();
2936 if trimmed.is_empty() {
2937 Err(lattice_grammar::error::CommandError::BadArgs(
2938 "magit-branch-create: branch name required".to_string(),
2939 ))
2940 } else {
2941 Ok(Args::String(trimmed.to_string()))
2942 }
2943 }),
2944 apply: {
2945 let store = store.clone();
2946 let scopes = scopes.clone();
2947 Arc::new(move |ctx| {
2948 let Args::String(ref name) = ctx.args else {
2949 return Ok(Effect::Echo {
2950 level: lattice_grammar::EchoLevel::Error,
2951 text: "magit-branch-create: branch name required".to_string(),
2952 });
2953 };
2954 let name = name.clone();
2955 let workdir = repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id);
2958 tokio::task::spawn(tokio::task::spawn_blocking(move || {
2959 let Ok(repo) = lattice_vcs::Repository::discover(&workdir) else {
2960 tracing::error!(target: "lattice_magit", "branch create: repo discover failed");
2961 return;
2962 };
2963 if let Err(e) = lattice_vcs::Branch::create(&repo, &name, true, None) {
2964 tracing::error!(target: "lattice_magit", "branch create {name}: {e}");
2965 }
2966 }));
2967 Ok(Effect::Echo {
2968 level: lattice_grammar::EchoLevel::Info,
2969 text: "magit: creating branch…".to_string(),
2970 })
2971 })
2972 },
2973 args_schema: vec![ArgSpec::required(
2974 "name",
2975 lattice_grammar::ArgKind::String,
2976 "new branch name",
2977 )],
2978 surface_form: SurfaceForm::Keyword,
2979 },
2980 );
2981 }
2982 {
2983 registry.register_ex_command(
2994 "magit-branch-delete",
2995 "Delete a branch by name — asks first (force delete).",
2996 ExCommandSpec {
2997 latency_class: LatencyClass::Reflex,
2998 accepts_bang: false,
2999 accepts_range: false,
3000 parse_args: Arc::new(|line: &str, _bang: bool| {
3001 let trimmed = line.trim();
3002 if trimmed.is_empty() {
3003 Err(lattice_grammar::error::CommandError::BadArgs(
3004 "magit-branch-delete: branch name required".to_string(),
3005 ))
3006 } else {
3007 Ok(Args::String(trimmed.to_string()))
3008 }
3009 }),
3010 apply: Arc::new(|ctx| {
3011 let Args::String(ref name) = ctx.args else {
3012 return Ok(Effect::Echo {
3013 level: lattice_grammar::EchoLevel::Error,
3014 text: "magit-branch-delete: branch name required".to_string(),
3015 });
3016 };
3017 Ok(confirm::ask_target(
3018 format!("Delete branch {name}?"),
3019 "action:magit-global-branch-delete-execute",
3020 name.clone(),
3021 ))
3022 }),
3023 args_schema: vec![ArgSpec::required(
3024 "name",
3025 lattice_grammar::ArgKind::String,
3026 "branch to delete",
3027 )],
3028 surface_form: SurfaceForm::Keyword,
3029 },
3030 );
3031 }
3032}
3033
3034#[cfg(test)]
3042pub(crate) fn register_action_commands_for_test(registry: &mut CommandRegistry) {
3043 register_action_commands(registry);
3044}
3045
3046#[cfg(test)]
3051pub(crate) fn register_ex_commands_for_test(registry: &mut CommandRegistry) {
3052 register_ex_commands(
3053 registry,
3054 Default::default(),
3055 crate::repo_scope::test_support::empty_store(),
3056 Default::default(),
3057 );
3058}
3059
3060fn register_action_commands(registry: &mut CommandRegistry) {
3061 let none = Some(Arc::new(
3062 |_: &lattice_grammar::ActionContext| -> GrammarResult<Effect> { Ok(Effect::None) },
3063 )
3064 as Arc<
3065 dyn Fn(&lattice_grammar::ActionContext) -> GrammarResult<Effect> + Send + Sync,
3066 >);
3067
3068 let mut reg = |name: &str, doc: &str| {
3069 registry.register_action(
3070 name,
3071 doc,
3072 ActionSpec {
3073 apply: none.clone().unwrap(),
3074 args_schema: Vec::new(),
3075 },
3076 );
3077 };
3078
3079 reg("action:magit-stage", "Stage the hunk or file at cursor");
3081 reg("action:magit-unstage", "Unstage the hunk or file at cursor");
3082 reg("action:magit-discard", "Discard the hunk or file at cursor");
3083 reg(
3084 "action:magit-discard-execute",
3085 "Execute the discard after confirmation",
3086 );
3087 reg(
3088 "action:magit-discard-untracked-execute",
3089 "Delete the untracked file after confirmation",
3090 );
3091 reg(
3092 "action:magit-discard-batch-execute",
3093 "Discard every file in the confirmed selection",
3094 );
3095 reg("action:magit-commit", "Open the commit buffer");
3096 reg("action:magit-commit-amend", "Amend the previous commit");
3097 reg("action:magit-toggle-diff", "Toggle inline diff at cursor");
3098 reg(
3099 "action:magit-diff-file",
3100 "Open file diff in a dedicated buffer",
3101 );
3102 reg(
3106 "action:magit-project-diff",
3107 "Open every changed file as one editable diff view",
3108 );
3109 reg(
3110 "action:magit-stage-patch",
3111 "Stage hunk interactively (git add -p)",
3112 );
3113 reg("action:magit-visit", "Context-aware open/visit at cursor");
3114 reg(
3119 "action:magit-visit-diff-target",
3120 "Visit the file at cursor, in the version this view describes",
3121 );
3122 reg(
3125 "action:magit-apply-hunk",
3126 "Apply the hunk at cursor to the working tree",
3127 );
3128 reg(
3129 "action:magit-reverse-hunk",
3130 "Reverse the hunk at cursor out of the working tree",
3131 );
3132 reg(
3136 "action:magit-jump-staged",
3137 "Jump to the Staged changes section",
3138 );
3139 reg(
3140 "action:magit-jump-unstaged",
3141 "Jump to the Unstaged changes section",
3142 );
3143 reg(
3144 "action:magit-jump-untracked",
3145 "Jump to the Untracked files section",
3146 );
3147 reg("action:magit-jump-stashes", "Jump to the Stashes section");
3148 reg(
3149 "action:magit-jump-unmerged",
3150 "Jump to the Unmerged into <upstream> section",
3151 );
3152 reg(
3153 "action:magit-jump-commits",
3154 "Jump to the Recent commits section",
3155 );
3156
3157 reg(
3159 "action:magit-blob-previous",
3160 "Visit this file at the previous revision",
3161 );
3162 reg(
3163 "action:magit-blob-next",
3164 "Visit this file at the next revision",
3165 );
3166
3167 reg(
3173 crate::providers::project_diff::REFRESH_ACTION,
3174 "Re-scan the project diff (the same comparison it already shows)",
3175 );
3176
3177 reg("action:magit-refresh", "Refresh the current magit buffer");
3179 reg("action:magit-close", "Close the magit buffer (bury)");
3180 reg(
3181 "action:magit-next-section",
3182 "Jump to the next top-level section",
3183 );
3184 reg(
3185 "action:magit-prev-section",
3186 "Jump to the previous top-level section",
3187 );
3188 reg(
3189 "action:magit-next-file",
3190 "Jump to the next file/entry in the current section",
3191 );
3192 reg(
3193 "action:magit-prev-file",
3194 "Jump to the previous file/entry in the current section",
3195 );
3196 reg("action:magit-next-hunk", "Jump to the next hunk");
3197 reg("action:magit-prev-hunk", "Jump to the previous hunk");
3198 reg(
3199 "action:magit-toggle-fold",
3200 "Toggle section/hunk fold at cursor",
3201 );
3202 reg("action:magit-cycle-sections", "Cycle section visibility");
3203
3204 reg(
3206 "action:magit-commit-confirm",
3207 "Create the commit with the entered message",
3208 );
3209 reg("action:magit-commit-abort", "Abort the commit");
3210
3211 reg(
3213 "action:magit-log-show-commit",
3214 "Show the commit detail at cursor",
3215 );
3216
3217 reg(
3219 "action:magit-blame-show-commit",
3220 "Show the commit for the blamed line",
3221 );
3222 reg("action:magit-blame-parent", "Re-blame at the parent commit");
3223 reg(
3224 "action:magit-blame-quit",
3225 "Stop blaming — the buffer becomes editable again",
3226 );
3227 reg(
3233 "action:magit-global-file-blame-reverse",
3234 "For each line of this revision of the file, the last commit it existed in",
3235 );
3236
3237 reg(
3246 "action:magit-global-log-merged",
3247 "Show the merge commit that brought a commit into HEAD",
3248 );
3249 reg(
3250 "action:magit-global-edit-line-commit",
3251 "Start a rebase that stops on the commit that wrote the line at the cursor",
3252 );
3253
3254 reg(
3256 "action:magit-refs-show",
3257 "Show the commit the ref at cursor points at",
3258 );
3259 reg(
3260 "action:magit-refs-checkout",
3261 "Check out the ref at cursor (refuses on a tag or remote-tracking branch)",
3262 );
3263
3264 reg("action:magit-stash-apply", "Apply the stash at cursor");
3266 reg("action:magit-stash-pop", "Pop the stash at cursor");
3267 reg("action:magit-stash-drop", "Drop the stash at cursor");
3268 reg(
3271 "action:magit-stash-drop-execute",
3272 "Execute the stash drop after confirmation",
3273 );
3274 reg("action:magit-stash-create", "Create a new stash");
3275 reg(
3277 "action:magit-stash-show",
3278 "Show the patch of the stash at cursor",
3279 );
3280
3281 reg(
3285 "action:magit-cherry-pick",
3286 "Cherry-pick the commit at cursor onto the current branch",
3287 );
3288 reg(
3289 "action:magit-revert",
3290 "Revert the commit at cursor (creates an inverse commit)",
3291 );
3292 reg(
3295 "action:magit-revert-changes",
3296 "Apply the inverse of the commit at cursor without committing",
3297 );
3298 reg(
3299 "action:magit-cherry-pick-apply",
3300 "Apply the commit at cursor's changes without committing",
3301 );
3302 reg(
3303 "action:magit-reset-soft",
3304 "Reset --soft to the commit at cursor (keeps index + working tree)",
3305 );
3306 reg(
3307 "action:magit-reset-mixed",
3308 "Reset --mixed to the commit at cursor (keeps working tree)",
3309 );
3310 reg(
3311 "action:magit-reset-hard",
3312 "Reset --hard to the commit at cursor (DISCARDS working tree; asks first)",
3313 );
3314 reg(
3315 "action:magit-reset-keep",
3316 "Reset --keep to the commit at cursor (refuses rather than discarding your work)",
3317 );
3318 reg(
3319 "action:magit-reset-index",
3320 "Reset the index to the commit at cursor, leaving HEAD and the working tree alone",
3321 );
3322 reg(
3323 "action:magit-commit-fixup",
3324 "Record a fixup! commit for the commit at cursor (folded by rebase --autosquash)",
3325 );
3326 reg(
3327 "action:magit-commit-squash",
3328 "Record a squash! commit for the commit at cursor (folded by rebase --autosquash)",
3329 );
3330 reg(
3331 "action:magit-reset-hard-execute",
3332 "Execute the hard reset after confirmation",
3333 );
3334
3335 reg(
3337 "action:magit-branch-checkout",
3338 "Check out the branch at cursor",
3339 );
3340 reg("action:magit-branch-create", "Create a new branch");
3341 reg("action:magit-branch-delete", "Delete the branch at cursor");
3342 reg(
3346 "action:magit-branch-delete-execute",
3347 "Execute the branch delete after confirmation",
3348 );
3349 reg(
3350 "action:magit-branch-merge",
3351 "Merge the branch at cursor into current",
3352 );
3353
3354 reg(
3360 "action:magit-global-remote",
3361 "Open the Magit remote list buffer",
3362 );
3363 reg(
3365 "action:magit-global-branch-checkout",
3366 "Pick a branch and check it out",
3367 );
3368 reg(
3369 "action:magit-global-branch-create",
3370 "Pick a base, then name a new branch",
3371 );
3372 reg(
3377 "action:magit-global-bisect-start",
3378 "Start a bisect (asks for a bad then a good revision)",
3379 );
3380 reg(
3381 "action:magit-global-bisect-start-good",
3382 "Ask for the good revision after the bad one",
3383 );
3384 reg(
3385 "action:magit-global-bisect-start-finish",
3386 "Start the bisect once both ends are known",
3387 );
3388 reg(
3389 "action:magit-global-bisect-good",
3390 "Mark the revision git checked out as good",
3391 );
3392 reg(
3393 "action:magit-global-bisect-bad",
3394 "Mark the revision git checked out as bad",
3395 );
3396 reg(
3397 "action:magit-global-bisect-skip",
3398 "Skip a revision that cannot be tested",
3399 );
3400 reg(
3401 "action:magit-global-bisect-reset",
3402 "End the bisect and return to where it started",
3403 );
3404 reg("action:magit-submodule-add", "Add a submodule");
3406 reg(
3407 "action:magit-submodule-add-path",
3408 "Ask where to put the submodule after its URL",
3409 );
3410 reg(
3411 "action:magit-submodule-add-finish",
3412 "Add the submodule once its URL and path are known",
3413 );
3414 reg(
3415 "action:magit-submodule-update",
3416 "Initialise and check out the submodule at cursor",
3417 );
3418 reg(
3419 "action:magit-submodule-sync",
3420 "Re-copy the configured URL into the submodule at cursor",
3421 );
3422 reg(
3423 "action:magit-submodule-remove",
3424 "Remove the submodule at cursor (asks first)",
3425 );
3426 reg(
3427 "action:magit-global-submodule",
3428 "Open the Magit submodule list buffer",
3429 );
3430 reg(
3432 "action:magit-global-refs",
3433 "Open the Magit refs buffer — every branch, remote-tracking branch and tag",
3434 );
3435 reg(
3437 "action:magit-cherry-show",
3438 "Show the commit at cursor in the cherry list",
3439 );
3440 reg("action:magit-note-confirm", "Save this note");
3441 reg(
3442 "action:magit-note-abort",
3443 "Close the note buffer without saving",
3444 );
3445
3446 for (name, doc) in [
3448 (
3449 "action:magit-global-subtree-add",
3450 "Add a repository as a subtree",
3451 ),
3452 (
3453 "action:magit-global-subtree-merge",
3454 "Merge a ref into a subtree",
3455 ),
3456 (
3457 "action:magit-global-subtree-pull",
3458 "Fetch and merge a subtree's upstream",
3459 ),
3460 (
3461 "action:magit-global-subtree-push",
3462 "Push a subtree's history to its repository",
3463 ),
3464 (
3465 "action:magit-global-subtree-split",
3466 "Extract a subtree's history",
3467 ),
3468 (
3469 "action:magit-global-subtree-finish",
3470 "Run the subtree operation once its arguments are known",
3471 ),
3472 ("action:magit-global-am-apply", "Apply a mailbox of patches"),
3473 (
3474 "action:magit-global-am-apply-finish",
3475 "Run the patch apply once the files are known",
3476 ),
3477 (
3478 "action:magit-global-am-continue",
3479 "Resume a stopped patch apply",
3480 ),
3481 (
3482 "action:magit-global-am-skip",
3483 "Skip the patch that would not apply",
3484 ),
3485 (
3486 "action:magit-global-am-abort",
3487 "Abandon a stopped patch apply",
3488 ),
3489 (
3490 "action:magit-global-format-patch",
3491 "Write a commit range out as .patch files",
3492 ),
3493 (
3494 "action:magit-global-format-patch-finish",
3495 "Run format-patch once the range is known",
3496 ),
3497 (
3498 "action:magit-global-cherries",
3499 "Show which commits are not upstream yet",
3500 ),
3501 (
3502 "action:magit-global-cherries-finish",
3503 "Open the cherry list once the upstream is known",
3504 ),
3505 ] {
3506 reg(name, doc);
3507 }
3508
3509 reg(
3511 "action:magit-global-note-edit",
3512 "Edit the note on a commit — opens an editable buffer",
3513 );
3514 reg(
3515 "action:magit-global-note-remove",
3516 "Remove the note from a commit",
3517 );
3518 reg(
3519 "action:magit-global-note-prune",
3520 "Drop notes whose commit no longer exists (asks first)",
3521 );
3522 reg(
3523 "action:magit-global-note-prune-execute",
3524 "Execute the notes prune after confirmation",
3525 );
3526 reg(
3527 "action:magit-global-note-merge",
3528 "Merge another notes ref into this one",
3529 );
3530 reg(
3531 "action:magit-global-note-merge-finish",
3532 "Run the notes merge once the ref is known",
3533 );
3534 reg(
3535 "action:magit-global-note-merge-commit",
3536 "Finish a notes merge that stopped on a conflict",
3537 );
3538 reg(
3539 "action:magit-global-note-merge-abort",
3540 "Abandon a notes merge that stopped on a conflict",
3541 );
3542
3543 reg(
3545 "action:magit-global-clone",
3546 "Clone a repository — asks for the URL",
3547 );
3548 reg(
3549 "action:magit-global-clone-dest",
3550 "Second step of the clone wizard — asks where to put it",
3551 );
3552 reg(
3553 "action:magit-global-clone-finish",
3554 "Run the clone once the URL and destination are known",
3555 );
3556
3557 reg(
3558 "action:magit-view-arguments",
3559 "Re-run this view with different git arguments",
3560 );
3561 for menu in transients::ROOT_MENUS {
3572 reg(menu.action, menu.doc);
3573 }
3574 reg(
3578 "action:magit-diff-side-by-side",
3579 "Open the file at cursor side-by-side against its baseline",
3580 );
3581 reg("action:magit-remote-add", "Add a remote");
3582 reg(
3583 "action:magit-remote-add-url",
3584 "Ask for the new remote's URL after its name",
3585 );
3586 reg(
3587 "action:magit-remote-add-finish",
3588 "Add the remote once its name and URL are known",
3589 );
3590 reg("action:magit-remote-rename", "Rename the remote at cursor");
3591 reg(
3592 "action:magit-remote-rename-finish",
3593 "Rename the remote to the typed name",
3594 );
3595 reg("action:magit-remote-remove", "Remove the remote at cursor");
3596 reg(
3597 "action:magit-remote-set-url",
3598 "Set the URL of the remote at cursor",
3599 );
3600 reg(
3601 "action:magit-remote-set-url-finish",
3602 "Point the remote at the typed URL",
3603 );
3604 reg(
3605 "action:magit-remote-prune",
3606 "Delete local refs whose branch is gone from the remote at cursor",
3607 );
3608
3609 reg("action:magit-rebase-confirm", "Execute the rebase");
3611 reg("action:magit-rebase-abort", "Abort the rebase");
3612 reg(
3616 "action:magit-rebase-abort-execute",
3617 "Execute the rebase abort after confirmation",
3618 );
3619 reg(
3620 "action:magit-rebase-show-commit",
3621 "Show the commit detail at cursor",
3622 );
3623
3624 reg("action:magit-global-status", "Open the status buffer");
3630 reg("action:magit-global-commit", "Open the commit buffer");
3631 reg("action:magit-global-amend", "Amend the previous commit");
3632 reg("action:magit-global-log", "Open the log buffer");
3633 reg("action:magit-global-diff", "Open the diff buffer");
3634 reg("action:magit-global-branch", "Open the branch list");
3635 reg("action:magit-global-stash", "Open the stash list");
3636 reg(
3637 "action:magit-global-stash-create",
3638 "Stash the working tree (git stash push)",
3639 );
3640 reg("action:magit-global-rebase", "Start an interactive rebase");
3641 reg(
3645 "action:magit-global-rebase-continue",
3646 "Resume a rebase that stopped, after amending or resolving conflicts",
3647 );
3648 reg(
3649 "action:magit-global-rebase-skip",
3650 "Skip the commit a stopped rebase is sitting on",
3651 );
3652 reg(
3653 "action:magit-global-merge-continue",
3654 "Conclude a merge that stopped on a conflict, once the resolution is staged",
3655 );
3656 reg(
3657 "action:magit-global-merge-abort",
3658 "Abandon a merge in progress, restoring the branch",
3659 );
3660 reg(
3661 "action:magit-global-cherry-pick-continue",
3662 "Resume a cherry-pick that stopped on a conflict",
3663 );
3664 reg(
3665 "action:magit-global-cherry-pick-skip",
3666 "Skip the commit a stopped cherry-pick is sitting on",
3667 );
3668 reg(
3669 "action:magit-global-cherry-pick-abort",
3670 "Abandon a cherry-pick in progress, restoring the branch",
3671 );
3672 reg(
3673 "action:magit-global-revert-continue",
3674 "Resume a revert that stopped on a conflict",
3675 );
3676 reg(
3677 "action:magit-global-revert-skip",
3678 "Skip the commit a stopped revert is sitting on",
3679 );
3680 reg(
3681 "action:magit-global-revert-abort",
3682 "Abandon a revert in progress, restoring the branch",
3683 );
3684 reg(
3685 "action:magit-global-rebase-abort",
3686 "Abandon a rebase in progress, restoring the branch to where it started",
3687 );
3688 reg(
3689 "action:magit-global-fetch",
3690 "Fetch from the remote without merging",
3691 );
3692 reg(
3693 "action:magit-global-pull",
3694 "Fetch + fast-forward merge from the remote",
3695 );
3696 reg("action:magit-global-push", "Push to the remote");
3697 reg(
3698 "action:magit-global-stash-keep-index",
3699 "Stash everything but leave the index staged",
3700 );
3701 reg(
3702 "action:magit-global-stash-staged",
3703 "Stash only the staged changes",
3704 );
3705 reg(
3707 "action:magit-global-stash-snapshot",
3708 "Stash everything and put it straight back — a restore point that costs nothing",
3709 );
3710 reg(
3711 "action:magit-global-stash-snapshot-index",
3712 "Snapshot the staged changes without disturbing the working tree",
3713 );
3714 reg(
3715 "action:magit-global-stash-snapshot-worktree",
3716 "Snapshot the working tree without disturbing the index",
3717 );
3718 reg(
3719 "action:magit-global-branch-reset-finish",
3720 "Ask before resetting the current branch to the named ref",
3721 );
3722 for (name, doc) in [
3724 (
3725 "action:magit-cherry-harvest",
3726 "Move a commit here from another branch, removing it there",
3727 ),
3728 (
3729 "action:magit-cherry-donate",
3730 "Move a commit to another branch, staying on this one",
3731 ),
3732 (
3733 "action:magit-cherry-spinout",
3734 "Move a commit to a new branch, staying on this one",
3735 ),
3736 (
3737 "action:magit-cherry-spinoff",
3738 "Move a commit to a new branch and check it out",
3739 ),
3740 ] {
3741 reg(name, doc);
3742 reg(
3743 Box::leak(format!("{name}-finish").into_boxed_str()),
3744 "Run the cherry move once the branch is named",
3745 );
3746 }
3747 for (name, doc) in [
3748 (
3749 "action:magit-global-branch-spinoff",
3750 "Branch the unpushed commits and check it out",
3751 ),
3752 (
3753 "action:magit-global-branch-spinout",
3754 "Branch the unpushed commits, staying on this branch",
3755 ),
3756 ] {
3757 reg(name, doc);
3758 reg(
3759 Box::leak(format!("{name}-finish").into_boxed_str()),
3760 "Create the branch once it is named",
3761 );
3762 }
3763 reg(
3765 "action:magit-reset-worktree",
3766 "Reset the working tree to the commit at cursor, keeping HEAD and the index",
3767 );
3768 reg(
3769 "action:magit-global-fetch-submodules",
3770 "Fetch the superproject and its submodules",
3771 );
3772 reg(
3774 "action:magit-global-merge-preview",
3775 "Show what merging a branch would bring in (asks which)",
3776 );
3777 reg(
3778 "action:magit-global-merge-into",
3779 "Merge this branch into another, then delete this one (asks which)",
3780 );
3781 reg(
3782 "action:magit-global-tag-release",
3783 "Create an annotated release tag (asks name and message)",
3784 );
3785 reg(
3786 "action:magit-global-tag-prune",
3787 "Drop local tags that no longer exist on the remote (asks the remote)",
3788 );
3789 for (name, doc) in [
3791 (
3792 "action:magit-config-pull-rebase",
3793 "Set pull.rebase for this repository",
3794 ),
3795 (
3796 "action:magit-config-push-default",
3797 "Set remote.pushDefault for this repository",
3798 ),
3799 (
3800 "action:magit-config-fetch-prune",
3801 "Set fetch.prune for this repository",
3802 ),
3803 (
3804 "action:magit-config-tag-sign",
3805 "Set tag.gpgSign for this repository",
3806 ),
3807 (
3808 "action:magit-config-notes-ref",
3809 "Set core.notesRef for this repository",
3810 ),
3811 ] {
3812 reg(name, doc);
3813 reg(
3816 Box::leak(format!("{name}-finish").into_boxed_str()),
3817 "Write the configure row's new value",
3818 );
3819 }
3820 reg(
3822 "action:magit-rebase-edit-commit",
3823 "Replay history, stopping at a commit so you can change it",
3824 );
3825 reg(
3826 "action:magit-rebase-reword-commit",
3827 "Change an older commit's message",
3828 );
3829 reg(
3830 "action:magit-rebase-remove-commit",
3831 "Replay history without a commit",
3832 );
3833 reg(
3835 "action:magit-global-rebase-onto-push",
3836 "Rebase this branch onto its push target",
3837 );
3838 reg(
3839 "action:magit-global-rebase-onto-upstream",
3840 "Rebase this branch onto its upstream",
3841 );
3842 reg(
3843 "action:magit-global-rebase-onto-elsewhere",
3844 "Rebase this branch onto a ref you name (asks which)",
3845 );
3846 reg(
3847 "action:magit-global-rebase-subset",
3848 "Replay the commits after one ref onto another (asks both)",
3849 );
3850 reg(
3851 "action:magit-global-rebase-autosquash",
3852 "Replay, folding in fixup! and squash! markers (asks the base)",
3853 );
3854 reg(
3855 "action:magit-global-commit-extend",
3856 "Add staged changes to the last commit, keeping its message",
3857 );
3858 reg(
3859 "action:magit-global-branch-reset",
3860 "Reset the current branch to another ref (asks first)",
3861 );
3862 reg(
3863 "action:magit-commit-augment",
3864 "Record a squash! for a commit, carrying a note you write",
3865 );
3866 reg(
3867 "action:magit-global-merge-edit",
3868 "Merge a branch with a message you write (asks which)",
3869 );
3870 reg(
3871 "action:magit-global-reword",
3872 "Reword the last commit — its message only, leaving the index alone",
3873 );
3874 reg(
3875 "action:magit-global-reset-file",
3876 "Restore one file from a commit (asks for the commit, then the path)",
3877 );
3878 reg(
3879 "action:magit-global-stash-branch",
3880 "Start a branch from a stash (asks for the name, then the stash)",
3881 );
3882 reg(
3883 "action:magit-global-merge-absorb",
3884 "Merge a branch and delete it (asks which)",
3885 );
3886 reg(
3887 "action:magit-commit-instant-fixup",
3888 "Record a fixup! for a commit and fold it in immediately",
3889 );
3890 reg(
3891 "action:magit-commit-instant-squash",
3892 "Record a squash! for a commit and fold it in immediately",
3893 );
3894
3895 reg(
3900 "action:magit-global-push-configured",
3901 "Push to the configured push-remote (git resolves pushRemote / pushDefault)",
3902 );
3903 reg(
3904 "action:magit-global-push-upstream",
3905 "Push to this branch's @{upstream} — differs from the push-remote in a triangular workflow",
3906 );
3907 reg(
3908 "action:magit-global-push-elsewhere",
3909 "Push to a remote you name",
3910 );
3911 reg(
3912 "action:magit-global-push-other-branch",
3913 "Push a branch other than HEAD",
3914 );
3915 reg(
3916 "action:magit-global-push-refspecs",
3917 "Push explicit refspecs",
3918 );
3919 reg("action:magit-global-push-tag", "Push a single tag");
3920 reg("action:magit-global-push-all-tags", "Push every tag");
3921
3922 reg(
3923 "action:magit-global-pull-configured",
3924 "Pull from the configured remote",
3925 );
3926 reg(
3927 "action:magit-global-pull-upstream",
3928 "Pull from this branch's @{upstream}",
3929 );
3930 reg(
3931 "action:magit-global-pull-elsewhere",
3932 "Pull from a remote you name",
3933 );
3934
3935 reg(
3936 "action:magit-global-fetch-configured",
3937 "Fetch from the configured remote",
3938 );
3939 reg(
3940 "action:magit-global-fetch-upstream",
3941 "Fetch this branch's @{upstream}",
3942 );
3943 reg(
3944 "action:magit-global-fetch-elsewhere",
3945 "Fetch from a remote you name",
3946 );
3947 reg(
3948 "action:magit-global-fetch-other-branch",
3949 "Fetch a branch you name",
3950 );
3951 reg(
3952 "action:magit-global-fetch-refspecs",
3953 "Fetch explicit refspecs",
3954 );
3955 reg(
3956 "action:magit-global-fetch-all-remotes",
3957 "Fetch from every configured remote",
3958 );
3959
3960 reg("action:magit-global-tag", "Tag HEAD (asks for the name)");
3970 reg(
3971 "action:magit-global-tag-finish",
3972 "Create the tag with the typed name",
3973 );
3974 reg(
3975 "action:magit-global-gitignore",
3976 "Add a pattern to .gitignore (asks for it)",
3977 );
3978 reg(
3979 "action:magit-global-gitignore-finish",
3980 "Append the typed pattern to .gitignore",
3981 );
3982
3983 reg(
3985 "action:magit-global-file-untrack",
3986 "Stop tracking the file, keeping it on disk",
3987 );
3988 reg(
3989 "action:magit-global-file-delete",
3990 "Delete the file (asks first)",
3991 );
3992 reg(
3993 "action:magit-global-file-delete-execute",
3994 "Delete the file after confirmation",
3995 );
3996 reg(
3997 "action:magit-global-file-rename",
3998 "Rename the file (asks for the new name)",
3999 );
4000 reg(
4002 "action:magit-global-file-checkout",
4003 "Check the file out from a revision (asks for it, then confirms)",
4004 );
4005 reg(
4006 "action:magit-global-file-checkout-finish",
4007 "Confirm checking the file out from the typed revision",
4008 );
4009 reg(
4010 "action:magit-global-file-rename-finish",
4011 "Rename the file to the typed name",
4012 );
4013
4014 reg(
4016 "action:magit-global-init",
4017 "Initialize a git repository (asks for the directory)",
4018 );
4019 reg(
4020 "action:magit-global-init-finish",
4021 "Run git init in the typed directory",
4022 );
4023 reg("action:magit-global-merge", "Merge a branch (asks which)");
4024 reg(
4026 "action:magit-global-merge-no-commit",
4027 "Merge a branch but stop before committing (asks which)",
4028 );
4029 reg(
4030 "action:magit-global-merge-squash",
4031 "Squash a branch's changes into the index without a merge commit (asks which)",
4032 );
4033 reg(
4034 "action:magit-global-tag-delete",
4035 "Delete a local tag (asks which)",
4036 );
4037 reg(
4038 "action:magit-global-merge-finish",
4039 "Merge the typed branch into the current one",
4040 );
4041
4042 reg(
4044 "action:magit-global-stage-all",
4045 "Stage every tracked modification",
4046 );
4047 reg("action:magit-global-unstage-all", "Unstage everything");
4048
4049 reg(
4052 "action:magit-global-file-stage",
4053 "Stage the file in the current buffer",
4054 );
4055 reg(
4056 "action:magit-global-file-unstage",
4057 "Unstage the file in the current buffer",
4058 );
4059 reg(
4060 "action:magit-global-file-discard",
4061 "Discard changes to the file in the current buffer",
4062 );
4063 reg(
4064 "action:magit-global-file-discard-execute",
4065 "Execute the file discard after confirmation",
4066 );
4067 reg(
4068 "action:magit-global-file-diff",
4069 "Show diff for the file in the current buffer",
4070 );
4071 reg(
4072 "action:magit-global-file-log",
4073 "Show commit history for the file in the current buffer",
4074 );
4075 reg(
4079 "action:magit-global-file-at-revision",
4080 "Open the current file as it was at a revision you name",
4081 );
4082 reg(
4083 "action:magit-global-file-at-revision-finish",
4084 "Open the file once the revision is known",
4085 );
4086 reg(
4087 "action:magit-global-file-visit-live",
4088 "From a file-at-revision, open the working-tree copy at the same line",
4089 );
4090 reg(
4091 "action:magit-global-file-blame",
4092 "Blame the file in the current buffer",
4093 );
4094
4095 reg(
4101 "action:magit-branch-create-finish",
4102 "Create the new branch (from the picked base) with the typed name",
4103 );
4104
4105 reg(
4109 "action:magit-global-branch-checkout-rev",
4110 "Check out a branch or revision you name",
4111 );
4112 reg(
4113 "action:magit-global-branch-checkout-rev-finish",
4114 "Check out the typed branch or revision",
4115 );
4116 reg(
4117 "action:magit-global-branch-create-no-checkout",
4118 "Create a branch without checking it out",
4119 );
4120 reg(
4121 "action:magit-branch-create-no-checkout-finish",
4122 "Create the new branch (from the picked base) without checking it out",
4123 );
4124 reg("action:magit-global-branch-rename", "Rename a branch");
4125 reg(
4126 "action:magit-branch-rename-finish",
4127 "Rename the picked branch to the typed name",
4128 );
4129 reg(
4130 "action:magit-global-branch-delete",
4131 "Delete a branch — asks first",
4132 );
4133
4134 registry.register_action(
4141 "action:magit-view-refresh-args",
4142 "Re-run the current magit view with the chosen git arguments",
4143 ActionSpec {
4144 apply: none.clone().unwrap(),
4145 args_schema: magit_diff_mode::DIFF_ARGS
4146 .iter()
4147 .chain(magit_log_mode::LOG_ARGS.iter())
4148 .map(|f| {
4149 let kind = match f.kind {
4150 magit_global_mode::RemoteArgKind::Flag => lattice_grammar::ArgKind::Bool,
4151 _ => lattice_grammar::ArgKind::String,
4152 };
4153 lattice_grammar::ArgSpec::optional(f.name, kind, f.doc)
4154 })
4155 .collect(),
4156 },
4157 );
4158
4159 for (name, doc) in [
4175 (
4176 "action:magit-global-diff",
4177 "Open the diff view, with the chosen git arguments",
4178 ),
4179 (
4180 "action:magit-global-log",
4181 "Open the log view, with the chosen git arguments",
4182 ),
4183 ] {
4184 registry.register_action(
4185 name,
4186 doc,
4187 ActionSpec {
4188 apply: none.clone().unwrap(),
4189 args_schema: magit_core_mode::VIEW_ARG_TABLES
4190 .iter()
4191 .flat_map(|t| t.iter())
4192 .map(|f| {
4193 let kind = match f.kind {
4194 magit_global_mode::RemoteArgKind::Flag => {
4195 lattice_grammar::ArgKind::Bool
4196 }
4197 _ => lattice_grammar::ArgKind::String,
4198 };
4199 lattice_grammar::ArgSpec::optional(f.name, kind, f.doc)
4200 })
4201 .collect(),
4202 },
4203 );
4204 }
4205
4206 for (name, doc, slots) in CONFIRM_TARGET_ACTIONS {
4224 registry.register_action(
4225 name,
4226 doc,
4227 ActionSpec {
4228 apply: none.clone().unwrap(),
4229 args_schema: slots
4230 .iter()
4231 .map(|(slot, slot_doc)| {
4232 ArgSpec::optional(*slot, lattice_grammar::ArgKind::String, *slot_doc)
4233 })
4234 .collect(),
4235 },
4236 );
4237 }
4238
4239 for (name, doc) in FILE_TARGET_ACTIONS {
4240 registry.register_action(
4241 name,
4242 doc,
4243 ActionSpec {
4244 apply: none.clone().unwrap(),
4245 args_schema: vec![ArgSpec::optional(
4246 "file",
4247 lattice_grammar::ArgKind::String,
4248 "Repo-relative path to act on; the visited file when unset",
4249 )],
4250 },
4251 );
4252 }
4253}
4254
4255#[cfg(test)]
4256mod tests {
4257 use super::*;
4258
4259 fn declared_flag_names() -> std::collections::HashSet<&'static str> {
4284 use magit_global_mode::RemoteOp;
4285 [
4286 RemoteOp::PULL,
4287 RemoteOp::PUSH,
4288 RemoteOp::FETCH,
4289 RemoteOp::STASH,
4290 ]
4291 .iter()
4292 .flat_map(|op| op.flags.iter().map(|f| f.name))
4293 .chain(
4294 magit_core_mode::VIEW_ARG_TABLES
4300 .iter()
4301 .flat_map(|t| t.iter().map(|f| f.name)),
4302 )
4303 .collect()
4304 }
4305
4306 fn inert_items(spec: &lattice_picker::TransientSpec, path: &str) -> Vec<String> {
4307 let mut found = Vec::new();
4308 for group in &spec.groups {
4309 for item in &group.items {
4310 let where_ = format!("{path}{} / {}", group.label, item.label);
4311 match &item.kind {
4312 lattice_picker::TransientItemKind::Action { .. } => {}
4313 lattice_picker::TransientItemKind::Submenu(sub) => {
4314 found.extend(inert_items(sub, &format!("{where_} > ")));
4315 }
4316 lattice_picker::TransientItemKind::Flag { name, .. } => {
4317 if !declared_flag_names().contains(name.as_str()) {
4318 found.push(format!(
4319 "'{where_}' fell back to an inert Flag placeholder \
4320 named '{name}' — its action id failed to resolve"
4321 ));
4322 }
4323 }
4324 lattice_picker::TransientItemKind::Argument { name, .. } => {
4328 if !declared_flag_names().contains(name.as_str()) {
4329 found.push(format!(
4330 "'{where_}' is an Argument named '{name}' that no \
4331 RemoteOp declares — nothing will consume its value"
4332 ));
4333 }
4334 }
4335 lattice_picker::TransientItemKind::Variable { key, .. } => {
4343 if key.is_empty() {
4344 found.push(format!(
4345 "'{where_}' is a Variable with no config key — it would render a value it cannot read"
4346 ));
4347 }
4348 }
4349 other => found.push(format!("unexpected item kind for '{where_}': {other:?}")),
4350 }
4351 }
4352 }
4353 found
4354 }
4355
4356 fn probe_here() -> std::path::PathBuf {
4360 workdir::magit_workdir().unwrap_or_default()
4361 }
4362
4363 fn outside_magit() -> lattice_picker::TransientContext {
4364 lattice_picker::TransientContext::default()
4365 }
4366
4367 fn in_magit_status() -> lattice_picker::TransientContext {
4369 lattice_picker::TransientContext {
4370 major_mode: Some(MagitStatusMode::mode_id().as_str().to_string()),
4371 minor_modes: vec![MagitCoreMode::mode_id().as_str().to_string()],
4372 buffer: None,
4373 args: Default::default(),
4374 }
4375 }
4376
4377 fn in_magit_log() -> lattice_picker::TransientContext {
4380 lattice_picker::TransientContext {
4381 major_mode: Some(MagitLogMode::mode_id().as_str().to_string()),
4382 minor_modes: vec![MagitCoreMode::mode_id().as_str().to_string()],
4383 buffer: None,
4384 args: Default::default(),
4385 }
4386 }
4387
4388 fn top_level_keys(spec: &lattice_picker::TransientSpec) -> Vec<String> {
4390 spec.groups
4391 .iter()
4392 .flat_map(|g| &g.items)
4393 .flat_map(|i| i.key.clone())
4394 .collect()
4395 }
4396
4397 fn assert_no_inert_items(spec: &lattice_picker::TransientSpec) {
4398 let found = inert_items(spec, "");
4399 assert!(
4400 found.is_empty(),
4401 "inert transient items:\n {}",
4402 found.join("\n ")
4403 );
4404 }
4405
4406 #[test]
4421 fn no_two_modes_contribute_the_same_boot_action_handler() {
4422 use lattice_mode::Mode;
4423 let mut seen: Vec<(&'static str, &'static str)> = Vec::new();
4424 let mut collisions: Vec<String> = Vec::new();
4425
4426 macro_rules! collect {
4427 ($mode:expr, $label:literal) => {
4428 for c in $mode.action_handlers() {
4429 if let Some((prior, _)) = seen.iter().find(|(n, _)| *n == c.action_name) {
4430 let _ = prior;
4431 let owner = seen
4432 .iter()
4433 .find(|(n, _)| *n == c.action_name)
4434 .map(|(_, o)| *o)
4435 .unwrap_or("?");
4436 collisions.push(format!(
4437 "`{}` contributed by both `{}` and `{}` — the second \
4438 replaces the first at boot and dropping either kills \
4439 both; publish a `MagitView` instead",
4440 c.action_name, owner, $label
4441 ));
4442 } else {
4443 seen.push((c.action_name, $label));
4444 }
4445 }
4446 };
4447 }
4448
4449 collect!(MagitGlobalMode, "magit-global-mode");
4450 collect!(MagitCoreMode, "magit-core-mode");
4451 collect!(MagitStatusMode, "magit-status-mode");
4452 collect!(MagitCommitMode, "magit-commit-mode");
4453 collect!(MagitDiffMode, "magit-diff-mode");
4454 collect!(MagitLogMode, "magit-log-mode");
4455 collect!(MagitBlameMode, "magit-blame-mode");
4456 collect!(MagitStashMode, "magit-stash-mode");
4457 collect!(MagitBranchMode, "magit-branch-mode");
4458 collect!(MagitRemoteMode, "magit-remote-mode");
4459 collect!(MagitRefsMode, "magit-refs-mode");
4460 collect!(MagitNotesMode, "magit-notes-mode");
4461 collect!(MagitCherryMode, "magit-cherry-mode");
4462 collect!(MagitSubmoduleMode, "magit-submodule-mode");
4463 collect!(MagitRebaseMode, "magit-rebase-mode");
4464 collect!(MagitRevisionMode, "magit-revision-mode");
4465 collect!(MagitFileRevisionMode, "magit-file-revision-mode");
4466
4467 assert!(
4468 collisions.is_empty(),
4469 "duplicate boot action handlers:\n {}",
4470 collisions.join("\n ")
4471 );
4472 }
4473
4474 #[test]
4483 fn every_destructive_execute_declares_the_slots_its_confirm_carries() {
4484 let mut registry = CommandRegistry::new();
4485 register_action_commands(&mut registry);
4486 for (name, _, slots) in CONFIRM_TARGET_ACTIONS {
4487 let spec = registry
4488 .lookup_by_name(name)
4489 .unwrap_or_else(|| panic!("`{name}` is registered"));
4490 let declared: Vec<&str> = spec.args_schema.iter().map(|a| a.name.as_ref()).collect();
4491 let expected: Vec<&str> = slots.iter().map(|(s, _)| *s).collect();
4492 assert_eq!(
4493 declared, expected,
4494 "`{name}`'s schema must match the slots its confirm carries, \
4495 in order — the host projects positionally into these names"
4496 );
4497 }
4498 }
4499
4500 #[test]
4514 fn every_commit_picker_arg_names_a_registered_ex_command() {
4515 let mut registry = CommandRegistry::new();
4516 register_ex_commands(
4517 &mut registry,
4518 Default::default(),
4519 crate::repo_scope::test_support::empty_store(),
4520 Default::default(),
4521 );
4522
4523 let picker_args = [
4527 magit_global_mode::CommitOp::CHERRY_PICK.ex_command,
4528 magit_global_mode::CommitOp::REVERT.ex_command,
4529 magit_global_mode::CommitOp::RESET_SOFT.ex_command,
4530 magit_global_mode::CommitOp::RESET_MIXED.ex_command,
4531 magit_global_mode::CommitOp::RESET_HARD.ex_command,
4532 magit_global_mode::CommitOp::RESET_KEEP.ex_command,
4533 magit_global_mode::CommitOp::RESET_INDEX.ex_command,
4534 magit_global_mode::CommitOp::COMMIT_FIXUP.ex_command,
4535 magit_global_mode::CommitOp::COMMIT_SQUASH.ex_command,
4536 "magit-augment",
4539 "magit-rebase-edit-commit",
4541 "magit-rebase-remove-commit",
4542 "magit-rebase-reword-commit",
4543 ];
4544
4545 for name in picker_args {
4546 assert!(
4547 registry.lookup_by_name(name).is_some(),
4548 "`{name}` is opened as a commit-picker arg but is not a registered ex-command — picking a commit would do nothing"
4549 );
4550 }
4551 }
4552
4553 #[test]
4563 fn the_view_open_actions_declare_the_union_their_menus_project_onto() {
4564 let mut registry = CommandRegistry::new();
4565 register_action_commands(&mut registry);
4566 let union: Vec<&str> = magit_core_mode::VIEW_ARG_TABLES
4567 .iter()
4568 .flat_map(|t| t.iter().map(|f| f.name))
4569 .collect();
4570 for action in ["action:magit-global-diff", "action:magit-global-log"] {
4571 let id = registry
4572 .id_by_name(action)
4573 .unwrap_or_else(|| panic!("`{action}` is registered"));
4574 let spec = registry.lookup(id).expect("spec");
4575 let declared: Vec<&str> = spec.args_schema.iter().map(|a| a.name.as_ref()).collect();
4576 assert_eq!(
4577 declared, union,
4578 "`{action}` must declare the union, in union order — \
4579 `view_argv` indexes by position in it",
4580 );
4581 }
4582 }
4583
4584 #[test]
4591 fn a_log_toggle_survives_the_round_trip_to_argv() {
4592 use lattice_grammar::{ArgValue, Args};
4593 let union: Vec<&str> = magit_core_mode::VIEW_ARG_TABLES
4594 .iter()
4595 .flat_map(|t| t.iter().map(|f| f.name))
4596 .collect();
4597 let slot = union
4598 .iter()
4599 .position(|n| *n == "count")
4600 .expect("the log table offers `-n`");
4601
4602 let mut list = vec![ArgValue::Bool(false); union.len()];
4604 list[slot] = ArgValue::String("5".to_string());
4605 let args = Args::List(list);
4606
4607 let argv = magit_core_mode::view_argv(magit_log_mode::LOG_ARGS, &args);
4608 assert!(
4609 argv.iter().any(|a| a == "5"),
4610 "the log's `-n 5` must reach the argv, got {argv:?}",
4611 );
4612 let diff_argv = magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &args);
4615 assert!(
4616 !diff_argv.iter().any(|a| a == "5"),
4617 "a log flag must not reach a diff argv: {diff_argv:?}",
4618 );
4619 }
4620
4621 #[test]
4627 fn every_destructive_pair_carries_a_target_except_the_one_with_none() {
4628 let mut registry = CommandRegistry::new();
4629 register_action_commands(&mut registry);
4630 for (_, execute) in confirm::DESTRUCTIVE_ACTIONS {
4631 if *execute == "action:magit-rebase-abort-execute" {
4632 continue;
4633 }
4634 let spec = registry
4642 .lookup_by_name(execute)
4643 .unwrap_or_else(|| panic!("`{execute}` is registered"));
4644 assert!(
4645 !spec.args_schema.is_empty(),
4646 "`{execute}` is destructive but declares no argument slot — a \
4647 carried target would have nowhere to land, so it would \
4648 re-derive at answer time, and a refresh landing while the \
4649 dialog is open makes that a different target"
4650 );
4651 }
4652 }
4653
4654 #[test]
4665 fn the_section_acting_rows_appear_only_inside_a_magit_buffer() {
4666 let mut registry = CommandRegistry::new();
4667 register_action_commands(&mut registry);
4668 let ids = transients::MagitActionIds::resolve(®istry);
4669
4670 for ctx in [in_magit_status(), in_magit_log()] {
4671 let keys = top_level_keys(&transients::dispatch_transient(&ids, &ctx, &probe_here()));
4672 for k in ["a", "-", "x"] {
4673 assert!(
4674 keys.contains(&k.to_string()),
4675 "`{k}` must be offered in a magit buffer: {keys:?}"
4676 );
4677 }
4678 }
4679
4680 let keys = top_level_keys(&transients::dispatch_transient(
4681 &ids,
4682 &outside_magit(),
4683 &probe_here(),
4684 ));
4685 for k in ["a", "-", "x"] {
4686 assert!(
4687 !keys.contains(&k.to_string()),
4688 "`{k}` acts on the hunk at cursor — it must not appear \
4689 outside a magit buffer: {keys:?}"
4690 );
4691 }
4692 for ctx in [in_magit_status(), in_magit_log(), outside_magit()] {
4695 let keys = top_level_keys(&transients::dispatch_transient(&ids, &ctx, &probe_here()));
4696 assert!(keys.contains(&"S".to_string()) && keys.contains(&"U".to_string()));
4697 }
4698 }
4699
4700 #[test]
4709 fn remote_management_is_offered_everywhere_and_is_not_an_inert_row() {
4710 use lattice_picker::TransientItemKind;
4711
4712 let mut registry = CommandRegistry::new();
4713 register_action_commands(&mut registry);
4714 let ids = transients::MagitActionIds::resolve(®istry);
4715
4716 for ctx in [in_magit_status(), in_magit_log(), outside_magit()] {
4717 let item = transients::dispatch_transient(&ids, &ctx, &probe_here())
4718 .groups
4719 .iter()
4720 .flat_map(|g| &g.items)
4721 .find(|i| i.key.iter().any(|k| k == "M"))
4722 .cloned()
4723 .expect("the dispatch offers `M` in every context");
4724 assert!(
4725 matches!(item.kind, TransientItemKind::Action { .. }),
4726 "`M` resolved to {:?}, not a real action",
4727 item.label
4728 );
4729 }
4730 }
4731
4732 #[test]
4739 fn the_view_argument_schema_matches_the_tables_it_is_built_from() {
4740 let mut registry = CommandRegistry::new();
4741 register_action_commands(&mut registry);
4742 let spec = registry
4743 .lookup_by_name("action:magit-view-refresh-args")
4744 .expect("registered");
4745 let declared: Vec<&str> = spec.args_schema.iter().map(|a| a.name.as_ref()).collect();
4746 let expected: Vec<&str> = magit_core_mode::VIEW_ARG_TABLES
4747 .iter()
4748 .flat_map(|t| t.iter())
4749 .map(|f| f.name)
4750 .collect();
4751 assert_eq!(declared, expected);
4752 }
4753
4754 #[test]
4757 fn no_two_view_arguments_share_a_name() {
4758 let mut seen = std::collections::HashSet::new();
4759 for f in magit_core_mode::VIEW_ARG_TABLES
4760 .iter()
4761 .flat_map(|t| t.iter())
4762 {
4763 assert!(
4764 seen.insert(f.name),
4765 "`{}` appears in more than one view-argument table — \
4766 `view_argv` resolves slots by name",
4767 f.name
4768 );
4769 }
4770 }
4771
4772 #[test]
4775 fn a_view_never_receives_the_other_views_arguments() {
4776 use lattice_grammar::{ArgValue, Args};
4777 let all_set = Args::List(vec![
4779 ArgValue::Bool(true), ArgValue::Bool(true), ArgValue::String("3".into()), ArgValue::Bool(true), ArgValue::String("200".into()), ArgValue::String("dhruva".into()), ]);
4786
4787 let diff = magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &all_set);
4788 assert_eq!(diff, vec!["-w", "--stat", "--unified=3"]);
4789 assert!(
4790 !diff.iter().any(|a| a.contains("author") || a == "--all"),
4791 "a diff must not receive log arguments: {diff:?}"
4792 );
4793
4794 let log = magit_core_mode::view_argv(magit_log_mode::LOG_ARGS, &all_set);
4795 assert_eq!(log, vec!["--all", "-n", "200", "--author", "dhruva"]);
4796 assert!(
4797 !log.iter().any(|a| a == "-w" || a.starts_with("--unified")),
4798 "a log must not receive diff arguments: {log:?}"
4799 );
4800 }
4801
4802 #[test]
4806 fn the_context_argument_is_one_joined_token() {
4807 use lattice_grammar::{ArgValue, Args};
4808 let args = Args::List(vec![
4809 ArgValue::Bool(false),
4810 ArgValue::Bool(false),
4811 ArgValue::String("5".into()),
4812 ]);
4813 assert_eq!(
4814 magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &args),
4815 vec!["--unified=5"]
4816 );
4817 }
4818
4819 #[test]
4822 fn unset_view_arguments_contribute_nothing() {
4823 use lattice_grammar::{ArgValue, Args};
4824 let none = Args::List(vec![
4825 ArgValue::Bool(false),
4826 ArgValue::Bool(false),
4827 ArgValue::String(String::new()),
4828 ]);
4829 assert!(magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &none).is_empty());
4830 assert!(
4831 magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &Args::None).is_empty(),
4832 "no state at all is the same as nothing set"
4833 );
4834 }
4835
4836 #[test]
4840 fn the_argument_menu_follows_the_view_it_was_opened_in() {
4841 let mut registry = CommandRegistry::new();
4842 register_action_commands(&mut registry);
4843 let ids = transients::MagitActionIds::resolve(®istry);
4844
4845 let keys = |ctx: &lattice_picker::TransientContext| -> Vec<String> {
4846 transients::view_arguments_transient(&ids, ctx)
4847 .groups
4848 .iter()
4849 .flat_map(|g| &g.items)
4850 .flat_map(|i| i.key.clone())
4851 .collect()
4852 };
4853
4854 let in_diff = lattice_picker::TransientContext {
4855 major_mode: Some("magit-diff-mode".into()),
4856 minor_modes: vec!["magit-core-mode".into()],
4857 buffer: None,
4858 args: Default::default(),
4859 };
4860 let diff_keys = keys(&in_diff);
4861 for k in ["-w", "-s", "-U", "g"] {
4862 assert!(diff_keys.contains(&k.to_string()), "diff: {diff_keys:?}");
4863 }
4864 assert!(
4865 !diff_keys.contains(&"-A".to_string()),
4866 "diff: {diff_keys:?}"
4867 );
4868
4869 let in_log = lattice_picker::TransientContext {
4870 major_mode: Some("magit-log-mode".into()),
4871 minor_modes: vec!["magit-core-mode".into()],
4872 buffer: None,
4873 args: Default::default(),
4874 };
4875 let log_keys = keys(&in_log);
4876 for k in ["-a", "-n", "-A", "g"] {
4877 assert!(log_keys.contains(&k.to_string()), "log: {log_keys:?}");
4878 }
4879 assert!(!log_keys.contains(&"-w".to_string()), "log: {log_keys:?}");
4880
4881 let elsewhere = in_magit_status();
4884 assert!(keys(&elsewhere).is_empty());
4885 assert!(
4886 transients::view_arguments_transient(&ids, &elsewhere).groups[0]
4887 .label
4888 .contains("no arguments"),
4889 "a view without arguments must say so"
4890 );
4891 }
4892
4893 #[test]
4903 fn the_blame_minor_shares_no_chord_with_magit_core() {
4904 use lattice_mode::Mode;
4905
4906 let core: Vec<&str> = MagitCoreMode
4918 .keymap()
4919 .entries
4920 .iter()
4921 .map(|e| e.chord)
4922 .collect();
4923 for entry in MagitBlameMode.keymap().entries {
4924 assert!(
4925 !core.contains(&entry.chord),
4926 "`{}` is bound by BOTH magit-blame-mode and magit-core-mode, \
4927 and both are active on a blob buffer",
4928 entry.chord
4929 );
4930 }
4931 }
4932
4933 #[test]
4937 fn magit_core_activates_only_on_real_majors() {
4938 use lattice_mode::{ActivationPolicy, Mode};
4939 let ActivationPolicy::Majors(majors) = MagitCoreMode.activation_policy() else {
4940 panic!("magit-core-mode activates by major");
4941 };
4942 assert!(
4943 !majors.contains(&MagitBlameMode::mode_id()),
4944 "magit-blame-mode is a minor — it can never be an active MAJOR, so \
4945 this entry never matches"
4946 );
4947 assert_eq!(MagitBlameMode.kind(), lattice_mode::ModeKind::Minor);
4948 }
4949
4950 #[test]
4959 fn a_file_at_a_revision_is_reachable_directly() {
4960 use lattice_picker::TransientItemKind;
4961
4962 let mut actions = CommandRegistry::new();
4963 register_action_commands(&mut actions);
4964 let ids = transients::MagitActionIds::resolve(&actions);
4965 let row = transients::file_dispatch_transient(&ids)
4966 .groups
4967 .iter()
4968 .flat_map(|g| &g.items)
4969 .find(|i| i.key.iter().any(|k| k == "v"))
4970 .cloned()
4971 .expect("`C-c f v` must exist");
4972 assert!(
4973 matches!(row.kind, TransientItemKind::Action { .. }),
4974 "`v` resolved to {:?}, not a real action",
4975 row.label
4976 );
4977
4978 let mut ex = CommandRegistry::new();
4979 register_ex_commands(
4980 &mut ex,
4981 Default::default(),
4982 crate::repo_scope::test_support::empty_store(),
4983 Default::default(),
4984 );
4985 let id = ex
4986 .id_by_name("magit-find-file")
4987 .expect("`:magit-find-file` must exist");
4988 assert!(
4989 ex.ex_command_spec(id).is_some(),
4990 "`:magit-find-file` must be an EX command"
4991 );
4992 }
4993
4994 #[test]
5002 fn the_way_into_a_revision_and_the_way_back_are_separate_rows() {
5003 use lattice_picker::TransientItemKind;
5004
5005 let mut actions = CommandRegistry::new();
5006 register_action_commands(&mut actions);
5007 let ids = transients::MagitActionIds::resolve(&actions);
5008 let spec = transients::file_dispatch_transient(&ids);
5009
5010 let row = |key: &str| {
5011 spec.groups
5012 .iter()
5013 .flat_map(|g| &g.items)
5014 .find(|i| i.key.iter().any(|k| k == key))
5015 .cloned()
5016 .unwrap_or_else(|| panic!("`C-c f {key}` must exist"))
5017 };
5018 for key in ["v", "V"] {
5019 assert!(
5020 matches!(row(key).kind, TransientItemKind::Action { .. }),
5021 "`{key}` must be a real action"
5022 );
5023 }
5024 assert_ne!(
5025 transients::MagitActionIds::resolve(&actions)
5026 .get("action:magit-global-file-at-revision"),
5027 transients::MagitActionIds::resolve(&actions)
5028 .get("action:magit-global-file-visit-live"),
5029 "in and out are different actions, not one key guessing"
5030 );
5031 }
5032
5033 #[test]
5040 fn the_branch_submenu_keeps_the_list_it_replaced() {
5041 use lattice_picker::{TransientItemKind, TransientSpec};
5042
5043 let mut registry = CommandRegistry::new();
5044 register_action_commands(&mut registry);
5045 let ids = transients::MagitActionIds::resolve(®istry);
5046
5047 let item = transients::dispatch_transient(&ids, &outside_magit(), &probe_here())
5048 .groups
5049 .iter()
5050 .flat_map(|g| &g.items)
5051 .find(|i| i.key.iter().any(|k| k == "b"))
5052 .cloned()
5053 .expect("`b` must exist on the dispatch");
5054 let TransientItemKind::Submenu(spec) = &item.kind else {
5055 panic!("`b` must open a submenu, got {:?}", item.label);
5056 };
5057 let spec: &TransientSpec = spec;
5058 let rows: Vec<(String, bool)> = spec
5059 .groups
5060 .iter()
5061 .flat_map(|g| &g.items)
5062 .flat_map(|i| {
5063 let real = matches!(i.kind, TransientItemKind::Action { .. });
5064 i.key.iter().map(move |k| (k.clone(), real))
5065 })
5066 .collect();
5067
5068 for key in ["b", "l", "c", "n", "m", "k", "L", "x"] {
5082 let (_, real) = rows
5083 .iter()
5084 .find(|(k, _)| k == key)
5085 .unwrap_or_else(|| panic!("`b {key}` must exist: {rows:?}"));
5086 assert!(real, "`b {key}` must be a real action");
5087 }
5088 let key_for = |action: &str| {
5089 spec.groups
5090 .iter()
5091 .flat_map(|g| &g.items)
5092 .find(|i| {
5093 matches!(&i.kind, TransientItemKind::Action { command, .. }
5094 if Some(*command) == registry.id_by_name(action))
5095 })
5096 .and_then(|i| i.key.first().cloned())
5097 };
5098 assert_eq!(
5099 key_for("action:magit-global-branch-reset").as_deref(),
5100 Some("x"),
5101 "reset must be on magit's `x`: {rows:?}"
5102 );
5103 assert_eq!(
5104 key_for("action:magit-global-branch-delete").as_deref(),
5105 Some("k"),
5106 "delete must be on magit's `k`, NOT the `x` reset now owns: {rows:?}"
5107 );
5108 assert_eq!(
5109 transients::MagitActionIds::resolve(®istry).get("action:magit-global-branch"),
5110 registry.id_by_name("action:magit-global-branch"),
5111 "`b L` fires the SAME action `b` used to — the list did not \
5112 disappear when MG.32 moved it off `l`"
5113 );
5114 }
5115
5116 #[test]
5135 fn the_branch_submenu_keys_mean_what_magit_means_by_them() {
5136 use lattice_picker::{TransientItemKind, TransientSpec};
5137
5138 let mut registry = CommandRegistry::new();
5139 register_action_commands(&mut registry);
5140 let ids = transients::MagitActionIds::resolve(®istry);
5141
5142 let item = transients::dispatch_transient(&ids, &outside_magit(), &probe_here())
5143 .groups
5144 .iter()
5145 .flat_map(|g| &g.items)
5146 .find(|i| i.key.iter().any(|k| k == "b"))
5147 .cloned()
5148 .expect("`b` must exist on the dispatch");
5149 let TransientItemKind::Submenu(spec) = &item.kind else {
5150 panic!("`b` must open a submenu");
5151 };
5152 let spec: &TransientSpec = spec;
5153
5154 let action_for = |key: &str| -> Option<lattice_grammar::CommandId> {
5155 spec.groups
5156 .iter()
5157 .flat_map(|g| &g.items)
5158 .find(|i| i.key.iter().any(|k| k == key))
5159 .and_then(|i| match i.kind {
5160 TransientItemKind::Action { command, .. } => Some(command),
5161 _ => None,
5162 })
5163 };
5164
5165 for key in ["b", "l", "L"] {
5170 assert!(
5171 action_for(key).is_some(),
5172 "`b {key}` must resolve to a real action, or the assertions \
5173 below compare None to None and prove nothing"
5174 );
5175 }
5176
5177 assert_eq!(
5178 action_for("l"),
5179 registry.id_by_name("action:magit-global-branch-checkout"),
5180 "`l` is magit's checkout-LOCAL-branch, and that is exactly the \
5181 picker MG.29 had built — it only sat on the wrong key"
5182 );
5183 assert_eq!(
5184 action_for("b"),
5185 registry.id_by_name("action:magit-global-branch-checkout-rev"),
5186 "`b` is magit's branch/REVISION: it must reach the prompt that \
5187 accepts a tag / remote ref / SHA, not the local-branch list"
5188 );
5189 assert_eq!(
5190 action_for("L"),
5191 registry.id_by_name("action:magit-global-branch"),
5192 "the list buffer has no magit counterpart, so it takes `L` — \
5193 capital-as-variant, and a key magit's transient leaves free"
5194 );
5195 assert_ne!(
5196 action_for("b"),
5197 action_for("l"),
5198 "branch/revision and local-branch are different operations; one \
5199 of them pointing at the other is the MG.29 bug returning"
5200 );
5201 }
5202
5203 #[test]
5211 fn the_deferred_branch_rows_keep_their_magit_keys_free() {
5212 use lattice_picker::{TransientItemKind, TransientSpec};
5213
5214 let mut registry = CommandRegistry::new();
5215 register_action_commands(&mut registry);
5216 let ids = transients::MagitActionIds::resolve(®istry);
5217
5218 let item = transients::dispatch_transient(&ids, &outside_magit(), &probe_here())
5219 .groups
5220 .iter()
5221 .flat_map(|g| &g.items)
5222 .find(|i| i.key.iter().any(|k| k == "b"))
5223 .cloned()
5224 .expect("`b` must exist on the dispatch");
5225 let TransientItemKind::Submenu(spec) = &item.kind else {
5226 panic!("`b` must open a submenu");
5227 };
5228 let spec: &TransientSpec = spec;
5229 let taken: Vec<String> = spec
5230 .groups
5231 .iter()
5232 .flat_map(|g| &g.items)
5233 .flat_map(|i| i.key.iter().cloned())
5234 .collect();
5235
5236 let key_of = |action: &str| {
5242 spec.groups
5243 .iter()
5244 .flat_map(|g| &g.items)
5245 .find(|i| {
5246 matches!(&i.kind, TransientItemKind::Action { command, .. }
5247 if Some(*command) == registry.id_by_name(action))
5248 })
5249 .and_then(|i| i.key.first().cloned())
5250 };
5251 assert_eq!(
5252 key_of("action:magit-global-branch-spinoff").as_deref(),
5253 Some("s"),
5254 "spin-off must be on magit's `s`: {taken:?}"
5255 );
5256 assert_eq!(
5257 key_of("action:magit-global-branch-spinout").as_deref(),
5258 Some("S"),
5259 "spin-out must be on magit's `S`: {taken:?}"
5260 );
5261
5262 let configure = spec
5267 .groups
5268 .iter()
5269 .flat_map(|g| &g.items)
5270 .find(|i| i.key.iter().any(|k| k == "C"))
5271 .expect("`C` must be the configure row");
5272 assert!(
5273 matches!(configure.kind, TransientItemKind::Variable { .. }),
5274 "`C` must report its value inline, got {:?}",
5275 configure.kind
5276 );
5277 }
5278
5279 #[test]
5283 fn submenu_footers_offer_esc_as_back() {
5284 use lattice_picker::{TransientItemKind, TransientSpec};
5285
5286 let mut registry = CommandRegistry::new();
5287 register_action_commands(&mut registry);
5288 let ids = transients::MagitActionIds::resolve(®istry);
5289 let root = transients::dispatch_transient(&ids, &outside_magit(), &probe_here());
5290
5291 let mut checked = 0;
5292 for item in root.groups.iter().flat_map(|g| &g.items) {
5293 if let TransientItemKind::Submenu(spec) = &item.kind {
5294 let spec: &TransientSpec = spec;
5295 let footer = spec.footer.clone().unwrap_or_default();
5296 assert!(
5297 footer.contains("Esc"),
5298 "submenu {:?} does not offer Esc as back: {footer:?}",
5299 spec.title
5300 );
5301 checked += 1;
5302 }
5303 }
5304 assert!(checked >= 4, "expected several submenus, checked {checked}");
5305 }
5306
5307 #[test]
5313 fn submodule_management_is_offered_everywhere_and_is_not_an_inert_row() {
5314 use lattice_picker::TransientItemKind;
5315
5316 let mut registry = CommandRegistry::new();
5317 register_action_commands(&mut registry);
5318 let ids = transients::MagitActionIds::resolve(®istry);
5319
5320 for ctx in [in_magit_status(), in_magit_log(), outside_magit()] {
5321 let item = transients::dispatch_transient(&ids, &ctx, &probe_here())
5322 .groups
5323 .iter()
5324 .flat_map(|g| &g.items)
5325 .find(|i| i.key.iter().any(|k| k == "o"))
5326 .cloned()
5327 .expect("the dispatch offers `o` in every context");
5328 assert!(
5329 matches!(item.kind, TransientItemKind::Action { .. }),
5330 "`o` resolved to {:?}, not a real action",
5331 item.label
5332 );
5333 }
5334 }
5335
5336 #[test]
5340 fn the_submodule_buffer_is_reachable_by_ex_command_and_by_action() {
5341 let mut registry = CommandRegistry::new();
5342 register_ex_commands(
5343 &mut registry,
5344 Default::default(),
5345 crate::repo_scope::test_support::empty_store(),
5346 Default::default(),
5347 );
5348 let id = registry
5349 .id_by_name("magit-submodule")
5350 .expect("`:magit-submodule` must exist");
5351 assert!(
5352 registry.ex_command_spec(id).is_some(),
5353 "`:magit-submodule` must be an EX command, not an action of the same name"
5354 );
5355
5356 let mut actions = CommandRegistry::new();
5357 register_action_commands(&mut actions);
5358 assert!(
5359 actions
5360 .id_by_name("action:magit-global-submodule")
5361 .is_some(),
5362 "`o` fires `action:magit-global-submodule` — it must be registered"
5363 );
5364 assert_eq!(
5365 MagitSubmoduleMode::mode_id().as_str(),
5366 "magit-submodule-mode",
5367 "the mode id both open paths hardcode"
5368 );
5369 }
5370
5371 #[test]
5381 fn the_bisect_menu_offers_start_or_the_marks_but_never_both() {
5382 use lattice_picker::{TransientItemKind, TransientSpec};
5383
5384 let mut registry = CommandRegistry::new();
5385 register_action_commands(&mut registry);
5386 let ids = transients::MagitActionIds::resolve(®istry);
5387
5388 let bisect_keys = |in_progress: bool| -> Vec<String> {
5389 let root = transients::dispatch_transient_with(
5390 &ids,
5391 &outside_magit(),
5392 &transients::DispatchGates {
5393 workdir: Default::default(),
5394 merge: false,
5395 bisect: in_progress,
5396 notes_merge: false,
5397 am: false,
5398 rebase: false,
5399 cherry_pick: false,
5400 revert: false,
5401 },
5402 );
5403 let item = root
5404 .groups
5405 .iter()
5406 .flat_map(|g| &g.items)
5407 .find(|i| i.key.iter().any(|k| k == "B"))
5408 .expect("the dispatch offers `B`");
5409 let TransientItemKind::Submenu(spec) = &item.kind else {
5410 panic!("`B` must open a submenu, got {:?}", item.label);
5411 };
5412 let spec: &TransientSpec = spec;
5413 spec.groups
5414 .iter()
5415 .flat_map(|g| &g.items)
5416 .flat_map(|i| i.key.clone())
5417 .collect()
5418 };
5419
5420 let idle = bisect_keys(false);
5421 assert_eq!(idle, vec!["B"], "idle offers only start: {idle:?}");
5422
5423 let running = bisect_keys(true);
5424 for k in ["g", "b", "k", "r"] {
5425 assert!(
5426 running.contains(&k.to_string()),
5427 "`{k}` must be offered during a bisect: {running:?}"
5428 );
5429 }
5430 assert!(
5431 !running.contains(&"B".to_string()),
5432 "start must NOT be offered during a bisect: {running:?}"
5433 );
5434 }
5435
5436 #[test]
5440 fn every_bisect_row_resolves_to_a_real_action() {
5441 use lattice_picker::{TransientItemKind, TransientSpec};
5442
5443 let mut registry = CommandRegistry::new();
5444 register_action_commands(&mut registry);
5445 let ids = transients::MagitActionIds::resolve(®istry);
5446
5447 for in_progress in [false, true] {
5448 let root = transients::dispatch_transient_with(
5449 &ids,
5450 &outside_magit(),
5451 &transients::DispatchGates {
5452 workdir: Default::default(),
5453 merge: false,
5454 bisect: in_progress,
5455 notes_merge: false,
5456 am: false,
5457 rebase: false,
5458 cherry_pick: false,
5459 revert: false,
5460 },
5461 );
5462 let item = root
5463 .groups
5464 .iter()
5465 .flat_map(|g| &g.items)
5466 .find(|i| i.key.iter().any(|k| k == "B"))
5467 .expect("`B` row");
5468 let TransientItemKind::Submenu(spec) = &item.kind else {
5469 panic!("`B` must open a submenu");
5470 };
5471 let spec: &TransientSpec = spec;
5472 for row in spec.groups.iter().flat_map(|g| &g.items) {
5473 assert!(
5474 matches!(row.kind, TransientItemKind::Action { .. }),
5475 "bisect row {:?} is inert (in_progress={in_progress})",
5476 row.label
5477 );
5478 }
5479 }
5480 }
5481
5482 #[test]
5490 fn no_magit_mode_binds_m_or_b_as_a_chord() {
5491 use lattice_mode::Mode;
5492 macro_rules! check {
5493 ($($mode:expr => $label:literal),* $(,)?) => {
5494 $(for entry in $mode.keymap().entries {
5495 for taken in ["M", "B"] {
5496 assert!(
5497 entry.chord != taken,
5498 "`{}` binds `{taken}`, shadowing the vim motion — \
5499 put it on the dispatch transient instead", $label
5500 );
5501 }
5502 })*
5503 };
5504 }
5505 check!(
5506 MagitCoreMode => "magit-core-mode",
5507 MagitStatusMode => "magit-status-mode",
5508 MagitBranchMode => "magit-branch-mode",
5509 MagitRemoteMode => "magit-remote-mode",
5510 MagitRefsMode => "magit-refs-mode",
5511 MagitNotesMode => "magit-notes-mode",
5512 MagitCherryMode => "magit-cherry-mode",
5513 MagitSubmoduleMode => "magit-submodule-mode",
5514 MagitStashMode => "magit-stash-mode",
5515 MagitLogMode => "magit-log-mode",
5516 MagitDiffMode => "magit-diff-mode",
5517 MagitBlameMode => "magit-blame-mode",
5518 MagitRebaseMode => "magit-rebase-mode",
5519 MagitRevisionMode => "magit-revision-mode",
5520 MagitFileRevisionMode => "magit-file-revision-mode",
5521 magit_stash_show_mode::MagitStashShowMode => "magit-stash-show-mode",
5522 magit_hunk_mode::MagitHunkMode => "magit-hunk-mode",
5523 magit_nav_mode::MagitNavMode => "magit-nav-mode",
5525 );
5526 }
5527
5528 #[test]
5533 fn the_status_row_becomes_a_section_jump_only_in_the_status_buffer() {
5534 let mut registry = CommandRegistry::new();
5535 register_action_commands(&mut registry);
5536 let ids = transients::MagitActionIds::resolve(®istry);
5537
5538 let row = |ctx: &lattice_picker::TransientContext| {
5539 transients::dispatch_transient(&ids, ctx, &probe_here())
5540 .groups
5541 .iter()
5542 .flat_map(|g| &g.items)
5543 .find(|i| i.key.iter().any(|k| k == "s"))
5544 .map(|i| {
5545 (
5546 i.label.clone(),
5547 matches!(i.kind, lattice_picker::TransientItemKind::Submenu(_)),
5548 )
5549 })
5550 .expect("`s` is always offered")
5551 };
5552
5553 assert_eq!(
5554 row(&in_magit_status()),
5555 ("jump".to_string(), true),
5556 "in the status buffer, `s` must be the section-jump submenu \
5557 — opening the buffer you are already in is a no-op"
5558 );
5559 for ctx in [in_magit_log(), outside_magit()] {
5560 let (label, is_submenu) = row(&ctx);
5561 assert_eq!(label, "status");
5562 assert!(
5563 !is_submenu,
5564 "outside the status buffer, `s` opens it — a magit-log \
5565 buffer has no sections to jump between"
5566 );
5567 }
5568 }
5569
5570 #[test]
5576 fn no_context_produces_a_duplicate_key_in_the_dispatch() {
5577 let mut registry = CommandRegistry::new();
5578 register_action_commands(&mut registry);
5579 let ids = transients::MagitActionIds::resolve(®istry);
5580 for ctx in [in_magit_status(), in_magit_log(), outside_magit()] {
5581 let keys = top_level_keys(&transients::dispatch_transient(&ids, &ctx, &probe_here()));
5582 let mut seen = std::collections::HashSet::new();
5583 for k in &keys {
5584 assert!(seen.insert(k.clone()), "duplicate key `{k}` in {keys:?}");
5585 }
5586 }
5587 }
5588
5589 #[test]
5595 fn the_jump_submenu_covers_every_section_we_render() {
5596 let mut registry = CommandRegistry::new();
5597 register_action_commands(&mut registry);
5598 let ids = transients::MagitActionIds::resolve(®istry);
5599 let spec = transients::dispatch_transient(&ids, &in_magit_status(), &probe_here());
5600 let jump = spec
5601 .groups
5602 .iter()
5603 .flat_map(|g| &g.items)
5604 .find_map(|i| match &i.kind {
5605 lattice_picker::TransientItemKind::Submenu(sub) if i.label == "jump" => {
5606 Some(std::sync::Arc::clone(sub))
5607 }
5608 _ => None,
5609 })
5610 .expect("the jump submenu");
5611 assert_eq!(
5612 jump.selectable_count(),
5613 sections::SECTION_HEADER_PREFIXES.len(),
5614 "one row per rendered section, no more and no fewer"
5615 );
5616 assert_no_inert_items(&jump);
5617 }
5618
5619 #[test]
5634 fn every_commit_showing_view_overrides_commit_at_cursor() {
5635 use crate::buffer_state::MagitView;
5636 use lattice_protocol::position::Position;
5637
5638 struct DefaultOnly;
5642 impl MagitView for DefaultOnly {
5643 fn refresh(&self) -> Option<Effect> {
5644 None
5645 }
5646 }
5647 assert!(
5648 DefaultOnly.commit_at_cursor(Position::new(0, 0)).is_none(),
5649 "the trait default must answer None — this test's premise"
5650 );
5651
5652 for (module, file) in [
5658 ("magit-status", include_str!("actions.rs")),
5659 ("magit-log", include_str!("magit_log_mode.rs")),
5660 ("magit-revision", include_str!("magit_revision_mode.rs")),
5661 ("magit-rebase", include_str!("magit_rebase_mode.rs")),
5662 ] {
5663 assert!(
5664 file.contains("fn commit_at_cursor"),
5665 "`{module}` is named in magit-core-mode.md as a view where \
5666 `A` / `_` / `O` act on the commit at the cursor, so its \
5667 MagitView must override `commit_at_cursor` — without it \
5668 the trait default answers None and the chords are dead"
5669 );
5670 }
5671 }
5672
5673 #[test]
5683 fn every_commit_ops_ex_command_is_registered() {
5684 let mut registry = CommandRegistry::new();
5685 register_ex_commands(
5686 &mut registry,
5687 Default::default(),
5688 crate::repo_scope::test_support::empty_store(),
5689 Default::default(),
5690 );
5691 for op in [
5692 magit_global_mode::CommitOp::CHERRY_PICK,
5693 magit_global_mode::CommitOp::REVERT,
5694 magit_global_mode::CommitOp::RESET_SOFT,
5695 magit_global_mode::CommitOp::RESET_MIXED,
5696 magit_global_mode::CommitOp::RESET_HARD,
5697 magit_global_mode::CommitOp::RESET_KEEP,
5700 magit_global_mode::CommitOp::RESET_INDEX,
5701 magit_global_mode::CommitOp::COMMIT_FIXUP,
5702 magit_global_mode::CommitOp::COMMIT_SQUASH,
5703 ] {
5704 let id = registry.id_by_name(op.ex_command).unwrap_or_else(|| {
5705 panic!(
5706 "`:{}` must exist — the commit picker fires it by name",
5707 op.ex_command
5708 )
5709 });
5710 assert!(
5711 registry.ex_command_spec(id).is_some(),
5712 "`:{}` must be an EX command, not an action of the same name",
5713 op.ex_command
5714 );
5715 }
5716 }
5717
5718 #[test]
5727 fn the_remote_buffer_is_reachable_by_ex_command_and_by_action() {
5728 let mut registry = CommandRegistry::new();
5729 register_ex_commands(
5730 &mut registry,
5731 Default::default(),
5732 crate::repo_scope::test_support::empty_store(),
5733 Default::default(),
5734 );
5735 let id = registry
5736 .id_by_name("magit-remote")
5737 .expect("`:magit-remote` must exist");
5738 assert!(
5739 registry.ex_command_spec(id).is_some(),
5740 "`:magit-remote` must be an EX command, not an action of the same name"
5741 );
5742
5743 let mut actions = CommandRegistry::new();
5744 register_action_commands(&mut actions);
5745 assert!(
5746 actions.id_by_name("action:magit-global-remote").is_some(),
5747 "`M` fires `action:magit-global-remote` — it must be registered"
5748 );
5749
5750 assert_eq!(
5753 MagitRemoteMode::mode_id().as_str(),
5754 "magit-remote-mode",
5755 "the mode id both open paths hardcode"
5756 );
5757 }
5758
5759 #[test]
5763 fn the_commit_rows_reuse_the_chords_actions() {
5764 let mut registry = CommandRegistry::new();
5773 register_action_commands(&mut registry);
5774 let ids = transients::MagitActionIds::resolve(®istry);
5775 for action in [
5776 "action:magit-cherry-pick",
5777 "action:magit-revert",
5778 "action:magit-reset-soft",
5779 "action:magit-reset-mixed",
5780 "action:magit-reset-hard",
5781 ] {
5782 assert_eq!(
5783 ids.get(action),
5784 registry.id_by_name(action),
5785 "the `{action}` row must fire that action, not a twin"
5786 );
5787 assert!(
5788 ids.get(action).is_some(),
5789 "`{action}` must stay registered — a row references it"
5790 );
5791 }
5792 }
5793
5794 #[test]
5803 fn every_file_dispatch_action_takes_the_file_target_under_that_name() {
5804 let mut registry = CommandRegistry::new();
5805 register_action_commands(&mut registry);
5806 for (name, _) in FILE_TARGET_ACTIONS {
5807 let spec = registry
5808 .lookup_by_name(name)
5809 .unwrap_or_else(|| panic!("`{name}` is registered"));
5810 let schema = &spec.args_schema;
5811 assert_eq!(
5812 schema.len(),
5813 1,
5814 "`{name}` should declare exactly the file target, got {schema:?}"
5815 );
5816 assert_eq!(
5817 schema[0].name.as_ref(),
5818 "file",
5819 "`{name}`'s target arg must be named `file` — the transient \
5820 Argument is matched by name, and a mismatch silently means \
5821 'always the visited file'"
5822 );
5823 }
5824 }
5825
5826 #[test]
5830 fn the_other_file_menu_offers_the_file_argument_the_actions_read() {
5831 let spec = transients::other_file_dispatch_transient(&Default::default());
5832 let named_file = spec.groups.iter().flat_map(|g| &g.items).any(|item| {
5833 matches!(
5834 &item.kind,
5835 lattice_picker::TransientItemKind::Argument { name, .. } if name == "file"
5836 )
5837 });
5838 assert!(
5839 named_file,
5840 "the menu must expose an `Argument` named `file`, or no row can \
5841 ever act on anything but the visited file"
5842 );
5843 }
5844
5845 #[test]
5851 fn the_project_diff_is_reachable_from_the_dispatch_and_the_diff_menu() {
5852 let ids = transients::MagitActionIds::default();
5853 let ctx = lattice_picker::TransientContext::default();
5854 let spec = transients::dispatch_transient(&ids, &ctx, &probe_here());
5855 let top_level = spec
5856 .groups
5857 .iter()
5858 .flat_map(|g| &g.items)
5859 .any(|i| i.key.iter().any(|k| k == "e"));
5860 assert!(
5861 top_level,
5862 "the dispatch must offer `e` at its top level; keys were {:?}",
5863 spec.groups
5864 .iter()
5865 .flat_map(|g| &g.items)
5866 .flat_map(|i| i.key.clone())
5867 .collect::<Vec<_>>()
5868 );
5869
5870 let in_diff_menu = transients::all_row_tables()
5874 .iter()
5875 .filter(|(name, _)| *name == "diff/show")
5876 .flat_map(|(_, rows)| rows.iter())
5877 .any(|r| r.key == "e" && r.action == "action:magit-project-diff");
5878 assert!(
5879 in_diff_menu,
5880 "the Diff menu must keep its `e` row — the promotion adds a route"
5881 );
5882 }
5883
5884 #[test]
5889 fn the_ex_command_matches_the_mode_name() {
5890 let mut registry = lattice_grammar::CommandRegistry::new();
5891 crate::register_ex_commands_for_test(&mut registry);
5892 assert!(
5893 registry.id_by_name("magit-project-diff").is_some(),
5894 "`:magit-project-diff` must resolve"
5895 );
5896 assert!(
5897 registry.id_by_name("magit-diff-project").is_none(),
5898 "the old spelling must not linger — one alias per command"
5899 );
5900 }
5901
5902 #[test]
5915 fn no_editable_mode_implies_magit_core() {
5916 use lattice_mode::Mode as _;
5917
5918 let project_diff = providers::project_diff::MagitProjectDiffMode.implies();
5922 assert!(
5923 !project_diff.contains(&magit_core_mode::MagitCoreMode::mode_id()),
5924 "the project diff is EDITABLE — implying `magit-core-mode` gives it \
5925 `i`, `C`, `D`, `S`, `U`, `q`, `yr` and makes the buffer untypeable"
5926 );
5927 assert!(
5928 project_diff.contains(&magit_nav_mode::MagitNavMode::mode_id()),
5929 "...but it still wants section navigation and folding"
5930 );
5931 assert!(
5932 project_diff.contains(&lattice_mode::RefreshableViewMode::mode_id()),
5933 "...and `gr`, which comes from refreshable-view-mode rather than magit-core"
5934 );
5935 }
5936
5937 #[test]
5940 fn magit_core_still_supplies_navigation_to_read_only_views() {
5941 use lattice_mode::Mode as _;
5942 assert!(
5943 magit_core_mode::MagitCoreMode
5944 .implies()
5945 .contains(&magit_nav_mode::MagitNavMode::mode_id()),
5946 "read-only magit buffers must keep `]]` / `[[` / `<Tab>` / `<S-Tab>`"
5947 );
5948 }
5949
5950 #[test]
5954 fn the_navigation_chords_live_in_exactly_one_mode() {
5955 use lattice_mode::Mode as _;
5956 let core = magit_core_mode::MagitCoreMode.keymap();
5957 for chord in ["]]", "[[", "<Tab>", "<S-Tab>"] {
5958 assert!(
5959 !core.entries.iter().any(|e| e.chord == chord),
5960 "`{chord}` still bound in magit-core-mode as well as magit-nav-mode"
5961 );
5962 }
5963 }
5964
5965 #[test]
5981 fn visiting_the_live_file_from_a_live_file_does_nothing() {
5982 use lattice_mode::Mode as _;
5983 let handler = magit_global_mode::MagitGlobalMode
5984 .action_handlers()
5985 .into_iter()
5986 .find(|c| c.action_name == "action:magit-global-file-visit-live")
5987 .expect("the handler is contributed")
5988 .handler;
5989
5990 let services = lattice_mode::ServiceRegistry::new();
5993 let events = lattice_runtime::EventBus::new();
5994 let ctx = lattice_mode::ActionContext {
5995 buffer_id: lattice_protocol::ids::BufferId::new(1),
5996 cursor: lattice_protocol::position::Position::new(0, 0),
5997 selection: None,
5998 services: &services,
5999 events: &events,
6000 prompt_value: None,
6001 args: lattice_grammar::Args::None,
6002 buffer_locals: None,
6003 };
6004
6005 match handler(&ctx) {
6006 None | Some(lattice_grammar::Effect::None) => {}
6007 Some(lattice_grammar::Effect::Echo { level, text }) => {
6008 panic!("`V` on a live file must not report anything; got {level:?}: {text}")
6009 }
6010 Some(other) => panic!("expected a no-op; got {other:?}"),
6011 }
6012 }
6013
6014 #[test]
6025 fn the_other_file_menu_picks_its_file_rather_than_prompting_for_it() {
6026 let spec = transients::other_file_dispatch_transient(&Default::default());
6027 let source = spec
6028 .groups
6029 .iter()
6030 .flat_map(|g| &g.items)
6031 .find_map(|item| match &item.kind {
6032 lattice_picker::TransientItemKind::Argument { name, source, .. }
6033 if name == "file" =>
6034 {
6035 Some(source.clone())
6036 }
6037 _ => None,
6038 })
6039 .expect("the `file` argument exists");
6040 let source = source.expect(
6041 "the `file` argument must be picker-backed — a free-text path for a \
6042 file that must already exist is the typo this plan exists to remove",
6043 );
6044 assert_eq!(source.id, lattice_picker::FILE_PICK_SOURCE);
6045 }
6046
6047 #[test]
6051 fn the_file_pick_source_the_menu_names_is_registered() {
6052 let ids: Vec<String> = lattice_picker::picker_sources::first_party_generators(
6053 std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
6054 lattice_grammar::CommandRegistry::new(),
6055 )),
6056 std::sync::Arc::new(lattice_config::ConfigRegistry::new()),
6057 std::sync::Arc::new(NoKeybindings),
6058 None,
6059 )
6060 .iter()
6061 .map(|g| g.spec().id.to_string())
6062 .collect();
6063 assert!(
6064 ids.iter().any(|id| id == lattice_picker::FILE_PICK_SOURCE),
6065 "`{}` must be a registered first-party source; known: {ids:?}",
6066 lattice_picker::FILE_PICK_SOURCE
6067 );
6068 }
6069
6070 struct NoKeybindings;
6073 impl lattice_completion::KeymapReverseLookup for NoKeybindings {
6074 fn chords_for(&self, _name: &str) -> Vec<lattice_protocol::chord::KeyChord> {
6075 Vec::new()
6076 }
6077 }
6078
6079 #[test]
6093 fn the_other_file_menus_discard_carries_the_file_it_names() {
6094 use lattice_mode::Mode;
6095
6096 let handler = MagitGlobalMode
6097 .action_handlers()
6098 .into_iter()
6099 .find(|c| c.action_name == "action:magit-global-file-discard")
6100 .expect("the ask half is contributed")
6101 .handler;
6102
6103 let services = lattice_mode::ServiceRegistry::new();
6104 let events = lattice_runtime::EventBus::new();
6105 let ctx = lattice_mode::ActionContext {
6106 buffer_id: lattice_protocol::ids::BufferId::new(1),
6107 cursor: lattice_protocol::position::Position::new(0, 0),
6108 selection: None,
6109 services: &services,
6110 events: &events,
6111 prompt_value: None,
6112 args: lattice_grammar::Args::List(vec![lattice_grammar::ArgValue::String(
6114 "Cargo.toml".to_string(),
6115 )]),
6116 buffer_locals: None,
6117 };
6118
6119 match handler(&ctx) {
6120 Some(lattice_grammar::Effect::Confirm { prompt, args, .. }) => {
6121 assert!(
6122 prompt.contains("Cargo.toml"),
6123 "the prompt names the target it will act on: {prompt}"
6124 );
6125 let carried = args.as_list().expect("the target is carried");
6126 assert!(
6127 matches!(
6128 &carried[0],
6129 lattice_grammar::ArgValue::String(p) if p == "Cargo.toml"
6130 ),
6131 "and the execute half receives that same target, rather \
6132 than re-deriving the visited file: {carried:?}"
6133 );
6134 }
6135 other => panic!("expected a Confirm carrying its target, got {other:?}"),
6136 }
6137 }
6138
6139 struct NamedBuffer(&'static str);
6144
6145 impl lattice_mode::BufferStore for NamedBuffer {
6146 fn find_by_name(&self, _name: &str) -> Option<lattice_core::BufferId> {
6147 None
6148 }
6149 fn handle_for(
6150 &self,
6151 _id: lattice_core::BufferId,
6152 ) -> Option<std::sync::Arc<dyn lattice_runtime::Document>> {
6153 None
6154 }
6155 fn name_for(&self, _id: lattice_core::BufferId) -> Option<String> {
6156 Some(self.0.to_string())
6157 }
6158 fn insert_document_buffer(
6159 &self,
6160 _id: lattice_core::BufferId,
6161 _kind: lattice_core::BufferKind,
6162 _handle: std::sync::Arc<dyn lattice_runtime::Document>,
6163 _flags: lattice_core::BufferFlags,
6164 _name: Option<String>,
6165 ) {
6166 }
6167 }
6168
6169 fn fire_reverse_blame_in(buffer_name: &'static str) -> Option<Effect> {
6172 use lattice_mode::Mode;
6173
6174 let handler = MagitGlobalMode
6175 .action_handlers()
6176 .into_iter()
6177 .find(|c| c.action_name == "action:magit-global-file-blame-reverse")
6178 .expect("reverse blame is contributed")
6179 .handler;
6180
6181 let mut services = lattice_mode::ServiceRegistry::new();
6182 services.register(lattice_mode::BufferStoreHandle::new(std::sync::Arc::new(
6188 NamedBuffer(buffer_name),
6189 )));
6190 let events = lattice_runtime::EventBus::new();
6191 handler(&lattice_mode::ActionContext {
6192 buffer_id: lattice_protocol::ids::BufferId::new(1),
6193 cursor: lattice_protocol::position::Position::new(0, 0),
6194 selection: None,
6195 services: &services,
6196 events: &events,
6197 prompt_value: None,
6198 args: lattice_grammar::Args::None,
6199 buffer_locals: None,
6200 })
6201 }
6202
6203 #[test]
6208 fn reverse_blame_toggles_the_minor_on_the_blob_buffer_it_runs_in() {
6209 match fire_reverse_blame_in("*magit:file:lattice:a1b2c3d:src/main.rs*") {
6210 Some(Effect::ToggleMode { mode_name }) => {
6211 assert_eq!(mode_name, "magit-blame-mode");
6212 }
6213 other => panic!("expected the blame minor to be toggled, got {other:?}"),
6214 }
6215 }
6216
6217 #[test]
6226 fn reverse_blame_says_why_when_there_is_no_revision_to_walk_from() {
6227 for name in [
6228 "*magit:file:lattice:staged:src/main.rs*",
6229 "*magit:status*",
6230 "src/main.rs",
6231 ] {
6232 match fire_reverse_blame_in(name) {
6233 Some(Effect::Echo { level, text }) => {
6234 assert_eq!(level, lattice_grammar::EchoLevel::Error);
6235 assert!(
6236 text.contains("revision"),
6237 "the message must name what is missing: {text}"
6238 );
6239 }
6240 other => panic!("expected an explained refusal in {name}, got {other:?}"),
6241 }
6242 }
6243 }
6244
6245 #[test]
6259 fn no_magit_mode_binds_a_visual_entry_key() {
6260 use lattice_mode::Mode;
6261
6262 const VISUAL_ENTRY: &[&str] = &["v", "V", "<C-v>"];
6263 let mut stolen: Vec<String> = Vec::new();
6264 macro_rules! check {
6265 ($($mode:expr => $label:literal),* $(,)?) => {
6266 $(for entry in $mode.keymap().entries {
6267 if VISUAL_ENTRY.contains(&entry.chord)
6268 && entry.modes.contains(&lattice_keymap::BindingMode::Normal)
6269 {
6270 stolen.push(format!(
6271 "{}: binds `{}`, which is how you ENTER Visual mode — \
6272 region staging becomes unreachable in this buffer",
6273 $label, entry.chord
6274 ));
6275 }
6276 })*
6277 };
6278 }
6279 check!(
6280 MagitGlobalMode => "magit-global-mode",
6285 MagitCoreMode => "magit-core-mode",
6286 MagitGlobalMode => "magit-global-mode",
6287 MagitStatusMode => "magit-status-mode",
6288 MagitCommitMode => "magit-commit-mode",
6289 MagitDiffMode => "magit-diff-mode",
6290 MagitLogMode => "magit-log-mode",
6291 MagitBlameMode => "magit-blame-mode",
6292 MagitStashMode => "magit-stash-mode",
6293 MagitBranchMode => "magit-branch-mode",
6294 MagitRemoteMode => "magit-remote-mode",
6295 MagitRefsMode => "magit-refs-mode",
6296 MagitNotesMode => "magit-notes-mode",
6297 MagitCherryMode => "magit-cherry-mode",
6298 MagitSubmoduleMode => "magit-submodule-mode",
6299 MagitRebaseMode => "magit-rebase-mode",
6300 MagitRevisionMode => "magit-revision-mode",
6301 MagitFileRevisionMode => "magit-file-revision-mode",
6302 magit_stash_show_mode::MagitStashShowMode => "magit-stash-show-mode",
6303 );
6304 assert!(stolen.is_empty(), "{}", stolen.join("\n"));
6305 }
6306
6307 #[test]
6317 fn every_view_that_stages_in_normal_mode_also_stages_over_a_selection() {
6318 use lattice_mode::Mode;
6319
6320 for (label, keymap) in [("magit-hunk-mode", magit_hunk_mode::MagitHunkMode.keymap())] {
6324 let bound = |mode: lattice_keymap::BindingMode, chord: &str| -> Option<&'static str> {
6325 keymap
6326 .entries
6327 .iter()
6328 .find(|e| e.modes.contains(&mode) && e.chord == chord)
6329 .and_then(|e| e.command)
6330 };
6331 for chord in ["s", "u", "x"] {
6332 let Some(normal) = bound(lattice_keymap::BindingMode::Normal, chord) else {
6333 continue; };
6335 assert_eq!(
6336 bound(lattice_keymap::BindingMode::Visual, chord),
6337 Some(normal),
6338 "{label}: `{chord}` acts in Normal mode but not over a \
6339 selection — region staging is unreachable there"
6340 );
6341 }
6342 }
6343 }
6344
6345 #[test]
6361 fn every_chord_every_mode_binds_reaches_a_registered_action_and_a_handler() {
6362 use lattice_mode::Mode;
6363
6364 let mut registry = CommandRegistry::new();
6365 register_action_commands(&mut registry);
6366
6367 let mut handled: Vec<&'static str> = Vec::new();
6369 macro_rules! handlers {
6370 ($($mode:expr),* $(,)?) => {
6371 $(for c in $mode.action_handlers() { handled.push(c.action_name); })*
6372 };
6373 }
6374 handlers!(
6375 MagitGlobalMode,
6376 MagitCoreMode,
6377 MagitStatusMode,
6378 MagitCommitMode,
6379 MagitDiffMode,
6380 MagitLogMode,
6381 MagitBlameMode,
6382 MagitStashMode,
6383 MagitBranchMode,
6384 MagitRemoteMode,
6385 MagitRefsMode,
6386 MagitNotesMode,
6387 MagitCherryMode,
6388 MagitSubmoduleMode,
6389 MagitRebaseMode,
6390 MagitRevisionMode,
6391 MagitFileRevisionMode,
6392 magit_stash_show_mode::MagitStashShowMode,
6393 magit_hunk_mode::MagitHunkMode,
6394 );
6395
6396 let mut dead: Vec<String> = Vec::new();
6397 macro_rules! check {
6398 ($($mode:expr => $label:literal),* $(,)?) => {
6399 $(for entry in $mode.keymap().entries {
6400 let Some(cmd) = entry.command else { continue };
6404 if !cmd.starts_with("action:") {
6407 continue;
6408 }
6409 if registry.lookup_by_name(cmd).is_none() {
6410 dead.push(format!(
6411 "{}: chord `{}` → `{cmd}`, which is NOT a registered \
6412 action command — the key is silently inert",
6413 $label, entry.chord
6414 ));
6415 } else if !handled.contains(&cmd) {
6416 dead.push(format!(
6417 "{}: chord `{}` → `{cmd}`, registered but NO mode \
6418 contributes a handler — the key does nothing",
6419 $label, entry.chord
6420 ));
6421 }
6422 })*
6423 };
6424 }
6425 check!(
6426 MagitCoreMode => "magit-core-mode",
6427 MagitStatusMode => "magit-status-mode",
6428 MagitCommitMode => "magit-commit-mode",
6429 MagitDiffMode => "magit-diff-mode",
6430 MagitLogMode => "magit-log-mode",
6431 MagitBlameMode => "magit-blame-mode",
6432 MagitStashMode => "magit-stash-mode",
6433 MagitBranchMode => "magit-branch-mode",
6434 MagitRemoteMode => "magit-remote-mode",
6435 MagitRefsMode => "magit-refs-mode",
6436 MagitNotesMode => "magit-notes-mode",
6437 MagitCherryMode => "magit-cherry-mode",
6438 MagitSubmoduleMode => "magit-submodule-mode",
6439 MagitRebaseMode => "magit-rebase-mode",
6440 MagitRevisionMode => "magit-revision-mode",
6441 MagitFileRevisionMode => "magit-file-revision-mode",
6442 magit_stash_show_mode::MagitStashShowMode => "magit-stash-show-mode",
6443 magit_hunk_mode::MagitHunkMode => "magit-hunk-mode",
6444 magit_nav_mode::MagitNavMode => "magit-nav-mode",
6446 );
6447
6448 let installed = include_str!("lib.rs")
6459 .lines()
6460 .filter_map(|l| l.trim().strip_prefix(".register("))
6461 .filter_map(|l| l.strip_suffix(")"))
6462 .filter(|m| m.contains("Magit"))
6463 .count();
6464 assert_eq!(
6465 installed, 20,
6466 "`install` registers {installed} magit modes but this guard \
6467 checks 20 — a mode registered at boot and absent from the \
6468 lists above has its chords unverified"
6469 );
6470
6471 assert!(
6472 dead.is_empty(),
6473 "chords that cannot reach a handler:\n {}",
6474 dead.join("\n ")
6475 );
6476 }
6477
6478 #[test]
6491 fn the_cmdline_and_the_transient_resolve_a_flag_to_the_same_args() {
6492 use lattice_grammar::{ArgValue, Args};
6493 use magit_global_mode::RemoteOp;
6494
6495 let op = RemoteOp::PUSH;
6496
6497 let from_cmdline = parse_remote_flags(op, "--force-with-lease");
6499
6500 let toggled: std::collections::HashMap<&str, bool> =
6502 [("force-with-lease", true)].into_iter().collect();
6503 let from_transient = Args::List(
6504 op.arg_specs()
6505 .iter()
6506 .map(|spec| {
6507 ArgValue::Bool(toggled.get(spec.name.as_ref()).copied().unwrap_or(false))
6508 })
6509 .collect(),
6510 );
6511
6512 assert_eq!(from_cmdline, from_transient);
6513 assert_eq!(
6514 op.argv(&from_cmdline),
6515 vec!["push", "--force-with-lease"],
6516 "and both must produce the force-with-lease push"
6517 );
6518 }
6519
6520 #[test]
6525 fn an_unrecognised_flag_on_the_cmdline_is_ignored_not_fatal() {
6526 use lattice_grammar::{ArgValue, Args};
6527 use magit_global_mode::RemoteOp;
6528 assert_eq!(
6529 parse_remote_flags(RemoteOp::PUSH, "--frce-with-lease --set-upstream"),
6530 Args::List(
6535 RemoteOp::PUSH
6536 .flags
6537 .iter()
6538 .map(|f| ArgValue::Bool(f.name == "set-upstream"))
6539 .collect()
6540 ),
6541 "the typo drops out; the flag that parsed still applies"
6542 );
6543 }
6544
6545 #[test]
6553 fn a_flagless_operation_parses_to_no_args() {
6554 use lattice_grammar::Args;
6555 use magit_global_mode::RemoteOp;
6556 assert!(
6557 RemoteOp::REBASE_CONTINUE.flags.is_empty(),
6558 "this test needs a genuinely flagless op",
6559 );
6560 assert_eq!(
6561 parse_remote_flags(RemoteOp::REBASE_CONTINUE, "--force"),
6562 Args::None
6563 );
6564 }
6565
6566 #[test]
6574 fn every_remote_operation_has_both_a_transient_item_and_an_ex_command() {
6575 use lattice_mode::Mode;
6576
6577 let mut registry = CommandRegistry::new();
6578 register_ex_commands(
6579 &mut registry,
6580 Default::default(),
6581 crate::repo_scope::test_support::empty_store(),
6582 Default::default(),
6583 );
6584 register_action_commands(&mut registry);
6585 let handlers = MagitGlobalMode.action_handlers();
6586
6587 for (ex_name, action_name) in [
6588 ("magit-fetch", "action:magit-global-fetch"),
6589 ("magit-pull", "action:magit-global-pull"),
6590 ("magit-push", "action:magit-global-push"),
6591 ("magit-stash", "action:magit-global-stash-create"),
6592 ] {
6593 assert!(
6594 registry.lookup_by_name(ex_name).is_some(),
6595 "`:{ex_name}` must exist — an operation reachable only from a \
6596 transient is invisible to `:` and unscriptable"
6597 );
6598 assert!(
6599 handlers.iter().any(|c| c.action_name == action_name),
6600 "`{action_name}` must still have its transient handler — the \
6601 ex-command is a second front-end, not a replacement"
6602 );
6603 }
6604 }
6605
6606 #[test]
6610 fn each_remote_op_names_a_distinct_git_invocation() {
6611 use magit_global_mode::RemoteOp;
6612 let ops = [
6613 RemoteOp::FETCH,
6614 RemoteOp::PULL,
6615 RemoteOp::PUSH,
6616 RemoteOp::STASH,
6617 ];
6618 for (i, a) in ops.iter().enumerate() {
6619 assert!(!a.args.is_empty(), "`{}` has no argv", a.what);
6620 assert!(
6621 a.what.contains(a.args[0]),
6622 "`{}` must name the operation its argv runs (`{}`)",
6623 a.what,
6624 a.args[0]
6625 );
6626 for b in &ops[i + 1..] {
6627 assert_ne!(
6628 a.args, b.args,
6629 "`{}` and `{}` run the same git",
6630 a.what, b.what
6631 );
6632 }
6633 }
6634 }
6635
6636 #[test]
6643 fn magit_stash_and_magit_stash_list_are_distinct_commands() {
6644 let mut registry = CommandRegistry::new();
6645 register_ex_commands(
6646 &mut registry,
6647 Default::default(),
6648 crate::repo_scope::test_support::empty_store(),
6649 Default::default(),
6650 );
6651 let create = registry
6652 .lookup_by_name("magit-stash")
6653 .expect("`:magit-stash` registered");
6654 let list = registry
6655 .lookup_by_name("magit-stash-list")
6656 .expect("`:magit-stash-list` registered");
6657 assert_eq!(create.name, "magit-stash");
6658 assert_eq!(list.name, "magit-stash-list");
6659 assert_ne!(create.id, list.id, "a prefix collision would alias the two");
6660 }
6661
6662 #[test]
6671 fn shared_actions_are_owned_solely_by_magit_core_mode() {
6672 use lattice_mode::Mode;
6673 const SHARED: &[&str] = &[
6674 "action:magit-refresh",
6675 "action:magit-stage",
6676 "action:magit-unstage",
6677 ];
6678 for name in SHARED {
6679 assert!(
6680 MagitCoreMode
6681 .action_handlers()
6682 .iter()
6683 .any(|c| c.action_name == *name),
6684 "`{name}` is reachable from more than one magit view, so \
6685 magit-core-mode must own its single handler"
6686 );
6687 }
6688 for (label, contributions) in [
6689 ("magit-branch-mode", MagitBranchMode.action_handlers()),
6690 ("magit-remote-mode", MagitRemoteMode.action_handlers()),
6691 ("magit-refs-mode", MagitRefsMode.action_handlers()),
6692 ("magit-notes-mode", MagitNotesMode.action_handlers()),
6693 ("magit-cherry-mode", MagitCherryMode.action_handlers()),
6694 ("magit-submodule-mode", MagitSubmoduleMode.action_handlers()),
6695 ("magit-stash-mode", MagitStashMode.action_handlers()),
6696 ("magit-diff-mode", MagitDiffMode.action_handlers()),
6697 ("magit-log-mode", MagitLogMode.action_handlers()),
6698 ("magit-status-mode", MagitStatusMode.action_handlers()),
6699 ] {
6700 for c in contributions {
6701 assert!(
6702 !SHARED.contains(&c.action_name),
6703 "`{label}` contributes shared action `{}` — it must reach \
6704 it through its MagitView instead",
6705 c.action_name
6706 );
6707 }
6708 }
6709 }
6710
6711 #[test]
6717 fn every_root_dispatch_item_resolves_to_a_real_action_not_a_flag_fallback() {
6718 let mut registry = CommandRegistry::new();
6719 register_action_commands(&mut registry);
6720 let ids = transients::MagitActionIds::resolve(®istry);
6724 for ctx in [&outside_magit(), &in_magit_status()] {
6728 assert_no_inert_items(&transients::dispatch_transient(&ids, ctx, &probe_here()));
6729 }
6730 }
6731
6732 #[test]
6733 fn file_dispatch_items_resolve_to_real_actions() {
6734 let mut registry = CommandRegistry::new();
6735 register_action_commands(&mut registry);
6736 let ids = transients::MagitActionIds::resolve(®istry);
6737 let spec = transients::file_dispatch_transient(&ids);
6738 assert_no_inert_items(&spec);
6739 }
6740
6741 #[test]
6747 fn no_duplicate_keys_within_any_transient_menu_level() {
6748 fn check(spec: &lattice_picker::TransientSpec, path: &str) {
6749 let mut seen: Vec<(String, String)> = Vec::new();
6750 for group in &spec.groups {
6751 for item in &group.items {
6752 for key in &item.key {
6753 if let Some((_, prior)) = seen.iter().find(|(k, _)| k == key) {
6754 panic!(
6755 "key '{key}' in menu '{path}' is bound twice: \
6756 '{prior}' and '{}' — the second is unreachable",
6757 item.label
6758 );
6759 }
6760 seen.push((key.clone(), item.label.clone()));
6761 }
6762 if let lattice_picker::TransientItemKind::Submenu(sub) = &item.kind {
6763 check(sub, &format!("{path}{} > ", item.label));
6764 }
6765 }
6766 }
6767 }
6768 let mut registry = CommandRegistry::new();
6769 register_action_commands(&mut registry);
6770 check(
6771 &transients::dispatch_transient(
6772 &transients::MagitActionIds::resolve(®istry),
6773 &in_magit_status(),
6774 &probe_here(),
6775 ),
6776 "dispatch",
6777 );
6778 check(
6779 &transients::file_dispatch_transient(&transients::MagitActionIds::resolve(®istry)),
6780 "file-dispatch",
6781 );
6782 }
6783
6784 #[test]
6790 fn unresolved_ids_do_produce_inert_items_so_the_guard_is_not_vacuous() {
6791 let file = inert_items(
6796 &transients::file_dispatch_transient(&Default::default()),
6797 "",
6798 );
6799 assert_eq!(
6800 file.len(),
6801 15,
6802 "expected every file-dispatch leaf to report inert, got: {file:?}"
6803 );
6804 let root = inert_items(
6825 &transients::dispatch_transient_with(
6826 &Default::default(),
6827 &outside_magit(),
6828 &transients::DispatchGates::default(),
6829 ),
6830 "",
6831 );
6832 assert_eq!(
6833 root.len(),
6834 117,
6865 "expected every root-dispatch leaf (incl. both submenus') to \
6866 report inert, got: {root:?}"
6867 );
6868
6869 let bisecting = inert_items(
6872 &transients::dispatch_transient_with(
6873 &Default::default(),
6874 &outside_magit(),
6875 &transients::DispatchGates {
6876 workdir: Default::default(),
6877 merge: false,
6878 bisect: true,
6879 notes_merge: false,
6880 am: false,
6881 rebase: false,
6882 cherry_pick: false,
6883 revert: false,
6884 },
6885 ),
6886 "",
6887 );
6888 assert_eq!(
6889 bisecting.len(),
6890 120,
6900 "the in-progress bisect menu trades `start` for good/bad/skip/reset: {bisecting:?}"
6901 );
6902 }
6903}
6904
6905#[cfg(test)]
6906mod same_buffer_chord_tests {
6907 use lattice_mode::Mode;
6908
6909 const HUNK_MAJORS: &[&str] = &[
6911 "magit-status-mode",
6912 "magit-diff-mode",
6913 "magit-commit-mode",
6914 "magit-revision-mode",
6915 "magit-stash-show-mode",
6916 ];
6917
6918 fn chords(m: &dyn Fn() -> Vec<&'static str>) -> Vec<&'static str> {
6919 m()
6920 }
6921
6922 #[test]
6940 fn no_magit_chord_is_unreachable_on_a_buffer_that_binds_both() {
6941 fn tokens(c: &str) -> Vec<String> {
6942 let mut out = Vec::new();
6943 let mut it = c.chars().peekable();
6944 while let Some(ch) = it.next() {
6945 if ch == '<' {
6946 let mut t = String::from('<');
6947 for c2 in it.by_ref() {
6948 t.push(c2);
6949 if c2 == '>' {
6950 break;
6951 }
6952 }
6953 out.push(t);
6954 } else {
6955 out.push(ch.to_string());
6956 }
6957 }
6958 out
6959 }
6960
6961 fn uniq<'a>(
6964 entries: impl IntoIterator<Item = &'a lattice_keymap::KeymapEntry>,
6965 ) -> Vec<&'static str> {
6966 let mut v: Vec<&'static str> = Vec::new();
6967 for e in entries {
6968 if !v.contains(&e.chord) {
6969 v.push(e.chord);
6970 }
6971 }
6972 v
6973 }
6974 let core = uniq(crate::MagitCoreMode.keymap().entries);
6975 let hunk = uniq(crate::magit_hunk_mode::MagitHunkMode.keymap().entries);
6976
6977 let majors: Vec<(&str, Vec<&str>)> = vec![
6979 (
6980 "magit-status-mode",
6981 uniq(crate::MagitStatusMode.keymap().entries),
6982 ),
6983 (
6984 "magit-branch-mode",
6985 uniq(crate::MagitBranchMode.keymap().entries),
6986 ),
6987 (
6988 "magit-remote-mode",
6989 uniq(crate::MagitRemoteMode.keymap().entries),
6990 ),
6991 (
6992 "magit-stash-mode",
6993 uniq(crate::MagitStashMode.keymap().entries),
6994 ),
6995 (
6996 "magit-submodule-mode",
6997 uniq(crate::MagitSubmoduleMode.keymap().entries),
6998 ),
6999 (
7000 "magit-refs-mode",
7001 uniq(crate::MagitRefsMode.keymap().entries),
7002 ),
7003 ];
7004
7005 for (major, major_chords) in &majors {
7006 let mut live: Vec<(&str, &str)> = Vec::new();
7007 for c in major_chords {
7008 live.push((c, major));
7009 }
7010 for c in &core {
7011 live.push((c, "magit-core-mode"));
7012 }
7013 if HUNK_MAJORS.contains(major) {
7014 for c in &hunk {
7015 live.push((c, "magit-hunk-mode"));
7016 }
7017 }
7018
7019 for (i, (a, owner_a)) in live.iter().enumerate() {
7020 for (b, owner_b) in live.iter().skip(i + 1) {
7021 assert_ne!(
7022 a, b,
7023 "on a {major} buffer, `{a}` is bound by BOTH \
7024 {owner_a} and {owner_b} — which wins is layer \
7025 order, not something either mode's reader sees",
7026 );
7027 }
7028 }
7029 for (a, owner_a) in &live {
7030 let ta = tokens(a);
7036 for (b, owner_b) in &live {
7037 if a == b {
7038 continue;
7039 }
7040 let tb = tokens(b);
7041 assert!(
7042 !(tb.len() > ta.len() && tb[..ta.len()] == ta[..]),
7043 "on a {major} buffer, `{a}` ({owner_a}) terminates \
7044 and is a prefix of `{b}` ({owner_b}) — the trie \
7045 checks a node's binding before its children, so \
7046 `{b}` is unreachable there",
7047 );
7048 }
7049 }
7050 }
7051 let _ = chords;
7052 }
7053}