Skip to main content

git_workflow/state/
next_action.rs

1//! Next action detection for workflow automation
2//!
3//! Determines the recommended next action based on current repository state.
4
5use crate::github::PrInfo;
6use crate::output;
7
8use super::{SyncState, WorkingDirState};
9
10/// Recommended next action based on current state
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum NextAction {
13    /// On home branch, ready to start new work
14    StartNewWork,
15    /// On home branch but behind upstream, should sync first
16    SyncHomeWithUpstream { behind_count: usize },
17    /// Has uncommitted changes, should commit
18    CommitChanges,
19    /// Has unpushed commits, should push
20    PushChanges,
21    /// Pushed but no PR, should create PR
22    CreatePr,
23    /// PR is open, waiting for review/CI
24    WaitingForReview { pr_number: u64 },
25    /// PR is merged, should cleanup
26    Cleanup,
27    /// Branch is behind origin/main, should rebase
28    RebaseNeeded,
29    /// Branch has diverged from upstream, needs resolution
30    ResolveDivergence,
31    /// PR was closed without merging
32    PrClosed { pr_number: u64 },
33    /// Base PR was merged, should sync (update base to main, rebase, push)
34    SyncNeeded { base_branch: String },
35}
36
37impl NextAction {
38    /// Detect the next action based on current state
39    ///
40    /// # Arguments
41    /// * `base_pr_merged` - If Some(branch_name), the base PR for that branch was merged
42    pub fn detect(
43        current_branch: &str,
44        home_branch: &str,
45        working_dir: &WorkingDirState,
46        sync_state: &SyncState,
47        pr_info: Option<&PrInfo>,
48        has_remote: bool,
49        base_pr_merged: Option<&str>,
50    ) -> Self {
51        // On home branch
52        if current_branch == home_branch {
53            // Behind upstream → sync first
54            if let SyncState::Behind { count } = sync_state {
55                return NextAction::SyncHomeWithUpstream {
56                    behind_count: *count,
57                };
58            }
59            return NextAction::StartNewWork;
60        }
61
62        // PR is merged → cleanup
63        if let Some(pr) = pr_info {
64            if pr.state.is_merged() {
65                return NextAction::Cleanup;
66            }
67            if pr.state.is_closed() {
68                return NextAction::PrClosed {
69                    pr_number: pr.number,
70                };
71            }
72        }
73
74        // Base PR was merged → sync needed (takes priority over uncommitted changes)
75        if let Some(base_branch) = base_pr_merged {
76            return NextAction::SyncNeeded {
77                base_branch: base_branch.to_string(),
78            };
79        }
80
81        // Has uncommitted changes → commit
82        if !matches!(working_dir, WorkingDirState::Clean) {
83            return NextAction::CommitChanges;
84        }
85
86        // Diverged from upstream → resolve
87        if matches!(sync_state, SyncState::Diverged { .. }) {
88            return NextAction::ResolveDivergence;
89        }
90
91        // Behind upstream → rebase
92        if matches!(sync_state, SyncState::Behind { .. }) {
93            return NextAction::RebaseNeeded;
94        }
95
96        // Has unpushed commits or no upstream → push
97        if matches!(
98            sync_state,
99            SyncState::HasUnpushedCommits { .. } | SyncState::NoUpstream
100        ) {
101            return NextAction::PushChanges;
102        }
103
104        // Pushed but no PR → create PR
105        if pr_info.is_none() && has_remote {
106            return NextAction::CreatePr;
107        }
108
109        // PR is open → waiting
110        if let Some(pr) = pr_info {
111            if pr.state.is_open() {
112                return NextAction::WaitingForReview {
113                    pr_number: pr.number,
114                };
115            }
116        }
117
118        // Default: waiting for something
119        NextAction::WaitingForReview { pr_number: 0 }
120    }
121
122    /// Display the next action with commands
123    pub fn display(&self, branch: &str) {
124        println!();
125        output::separator();
126
127        match self {
128            NextAction::StartNewWork => {
129                output::action("Next: start new work");
130                println!();
131                println!("  mise run git:new feature/your-feature");
132            }
133            NextAction::SyncHomeWithUpstream { behind_count } => {
134                output::action(&format!(
135                    "Next: sync with upstream ({} commit(s) behind)",
136                    behind_count
137                ));
138                println!();
139                println!("  mise run git:home");
140            }
141            NextAction::CommitChanges => {
142                output::action("Next: commit changes");
143                println!();
144                println!("  git add -A && git commit -m \"feat: ...\"");
145            }
146            NextAction::PushChanges => {
147                output::action("Next: push to remote");
148                println!();
149                println!("  git push -u origin {}", branch);
150            }
151            NextAction::CreatePr => {
152                output::action("Next: create pull request");
153                println!();
154                println!("  gh pr create -a \"@me\" -t \"...\"");
155            }
156            NextAction::WaitingForReview { pr_number } => {
157                if *pr_number > 0 {
158                    output::action(&format!("Waiting: PR #{} in review", pr_number));
159                } else {
160                    output::action("Waiting: PR in review");
161                }
162                println!();
163                println!("  gh pr checks --watch    # Wait for CI");
164                println!("  gh pr view --web        # Open in browser");
165            }
166            NextAction::Cleanup => {
167                output::action("Next: cleanup merged branch");
168                println!();
169                println!("  mise run git:cleanup");
170            }
171            NextAction::RebaseNeeded => {
172                output::action("Next: rebase on latest main");
173                println!();
174                println!("  git fetch --prune && git rebase origin/main");
175            }
176            NextAction::ResolveDivergence => {
177                output::action("Next: resolve divergence");
178                println!();
179                println!("  # Option 1: Rebase (preferred)");
180                println!("  git fetch --prune && git rebase origin/main");
181                println!();
182                println!("  # Option 2: Force push (if you know what you're doing)");
183                println!("  git push --force-with-lease");
184            }
185            NextAction::PrClosed { pr_number } => {
186                output::action(&format!("PR #{} was closed without merging", pr_number));
187                println!();
188                println!("  # Option 1: Reopen the PR");
189                println!("  gh pr reopen {}", pr_number);
190                println!();
191                println!("  # Option 2: Cleanup and start fresh");
192                println!("  mise run git:cleanup");
193            }
194            NextAction::SyncNeeded { base_branch } => {
195                output::action(&format!("Next: sync (base '{}' was merged)", base_branch));
196                println!();
197                println!("  mise run git:sync");
198            }
199        }
200
201        output::separator();
202    }
203
204    /// Get a short description for the action
205    pub fn short_description(&self) -> &'static str {
206        match self {
207            NextAction::StartNewWork => "start new work",
208            NextAction::SyncHomeWithUpstream { .. } => "sync with upstream",
209            NextAction::CommitChanges => "commit changes",
210            NextAction::PushChanges => "push to remote",
211            NextAction::CreatePr => "create PR",
212            NextAction::WaitingForReview { .. } => "waiting for review",
213            NextAction::Cleanup => "cleanup branch",
214            NextAction::RebaseNeeded => "rebase needed",
215            NextAction::ResolveDivergence => "resolve divergence",
216            NextAction::PrClosed { .. } => "PR closed",
217            NextAction::SyncNeeded { .. } => "sync needed",
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::github::{PrInfo, PrState};
226
227    #[test]
228    fn test_on_home_branch_suggests_start_new_work() {
229        let action = NextAction::detect(
230            "main",
231            "main",
232            &WorkingDirState::Clean,
233            &SyncState::Synced,
234            None,
235            false,
236            None,
237        );
238        assert_eq!(action, NextAction::StartNewWork);
239    }
240
241    #[test]
242    fn test_on_home_branch_behind_suggests_sync() {
243        let action = NextAction::detect(
244            "main",
245            "main",
246            &WorkingDirState::Clean,
247            &SyncState::Behind { count: 5 },
248            None,
249            false,
250            None,
251        );
252        assert_eq!(action, NextAction::SyncHomeWithUpstream { behind_count: 5 });
253    }
254
255    #[test]
256    fn test_uncommitted_changes_suggests_commit() {
257        let action = NextAction::detect(
258            "feature/test",
259            "main",
260            &WorkingDirState::HasUnstagedChanges,
261            &SyncState::Synced,
262            None,
263            true,
264            None,
265        );
266        assert_eq!(action, NextAction::CommitChanges);
267    }
268
269    #[test]
270    fn test_unpushed_commits_suggests_push() {
271        let action = NextAction::detect(
272            "feature/test",
273            "main",
274            &WorkingDirState::Clean,
275            &SyncState::HasUnpushedCommits { count: 2 },
276            None,
277            true,
278            None,
279        );
280        assert_eq!(action, NextAction::PushChanges);
281    }
282
283    #[test]
284    fn test_no_upstream_suggests_push() {
285        let action = NextAction::detect(
286            "feature/test",
287            "main",
288            &WorkingDirState::Clean,
289            &SyncState::NoUpstream,
290            None,
291            false,
292            None,
293        );
294        assert_eq!(action, NextAction::PushChanges);
295    }
296
297    #[test]
298    fn test_pushed_no_pr_suggests_create_pr() {
299        let action = NextAction::detect(
300            "feature/test",
301            "main",
302            &WorkingDirState::Clean,
303            &SyncState::Synced,
304            None,
305            true,
306            None,
307        );
308        assert_eq!(action, NextAction::CreatePr);
309    }
310
311    #[test]
312    fn test_open_pr_suggests_waiting() {
313        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
314        let action = NextAction::detect(
315            "feature/test",
316            "main",
317            &WorkingDirState::Clean,
318            &SyncState::Synced,
319            Some(&pr),
320            true,
321            None,
322        );
323        assert_eq!(action, NextAction::WaitingForReview { pr_number: 42 });
324    }
325
326    #[test]
327    fn test_merged_pr_suggests_cleanup() {
328        let pr = PrInfo::new(
329            42,
330            "Test PR",
331            "https://...",
332            PrState::Merged {
333                method: crate::github::MergeMethod::Squash,
334                merge_commit: None,
335            },
336            "main",
337        );
338        let action = NextAction::detect(
339            "feature/test",
340            "main",
341            &WorkingDirState::Clean,
342            &SyncState::Synced,
343            Some(&pr),
344            true,
345            None,
346        );
347        assert_eq!(action, NextAction::Cleanup);
348    }
349
350    #[test]
351    fn test_closed_pr_suggests_reopen_or_cleanup() {
352        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Closed, "main");
353        let action = NextAction::detect(
354            "feature/test",
355            "main",
356            &WorkingDirState::Clean,
357            &SyncState::Synced,
358            Some(&pr),
359            true,
360            None,
361        );
362        assert_eq!(action, NextAction::PrClosed { pr_number: 42 });
363    }
364
365    #[test]
366    fn test_behind_suggests_rebase() {
367        let action = NextAction::detect(
368            "feature/test",
369            "main",
370            &WorkingDirState::Clean,
371            &SyncState::Behind { count: 3 },
372            None,
373            true,
374            None,
375        );
376        assert_eq!(action, NextAction::RebaseNeeded);
377    }
378
379    #[test]
380    fn test_diverged_suggests_resolve() {
381        let action = NextAction::detect(
382            "feature/test",
383            "main",
384            &WorkingDirState::Clean,
385            &SyncState::Diverged {
386                ahead: 2,
387                behind: 3,
388            },
389            None,
390            true,
391            None,
392        );
393        assert_eq!(action, NextAction::ResolveDivergence);
394    }
395
396    #[test]
397    fn test_uncommitted_changes_takes_priority_over_pr_open() {
398        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
399        let action = NextAction::detect(
400            "feature/test",
401            "main",
402            &WorkingDirState::HasStagedChanges,
403            &SyncState::Synced,
404            Some(&pr),
405            true,
406            None,
407        );
408        assert_eq!(action, NextAction::CommitChanges);
409    }
410
411    #[test]
412    fn test_merged_pr_takes_priority_over_uncommitted_changes() {
413        let pr = PrInfo::new(
414            42,
415            "Test PR",
416            "https://...",
417            PrState::Merged {
418                method: crate::github::MergeMethod::Squash,
419                merge_commit: None,
420            },
421            "main",
422        );
423        let action = NextAction::detect(
424            "feature/test",
425            "main",
426            &WorkingDirState::HasUnstagedChanges,
427            &SyncState::Synced,
428            Some(&pr),
429            true,
430            None,
431        );
432        // Merged PR takes priority - cleanup first
433        assert_eq!(action, NextAction::Cleanup);
434    }
435
436    #[test]
437    fn test_base_pr_merged_suggests_sync() {
438        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "feature/base");
439        let action = NextAction::detect(
440            "feature/child",
441            "main",
442            &WorkingDirState::Clean,
443            &SyncState::Synced,
444            Some(&pr),
445            true,
446            Some("feature/base"),
447        );
448        assert_eq!(
449            action,
450            NextAction::SyncNeeded {
451                base_branch: "feature/base".to_string()
452            }
453        );
454    }
455
456    #[test]
457    fn test_base_pr_merged_takes_priority_over_waiting() {
458        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "feature/base");
459        let action = NextAction::detect(
460            "feature/child",
461            "main",
462            &WorkingDirState::Clean,
463            &SyncState::Synced,
464            Some(&pr),
465            true,
466            Some("feature/base"),
467        );
468        // SyncNeeded should take priority over WaitingForReview
469        assert_eq!(
470            action,
471            NextAction::SyncNeeded {
472                base_branch: "feature/base".to_string()
473            }
474        );
475    }
476}