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 => super::ops::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(
206                workdir,
207                oid,
208                crate::features::repo::ResetMode::Soft,
209            ),
210            Self::ResetMixed => crate::features::repo::reset_to_commit(
211                workdir,
212                oid,
213                crate::features::repo::ResetMode::Mixed,
214            ),
215            Self::ResetHard => crate::features::repo::reset_to_commit(
216                workdir,
217                oid,
218                crate::features::repo::ResetMode::Hard,
219            ),
220            Self::CreateTag(name) => {
221                let repo = crate::features::repo::open_repo(workdir)?;
222                crate::features::branches::create_tag(&repo, name, oid)
223            }
224            Self::CreateAnnotatedTag(name, message) => {
225                let repo = crate::features::repo::open_repo(workdir)?;
226                crate::features::branches::create_annotated_tag(&repo, name, message, oid)
227            }
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn all_kinds_have_labels() {
238        use CommitActionKind::*;
239        let kinds = [
240            CheckoutDetached,
241            CreateBranchHere,
242            CherryPick,
243            Revert,
244            RebaseOnto,
245            ResetSoft,
246            ResetMixed,
247            ResetHard,
248            CreateTag,
249            CreateAnnotatedTag,
250        ];
251        for k in kinds {
252            assert!(!k.label().is_empty(), "{k:?} has empty label");
253        }
254    }
255
256    #[test]
257    fn needs_input_variants() {
258        assert!(!CommitActionKind::CheckoutDetached.needs_input());
259        assert!(!CommitActionKind::CherryPick.needs_input());
260        assert!(CommitActionKind::CreateBranchHere.needs_input());
261        assert!(CommitActionKind::CreateTag.needs_input());
262        assert!(CommitActionKind::CreateAnnotatedTag.needs_input());
263        assert!(CommitActionKind::CreateAnnotatedTag.needs_second_input());
264        assert!(!CommitActionKind::CreateTag.needs_second_input());
265    }
266
267    #[test]
268    fn into_action_round_trips() {
269        let a = CommitActionKind::CreateBranchHere.into_action("my-branch".into(), String::new());
270        assert!(matches!(a, CommitAction::CreateBranchHere(ref n) if n == "my-branch"));
271
272        let b = CommitActionKind::CreateAnnotatedTag.into_action("v1.0".into(), "release".into());
273        assert!(
274            matches!(b, CommitAction::CreateAnnotatedTag(ref n, ref m) if n == "v1.0" && m == "release")
275        );
276    }
277
278    #[test]
279    fn as_simple_action_returns_none_for_input_kinds() {
280        assert!(CommitActionKind::CreateBranchHere
281            .as_simple_action()
282            .is_none());
283        assert!(CommitActionKind::CreateTag.as_simple_action().is_none());
284    }
285
286    #[test]
287    fn as_simple_action_returns_some_for_no_input_kinds() {
288        assert!(CommitActionKind::CherryPick.as_simple_action().is_some());
289        assert!(CommitActionKind::ResetHard.as_simple_action().is_some());
290    }
291
292    #[test]
293    fn menu_groups_cover_all_kinds() {
294        use std::collections::HashSet;
295        let mut seen: HashSet<CommitActionKind> = HashSet::new();
296        for group in COMMIT_MENU_GROUPS {
297            for &kind in *group {
298                seen.insert(kind);
299            }
300        }
301        // All 10 kinds should appear exactly once
302        assert_eq!(seen.len(), 10);
303    }
304}