Skip to main content

gitkraft_core/features/commits/
actions.rs

1//! Commit action definitions shared by all frontends (GUI, TUI).
2//!
3//! `CommitActionKind` is generated by the `define_commit_action_kinds!` macro
4//! from a single table — each row specifies the variant name, display label,
5//! optional first-input prompt, and optional second-input prompt.  This is the
6//! single source of truth for action labels and input requirements.
7//!
8//! `CommitAction` carries the variant *with* its payload (branch name, tag
9//! name, etc.) and knows how to execute itself.
10//!
11//! `COMMIT_MENU_GROUPS` describes the canonical separator-delimited menu
12//! layout that both GUI and TUI iterate to build their commit context menus.
13
14use std::path::Path;
15
16use anyhow::Result;
17
18// ── Macro: generates CommitActionKind enum + all metadata impls ──────────────
19
20/// Generate `CommitActionKind` and its metadata `impl` from a single table.
21///
22/// Each row is:  `Variant { "Label", prompt1, prompt2 }`
23/// where `prompt1` and `prompt2` are `Option<&'static str>` literals.
24macro_rules! define_commit_action_kinds {
25    ( $( $variant:ident { $label:literal, $prompt1:expr, $prompt2:expr } ),* $(,)? ) => {
26
27        /// Discriminant-only commit action — used for building menus before
28        /// input values are known.  Every variant maps 1-to-1 to a
29        /// `CommitAction` variant.
30        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31        pub enum CommitActionKind {
32            $( $variant, )*
33        }
34
35        impl CommitActionKind {
36            /// Human-readable label shown in menus and key-hint panels.
37            pub fn label(self) -> &'static str {
38                match self {
39                    $( Self::$variant => $label, )*
40                }
41            }
42
43            /// Prompt to show when this action needs a first user-supplied
44            /// string (e.g. branch name, tag name).  `None` for actions that
45            /// need no input.
46            pub fn input_prompt(self) -> Option<&'static str> {
47                match self {
48                    $( Self::$variant => $prompt1, )*
49                }
50            }
51
52            /// Prompt to show when this action needs a *second* string
53            /// (e.g. annotated-tag message).  `None` for most actions.
54            pub fn second_input_prompt(self) -> Option<&'static str> {
55                match self {
56                    $( Self::$variant => $prompt2, )*
57                }
58            }
59
60            /// Whether this action requires at least one string from the user
61            /// before it can execute.
62            pub fn needs_input(self) -> bool {
63                self.input_prompt().is_some()
64            }
65
66            /// Whether this action requires a *second* string.
67            pub fn needs_second_input(self) -> bool {
68                self.second_input_prompt().is_some()
69            }
70
71            /// Build the corresponding `CommitAction` from this kind plus up
72            /// to two collected input strings.  Pass empty strings for inputs
73            /// that are not used by this variant.
74            pub fn into_action(self, input1: String, input2: String) -> CommitAction {
75                match self {
76                    Self::CheckoutDetached    => CommitAction::CheckoutDetached,
77                    Self::CreateBranchHere    => CommitAction::CreateBranchHere(input1),
78                    Self::CherryPick          => CommitAction::CherryPick,
79                    Self::Revert              => CommitAction::Revert,
80                    Self::RebaseOnto          => CommitAction::RebaseOnto,
81                    Self::ResetSoft           => CommitAction::ResetSoft,
82                    Self::ResetMixed          => CommitAction::ResetMixed,
83                    Self::ResetHard           => CommitAction::ResetHard,
84                    Self::CreateTag           => CommitAction::CreateTag(input1),
85                    Self::CreateAnnotatedTag  => CommitAction::CreateAnnotatedTag(input1, input2),
86                }
87            }
88
89            /// Convenience: build a `CommitAction` for variants that need no
90            /// user input.  Returns `None` for input-needing variants.
91            pub fn as_simple_action(self) -> Option<CommitAction> {
92                if self.needs_input() {
93                    None
94                } else {
95                    Some(self.into_action(String::new(), String::new()))
96                }
97            }
98        }
99    };
100}
101
102// ── Action table — single source of truth for all 10 commit actions ──────────
103
104define_commit_action_kinds! {
105    CheckoutDetached   { "Checkout (detached HEAD)",                        None,                   None                  },
106    CreateBranchHere   { "Create branch here\u{2026}",                     Some("Branch name:"),    None                  },
107    CherryPick         { "Cherry-pick onto current branch",                 None,                   None                  },
108    Revert             { "Revert commit",                                    None,                   None                  },
109    RebaseOnto         { "Rebase current branch onto this",                 None,                   None                  },
110    ResetSoft          { "Reset here \u{2014} soft (keep staged)",          None,                   None                  },
111    ResetMixed         { "Reset here \u{2014} mixed (keep files)",          None,                   None                  },
112    ResetHard          { "Reset here \u{2014} hard (discard all)",          None,                   None                  },
113    CreateTag          { "Create tag here",                                  Some("Tag name:"),      None                  },
114    CreateAnnotatedTag { "Create annotated tag here\u{2026}",               Some("Tag name:"),      Some("Tag message:")  }
115}
116
117// ── COMMIT_MENU_GROUPS — canonical menu layout used by all frontends ─────────
118
119/// The canonical commit-action menu, organised into separator-delimited groups.
120///
121/// Both the GUI (right-click context menu) and TUI (action popup) iterate this
122/// constant to render their respective menus.  The Copy group (SHA / message)
123/// is frontend-specific metadata and is therefore NOT included here.
124pub const COMMIT_MENU_GROUPS: &[&[CommitActionKind]] = &[
125    // Group 1 — branch / checkout ops
126    &[
127        CommitActionKind::CheckoutDetached,
128        CommitActionKind::CreateBranchHere,
129    ],
130    // Group 2 — history manipulation
131    &[
132        CommitActionKind::CherryPick,
133        CommitActionKind::Revert,
134        CommitActionKind::RebaseOnto,
135    ],
136    // Group 3 — reset
137    &[
138        CommitActionKind::ResetSoft,
139        CommitActionKind::ResetMixed,
140        CommitActionKind::ResetHard,
141    ],
142    // Group 4 — tags
143    &[
144        CommitActionKind::CreateTag,
145        CommitActionKind::CreateAnnotatedTag,
146    ],
147];
148
149// ── CommitAction — the action WITH its payload ────────────────────────────────
150
151/// A commit action ready to execute — variant carries all data needed.
152#[derive(Debug, Clone)]
153pub enum CommitAction {
154    CheckoutDetached,
155    CreateBranchHere(String),
156    CherryPick,
157    Revert,
158    RebaseOnto,
159    ResetSoft,
160    ResetMixed,
161    ResetHard,
162    CreateTag(String),
163    CreateAnnotatedTag(String, String), // (name, message)
164}
165
166impl CommitAction {
167    /// Return the discriminant kind of this action.
168    pub fn kind(&self) -> CommitActionKind {
169        match self {
170            Self::CheckoutDetached => CommitActionKind::CheckoutDetached,
171            Self::CreateBranchHere(_) => CommitActionKind::CreateBranchHere,
172            Self::CherryPick => CommitActionKind::CherryPick,
173            Self::Revert => CommitActionKind::Revert,
174            Self::RebaseOnto => CommitActionKind::RebaseOnto,
175            Self::ResetSoft => CommitActionKind::ResetSoft,
176            Self::ResetMixed => CommitActionKind::ResetMixed,
177            Self::ResetHard => CommitActionKind::ResetHard,
178            Self::CreateTag(_) => CommitActionKind::CreateTag,
179            Self::CreateAnnotatedTag(_, _) => CommitActionKind::CreateAnnotatedTag,
180        }
181    }
182
183    /// Human-readable label (delegates to `CommitActionKind::label`).
184    pub fn label(&self) -> &'static str {
185        self.kind().label()
186    }
187
188    /// Execute this action against the given commit in the given working
189    /// directory.  All necessary git operations are performed here, using only
190    /// the functions already present in `gitkraft-core`.
191    pub fn execute(&self, workdir: &Path, oid: &str) -> Result<()> {
192        match self {
193            Self::CheckoutDetached => {
194                let repo = crate::features::repo::open_repo(workdir)?;
195                crate::features::repo::checkout_commit_detached(&repo, oid)
196            }
197            Self::CreateBranchHere(name) => {
198                let repo = crate::features::repo::open_repo(workdir)?;
199                crate::features::branches::create_branch_at_commit(&repo, name, oid)?;
200                Ok(())
201            }
202            Self::CherryPick => crate::features::repo::cherry_pick_commit(workdir, oid),
203            Self::Revert => crate::features::repo::revert_commit(workdir, oid),
204            Self::RebaseOnto => crate::features::branches::rebase_onto(workdir, oid),
205            Self::ResetSoft => crate::features::repo::reset_to_commit(workdir, oid, "soft"),
206            Self::ResetMixed => crate::features::repo::reset_to_commit(workdir, oid, "mixed"),
207            Self::ResetHard => crate::features::repo::reset_to_commit(workdir, oid, "hard"),
208            Self::CreateTag(name) => {
209                let repo = crate::features::repo::open_repo(workdir)?;
210                crate::features::branches::create_tag(&repo, name, oid)
211            }
212            Self::CreateAnnotatedTag(name, message) => {
213                let repo = crate::features::repo::open_repo(workdir)?;
214                crate::features::branches::create_annotated_tag(&repo, name, message, oid)
215            }
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn all_kinds_have_labels() {
226        use CommitActionKind::*;
227        let kinds = [
228            CheckoutDetached,
229            CreateBranchHere,
230            CherryPick,
231            Revert,
232            RebaseOnto,
233            ResetSoft,
234            ResetMixed,
235            ResetHard,
236            CreateTag,
237            CreateAnnotatedTag,
238        ];
239        for k in kinds {
240            assert!(!k.label().is_empty(), "{k:?} has empty label");
241        }
242    }
243
244    #[test]
245    fn needs_input_variants() {
246        assert!(!CommitActionKind::CheckoutDetached.needs_input());
247        assert!(!CommitActionKind::CherryPick.needs_input());
248        assert!(CommitActionKind::CreateBranchHere.needs_input());
249        assert!(CommitActionKind::CreateTag.needs_input());
250        assert!(CommitActionKind::CreateAnnotatedTag.needs_input());
251        assert!(CommitActionKind::CreateAnnotatedTag.needs_second_input());
252        assert!(!CommitActionKind::CreateTag.needs_second_input());
253    }
254
255    #[test]
256    fn into_action_round_trips() {
257        let a = CommitActionKind::CreateBranchHere.into_action("my-branch".into(), String::new());
258        assert!(matches!(a, CommitAction::CreateBranchHere(ref n) if n == "my-branch"));
259
260        let b = CommitActionKind::CreateAnnotatedTag.into_action("v1.0".into(), "release".into());
261        assert!(
262            matches!(b, CommitAction::CreateAnnotatedTag(ref n, ref m) if n == "v1.0" && m == "release")
263        );
264    }
265
266    #[test]
267    fn as_simple_action_returns_none_for_input_kinds() {
268        assert!(CommitActionKind::CreateBranchHere
269            .as_simple_action()
270            .is_none());
271        assert!(CommitActionKind::CreateTag.as_simple_action().is_none());
272    }
273
274    #[test]
275    fn as_simple_action_returns_some_for_no_input_kinds() {
276        assert!(CommitActionKind::CherryPick.as_simple_action().is_some());
277        assert!(CommitActionKind::ResetHard.as_simple_action().is_some());
278    }
279
280    #[test]
281    fn menu_groups_cover_all_kinds() {
282        use std::collections::HashSet;
283        let mut seen: HashSet<CommitActionKind> = HashSet::new();
284        for group in COMMIT_MENU_GROUPS {
285            for &kind in *group {
286                seen.insert(kind);
287            }
288        }
289        // All 10 kinds should appear exactly once
290        assert_eq!(seen.len(), 10);
291    }
292}