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!("  gw 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!("  gw home");
140            }
141            NextAction::CommitChanges => {
142                output::action("Next: commit changes");
143                println!();
144                println!(
145                    "  git add <files> && git commit -m \"feat: ...\"  # stage deliberately, not -A"
146                );
147            }
148            NextAction::PushChanges => {
149                output::action("Next: push to remote");
150                println!();
151                println!("  git push -u origin {}", branch);
152            }
153            NextAction::CreatePr => {
154                output::action("Next: create pull request");
155                println!();
156                println!("  gh pr create -a \"@me\" -t \"...\"");
157            }
158            NextAction::WaitingForReview { pr_number } => {
159                if *pr_number > 0 {
160                    output::action(&format!("Waiting: PR #{} in review", pr_number));
161                    println!();
162                    println!(
163                        "  gw await {} --open  # Wait for merge, then cleanup",
164                        pr_number
165                    );
166                    println!("  gw open             # Open PR in browser");
167                } else {
168                    output::action("Waiting: PR in review");
169                    println!();
170                    println!("  gw open  # Open PR in browser");
171                }
172            }
173            NextAction::Cleanup => {
174                output::action("Next: cleanup merged branch");
175                println!();
176                println!("  gw cleanup");
177            }
178            NextAction::RebaseNeeded => {
179                output::action("Next: rebase on latest main");
180                println!();
181                println!("  git fetch --prune && git rebase origin/main");
182            }
183            NextAction::ResolveDivergence => {
184                output::action("Next: resolve divergence");
185                println!();
186                println!("  # Option 1: Rebase (preferred)");
187                println!("  git fetch --prune && git rebase origin/main");
188                println!();
189                println!("  # Option 2: Force push (if you know what you're doing)");
190                println!("  git push --force-with-lease");
191            }
192            NextAction::PrClosed { pr_number } => {
193                output::action(&format!("PR #{} was closed without merging", pr_number));
194                println!();
195                println!("  # Option 1: Reopen the PR");
196                println!("  gh pr reopen {}", pr_number);
197                println!();
198                println!("  # Option 2: Cleanup and start fresh");
199                println!("  gw cleanup");
200            }
201            NextAction::SyncNeeded { base_branch } => {
202                output::action(&format!("Next: sync (base '{}' was merged)", base_branch));
203                println!();
204                println!("  gw sync");
205            }
206        }
207
208        output::separator();
209    }
210
211    /// Get a short description for the action
212    pub fn short_description(&self) -> &'static str {
213        match self {
214            NextAction::StartNewWork => "start new work",
215            NextAction::SyncHomeWithUpstream { .. } => "sync with upstream",
216            NextAction::CommitChanges => "commit changes",
217            NextAction::PushChanges => "push to remote",
218            NextAction::CreatePr => "create PR",
219            NextAction::WaitingForReview { .. } => "waiting for review",
220            NextAction::Cleanup => "cleanup branch",
221            NextAction::RebaseNeeded => "rebase needed",
222            NextAction::ResolveDivergence => "resolve divergence",
223            NextAction::PrClosed { .. } => "PR closed",
224            NextAction::SyncNeeded { .. } => "sync needed",
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::github::{PrInfo, PrState};
233
234    #[test]
235    fn test_on_home_branch_suggests_start_new_work() {
236        let action = NextAction::detect(
237            "main",
238            "main",
239            &WorkingDirState::Clean,
240            &SyncState::Synced,
241            None,
242            false,
243            None,
244        );
245        assert_eq!(action, NextAction::StartNewWork);
246    }
247
248    #[test]
249    fn test_on_home_branch_behind_suggests_sync() {
250        let action = NextAction::detect(
251            "main",
252            "main",
253            &WorkingDirState::Clean,
254            &SyncState::Behind { count: 5 },
255            None,
256            false,
257            None,
258        );
259        assert_eq!(action, NextAction::SyncHomeWithUpstream { behind_count: 5 });
260    }
261
262    #[test]
263    fn test_uncommitted_changes_suggests_commit() {
264        let action = NextAction::detect(
265            "feature/test",
266            "main",
267            &WorkingDirState::HasUnstagedChanges,
268            &SyncState::Synced,
269            None,
270            true,
271            None,
272        );
273        assert_eq!(action, NextAction::CommitChanges);
274    }
275
276    #[test]
277    fn test_unpushed_commits_suggests_push() {
278        let action = NextAction::detect(
279            "feature/test",
280            "main",
281            &WorkingDirState::Clean,
282            &SyncState::HasUnpushedCommits { count: 2 },
283            None,
284            true,
285            None,
286        );
287        assert_eq!(action, NextAction::PushChanges);
288    }
289
290    #[test]
291    fn test_no_upstream_suggests_push() {
292        let action = NextAction::detect(
293            "feature/test",
294            "main",
295            &WorkingDirState::Clean,
296            &SyncState::NoUpstream,
297            None,
298            false,
299            None,
300        );
301        assert_eq!(action, NextAction::PushChanges);
302    }
303
304    #[test]
305    fn test_pushed_no_pr_suggests_create_pr() {
306        let action = NextAction::detect(
307            "feature/test",
308            "main",
309            &WorkingDirState::Clean,
310            &SyncState::Synced,
311            None,
312            true,
313            None,
314        );
315        assert_eq!(action, NextAction::CreatePr);
316    }
317
318    #[test]
319    fn test_open_pr_suggests_waiting() {
320        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
321        let action = NextAction::detect(
322            "feature/test",
323            "main",
324            &WorkingDirState::Clean,
325            &SyncState::Synced,
326            Some(&pr),
327            true,
328            None,
329        );
330        assert_eq!(action, NextAction::WaitingForReview { pr_number: 42 });
331    }
332
333    #[test]
334    fn test_merged_pr_suggests_cleanup() {
335        let pr = PrInfo::new(
336            42,
337            "Test PR",
338            "https://...",
339            PrState::Merged {
340                method: crate::github::MergeMethod::Squash,
341                merge_commit: None,
342            },
343            "main",
344        );
345        let action = NextAction::detect(
346            "feature/test",
347            "main",
348            &WorkingDirState::Clean,
349            &SyncState::Synced,
350            Some(&pr),
351            true,
352            None,
353        );
354        assert_eq!(action, NextAction::Cleanup);
355    }
356
357    #[test]
358    fn test_closed_pr_suggests_reopen_or_cleanup() {
359        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Closed, "main");
360        let action = NextAction::detect(
361            "feature/test",
362            "main",
363            &WorkingDirState::Clean,
364            &SyncState::Synced,
365            Some(&pr),
366            true,
367            None,
368        );
369        assert_eq!(action, NextAction::PrClosed { pr_number: 42 });
370    }
371
372    #[test]
373    fn test_behind_suggests_rebase() {
374        let action = NextAction::detect(
375            "feature/test",
376            "main",
377            &WorkingDirState::Clean,
378            &SyncState::Behind { count: 3 },
379            None,
380            true,
381            None,
382        );
383        assert_eq!(action, NextAction::RebaseNeeded);
384    }
385
386    #[test]
387    fn test_diverged_suggests_resolve() {
388        let action = NextAction::detect(
389            "feature/test",
390            "main",
391            &WorkingDirState::Clean,
392            &SyncState::Diverged {
393                ahead: 2,
394                behind: 3,
395            },
396            None,
397            true,
398            None,
399        );
400        assert_eq!(action, NextAction::ResolveDivergence);
401    }
402
403    #[test]
404    fn test_uncommitted_changes_takes_priority_over_pr_open() {
405        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
406        let action = NextAction::detect(
407            "feature/test",
408            "main",
409            &WorkingDirState::HasStagedChanges,
410            &SyncState::Synced,
411            Some(&pr),
412            true,
413            None,
414        );
415        assert_eq!(action, NextAction::CommitChanges);
416    }
417
418    #[test]
419    fn test_merged_pr_takes_priority_over_uncommitted_changes() {
420        let pr = PrInfo::new(
421            42,
422            "Test PR",
423            "https://...",
424            PrState::Merged {
425                method: crate::github::MergeMethod::Squash,
426                merge_commit: None,
427            },
428            "main",
429        );
430        let action = NextAction::detect(
431            "feature/test",
432            "main",
433            &WorkingDirState::HasUnstagedChanges,
434            &SyncState::Synced,
435            Some(&pr),
436            true,
437            None,
438        );
439        // Merged PR takes priority - cleanup first
440        assert_eq!(action, NextAction::Cleanup);
441    }
442
443    #[test]
444    fn test_base_pr_merged_suggests_sync() {
445        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "feature/base");
446        let action = NextAction::detect(
447            "feature/child",
448            "main",
449            &WorkingDirState::Clean,
450            &SyncState::Synced,
451            Some(&pr),
452            true,
453            Some("feature/base"),
454        );
455        assert_eq!(
456            action,
457            NextAction::SyncNeeded {
458                base_branch: "feature/base".to_string()
459            }
460        );
461    }
462
463    #[test]
464    fn test_base_pr_merged_takes_priority_over_waiting() {
465        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "feature/base");
466        let action = NextAction::detect(
467            "feature/child",
468            "main",
469            &WorkingDirState::Clean,
470            &SyncState::Synced,
471            Some(&pr),
472            true,
473            Some("feature/base"),
474        );
475        // SyncNeeded should take priority over WaitingForReview
476        assert_eq!(
477            action,
478            NextAction::SyncNeeded {
479                base_branch: "feature/base".to_string()
480            }
481        );
482    }
483}