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. `base` is the stacked parent branch
22    /// to pass as `-B`, or `None` when the PR targets the default branch.
23    CreatePr { base: Option<String> },
24    /// PR is open, waiting for review/CI
25    WaitingForReview { pr_number: u64 },
26    /// PR is merged, should cleanup
27    Cleanup,
28    /// The base this branch sits on (`origin/main`, or the stacked parent)
29    /// has new commits; catch up with `gw sync` before publishing.
30    BehindBase { base: String, behind_count: usize },
31    /// Someone pushed to this branch's upstream; pull those commits first.
32    PullUpstream { behind_count: usize },
33    /// Branch has diverged from upstream, needs resolution
34    ResolveDivergence,
35    /// PR was closed without merging
36    PrClosed { pr_number: u64 },
37    /// Base PR was merged, should sync (update base to main, rebase, push)
38    SyncNeeded { base_branch: String },
39    /// Recorded stacked base merged before this branch's PR was created; rebase
40    /// onto the default branch and open a normal (non-stacked) PR. `base_sha` is
41    /// the recorded base tip used as the `--onto` boundary when present (it
42    /// survives the base branch being deleted).
43    StackedBaseMerged {
44        base_branch: String,
45        base_sha: Option<String>,
46    },
47}
48
49/// Inputs for [`NextAction::detect`]. Bundled into a struct because the detected
50/// state depends on many independent signals; named fields keep call sites
51/// legible and let new signals be added without churning every caller.
52pub struct DetectContext<'a> {
53    pub current_branch: &'a str,
54    pub home_branch: &'a str,
55    pub working_dir: &'a WorkingDirState,
56    pub sync_state: &'a SyncState,
57    pub pr_info: Option<&'a PrInfo>,
58    pub has_remote: bool,
59    /// This branch's PR's base was merged (post-PR restack trigger).
60    pub base_pr_merged: Option<&'a str>,
61    /// Locally recorded stacked base (`gw new --stack`), filtered to a real
62    /// parent. Drives the `-B <base>` create-PR suggestion before a PR exists.
63    pub recorded_base: Option<&'a str>,
64    /// The recorded base's PR has already merged (stale stacked base): the
65    /// branch should rebase onto the default branch and open a normal PR.
66    pub recorded_base_merged: bool,
67    /// Recorded base tip SHA (`gw new --stack`); the `--onto` boundary that
68    /// survives the base branch being deleted.
69    pub recorded_base_sha: Option<&'a str>,
70    /// The ref this branch should sit on: the stacked parent while one is in
71    /// flight, else the default branch (`origin/main`). Display only.
72    pub base_ref: &'a str,
73    /// Commits on `base_ref` that this branch does not have.
74    pub behind_base: usize,
75}
76
77impl NextAction {
78    /// Detect the next action based on current state. See [`DetectContext`].
79    pub fn detect(ctx: &DetectContext) -> Self {
80        let current_branch = ctx.current_branch;
81        let home_branch = ctx.home_branch;
82        let working_dir = ctx.working_dir;
83        let sync_state = ctx.sync_state;
84        let pr_info = ctx.pr_info;
85        let has_remote = ctx.has_remote;
86        let base_pr_merged = ctx.base_pr_merged;
87        let recorded_base = ctx.recorded_base;
88        let recorded_base_merged = ctx.recorded_base_merged;
89        let recorded_base_sha = ctx.recorded_base_sha;
90        let base_ref = ctx.base_ref;
91        let behind_base = ctx.behind_base;
92        // On home branch
93        if current_branch == home_branch {
94            // Behind upstream → sync first
95            if let SyncState::Behind { count } = sync_state {
96                return NextAction::SyncHomeWithUpstream {
97                    behind_count: *count,
98                };
99            }
100            return NextAction::StartNewWork;
101        }
102
103        // PR is merged → cleanup
104        if let Some(pr) = pr_info {
105            if pr.state.is_merged() {
106                return NextAction::Cleanup;
107            }
108            if pr.state.is_closed() {
109                return NextAction::PrClosed {
110                    pr_number: pr.number,
111                };
112            }
113        }
114
115        // Base PR was merged → sync needed (takes priority over uncommitted changes)
116        if let Some(base_branch) = base_pr_merged {
117            return NextAction::SyncNeeded {
118                base_branch: base_branch.to_string(),
119            };
120        }
121
122        // Has uncommitted changes → commit
123        if !matches!(working_dir, WorkingDirState::Clean) {
124            return NextAction::CommitChanges;
125        }
126
127        // Diverged from upstream → resolve
128        if matches!(sync_state, SyncState::Diverged { .. }) {
129            return NextAction::ResolveDivergence;
130        }
131
132        // Behind own upstream (someone pushed to this branch) → pull first
133        if let SyncState::Behind { count } = sync_state {
134            return NextAction::PullUpstream {
135                behind_count: *count,
136            };
137        }
138
139        // The base moved under this branch and the PR isn't open yet → catch
140        // up before publishing. Once a PR is open, being behind the base is
141        // normal (GitHub merges against the latest base); we don't force a
142        // rebase + force-push on every trunk commit.
143        let pr_open = pr_info.is_some_and(|pr| pr.state.is_open());
144        if behind_base > 0 && !pr_open {
145            return NextAction::BehindBase {
146                base: base_ref.to_string(),
147                behind_count: behind_base,
148            };
149        }
150
151        // Has unpushed commits or no upstream → push
152        if matches!(
153            sync_state,
154            SyncState::HasUnpushedCommits { .. } | SyncState::NoUpstream
155        ) {
156            return NextAction::PushChanges;
157        }
158
159        // Pushed but no PR → create PR. If the recorded stacked base already
160        // merged, the `-B <base>` it would suggest is stale: rebase onto the
161        // default branch and open a normal PR instead.
162        if pr_info.is_none() && has_remote {
163            if recorded_base_merged {
164                if let Some(base) = recorded_base {
165                    return NextAction::StackedBaseMerged {
166                        base_branch: base.to_string(),
167                        base_sha: recorded_base_sha.map(String::from),
168                    };
169                }
170            }
171            return NextAction::CreatePr {
172                base: recorded_base.map(String::from),
173            };
174        }
175
176        // PR is open → waiting
177        if let Some(pr) = pr_info {
178            if pr.state.is_open() {
179                return NextAction::WaitingForReview {
180                    pr_number: pr.number,
181                };
182            }
183        }
184
185        // Default: waiting for something
186        NextAction::WaitingForReview { pr_number: 0 }
187    }
188
189    /// Display the next action with commands
190    pub fn display(&self, branch: &str) {
191        println!();
192        output::separator();
193
194        match self {
195            NextAction::StartNewWork => {
196                output::action("Next: start new work");
197                println!();
198                println!("  gw new feature/your-feature");
199            }
200            NextAction::SyncHomeWithUpstream { behind_count } => {
201                output::action(&format!(
202                    "Next: sync with upstream ({} commit(s) behind)",
203                    behind_count
204                ));
205                println!();
206                println!("  gw home");
207            }
208            NextAction::CommitChanges => {
209                output::action("Next: commit changes");
210                println!();
211                println!(
212                    "  git add <files> && git commit -m \"feat: ...\"  # stage deliberately, not -A"
213                );
214            }
215            NextAction::PushChanges => {
216                output::action("Next: push to remote");
217                println!();
218                println!("  git push -u origin {}", branch);
219            }
220            NextAction::CreatePr { base } => {
221                output::action("Next: create pull request");
222                println!();
223                match base {
224                    Some(base) => {
225                        println!(
226                            "  gh pr create -a \"@me\" -B {} -t \"...\"  # stacked on {}",
227                            base, base
228                        )
229                    }
230                    None => println!("  gh pr create -a \"@me\" -t \"...\""),
231                }
232            }
233            NextAction::WaitingForReview { pr_number } => {
234                if *pr_number > 0 {
235                    output::action(&format!("Waiting: PR #{} in review", pr_number));
236                    println!();
237                    println!(
238                        "  gw await {} --open  # Wait for merge, then cleanup",
239                        pr_number
240                    );
241                    println!("  gw open             # Open PR in browser");
242                } else {
243                    output::action("Waiting: PR in review");
244                    println!();
245                    println!("  gw open  # Open PR in browser");
246                }
247            }
248            NextAction::Cleanup => {
249                output::action("Next: cleanup merged branch");
250                println!();
251                println!("  gw cleanup");
252            }
253            NextAction::BehindBase { base, behind_count } => {
254                output::action(&format!(
255                    "Next: sync with {} ({} commit(s) behind)",
256                    base, behind_count
257                ));
258                println!();
259                println!("  gw sync  # rebase onto the latest {base}");
260            }
261            NextAction::PullUpstream { behind_count } => {
262                output::action(&format!(
263                    "Next: pull upstream changes ({} commit(s) behind origin/{})",
264                    behind_count, branch
265                ));
266                println!();
267                println!("  git pull --rebase  # someone pushed to this branch");
268            }
269            NextAction::ResolveDivergence => {
270                output::action(&format!("Next: resolve divergence from origin/{}", branch));
271                println!();
272                println!("  # Option 1: You rewrote history locally (rebase/amend) — publish it");
273                println!("  git push --force-with-lease");
274                println!();
275                println!("  # Option 2: Someone else pushed to this branch — take their commits");
276                println!("  git pull --rebase");
277            }
278            NextAction::PrClosed { pr_number } => {
279                output::action(&format!("PR #{} was closed without merging", pr_number));
280                println!();
281                println!("  # Option 1: Reopen the PR");
282                println!("  gh pr reopen {}", pr_number);
283                println!();
284                println!("  # Option 2: Cleanup and start fresh");
285                println!("  gw cleanup");
286            }
287            NextAction::SyncNeeded { base_branch } => {
288                output::action(&format!("Next: sync (base '{}' was merged)", base_branch));
289                println!();
290                println!("  gw sync");
291            }
292            NextAction::StackedBaseMerged { base_branch, .. } => {
293                output::action(&format!(
294                    "Next: base '{}' merged — restack onto main",
295                    base_branch
296                ));
297                println!();
298                // `gw sync` uses `rebase --onto` with the recorded base tip, so
299                // only THIS branch's commits are replayed — a plain
300                // `git rebase origin/main` would re-apply the (squash-)merged
301                // base's commits too.
302                println!("  gw sync  # replay only your commits onto main");
303                println!("  # then open a normal PR (base is now main):");
304                println!("  gh pr create -a \"@me\" -t \"...\"");
305            }
306        }
307
308        output::separator();
309    }
310
311    /// Get a short description for the action
312    pub fn short_description(&self) -> &'static str {
313        match self {
314            NextAction::StartNewWork => "start new work",
315            NextAction::SyncHomeWithUpstream { .. } => "sync with upstream",
316            NextAction::CommitChanges => "commit changes",
317            NextAction::PushChanges => "push to remote",
318            NextAction::CreatePr { .. } => "create PR",
319            NextAction::WaitingForReview { .. } => "waiting for review",
320            NextAction::Cleanup => "cleanup branch",
321            NextAction::BehindBase { .. } => "sync with base",
322            NextAction::PullUpstream { .. } => "pull upstream",
323            NextAction::ResolveDivergence => "resolve divergence",
324            NextAction::PrClosed { .. } => "PR closed",
325            NextAction::SyncNeeded { .. } => "sync needed",
326            NextAction::StackedBaseMerged { .. } => "rebase (base merged)",
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::github::{PrInfo, PrState};
335
336    /// Build a `DetectContext` with the common fields and safe defaults for the
337    /// stacked-base signals; tests override the latter via struct update syntax.
338    fn ctx<'a>(
339        current: &'a str,
340        home: &'a str,
341        working_dir: &'a WorkingDirState,
342        sync_state: &'a SyncState,
343        pr_info: Option<&'a PrInfo>,
344        has_remote: bool,
345    ) -> DetectContext<'a> {
346        DetectContext {
347            current_branch: current,
348            home_branch: home,
349            working_dir,
350            sync_state,
351            pr_info,
352            has_remote,
353            base_pr_merged: None,
354            recorded_base: None,
355            recorded_base_merged: false,
356            recorded_base_sha: None,
357            base_ref: "origin/main",
358            behind_base: 0,
359        }
360    }
361
362    fn merged_pr(base: &str) -> PrInfo {
363        PrInfo::new(
364            42,
365            "Test PR",
366            "https://...",
367            PrState::Merged {
368                method: crate::github::MergeMethod::Squash,
369                merge_commit: None,
370            },
371            base,
372        )
373    }
374
375    #[test]
376    fn test_on_home_branch_suggests_start_new_work() {
377        let action = NextAction::detect(&ctx(
378            "main",
379            "main",
380            &WorkingDirState::Clean,
381            &SyncState::Synced,
382            None,
383            false,
384        ));
385        assert_eq!(action, NextAction::StartNewWork);
386    }
387
388    #[test]
389    fn test_on_home_branch_behind_suggests_sync() {
390        let action = NextAction::detect(&ctx(
391            "main",
392            "main",
393            &WorkingDirState::Clean,
394            &SyncState::Behind { count: 5 },
395            None,
396            false,
397        ));
398        assert_eq!(action, NextAction::SyncHomeWithUpstream { behind_count: 5 });
399    }
400
401    #[test]
402    fn test_uncommitted_changes_suggests_commit() {
403        let action = NextAction::detect(&ctx(
404            "feature/test",
405            "main",
406            &WorkingDirState::HasUnstagedChanges,
407            &SyncState::Synced,
408            None,
409            true,
410        ));
411        assert_eq!(action, NextAction::CommitChanges);
412    }
413
414    #[test]
415    fn test_unpushed_commits_suggests_push() {
416        let action = NextAction::detect(&ctx(
417            "feature/test",
418            "main",
419            &WorkingDirState::Clean,
420            &SyncState::HasUnpushedCommits { count: 2 },
421            None,
422            true,
423        ));
424        assert_eq!(action, NextAction::PushChanges);
425    }
426
427    #[test]
428    fn test_no_upstream_suggests_push() {
429        let action = NextAction::detect(&ctx(
430            "feature/test",
431            "main",
432            &WorkingDirState::Clean,
433            &SyncState::NoUpstream,
434            None,
435            false,
436        ));
437        assert_eq!(action, NextAction::PushChanges);
438    }
439
440    #[test]
441    fn test_pushed_no_pr_suggests_create_pr() {
442        let action = NextAction::detect(&ctx(
443            "feature/test",
444            "main",
445            &WorkingDirState::Clean,
446            &SyncState::Synced,
447            None,
448            true,
449        ));
450        assert_eq!(action, NextAction::CreatePr { base: None });
451    }
452
453    #[test]
454    fn test_pushed_no_pr_with_recorded_base_suggests_stacked_pr() {
455        let action = NextAction::detect(&DetectContext {
456            recorded_base: Some("feature/parent"),
457            ..ctx(
458                "feature/child",
459                "main",
460                &WorkingDirState::Clean,
461                &SyncState::Synced,
462                None,
463                true,
464            )
465        });
466        assert_eq!(
467            action,
468            NextAction::CreatePr {
469                base: Some("feature/parent".to_string())
470            }
471        );
472    }
473
474    #[test]
475    fn test_recorded_base_merged_before_pr_suggests_rebase() {
476        let action = NextAction::detect(&DetectContext {
477            recorded_base: Some("feature/parent"),
478            recorded_base_merged: true,
479            ..ctx(
480                "feature/child",
481                "main",
482                &WorkingDirState::Clean,
483                &SyncState::Synced,
484                None,
485                true,
486            )
487        });
488        assert_eq!(
489            action,
490            NextAction::StackedBaseMerged {
491                base_branch: "feature/parent".to_string(),
492                base_sha: None,
493            }
494        );
495    }
496
497    #[test]
498    fn test_recorded_base_merged_carries_recorded_sha() {
499        let action = NextAction::detect(&DetectContext {
500            recorded_base: Some("feature/parent"),
501            recorded_base_merged: true,
502            recorded_base_sha: Some("abc1234"),
503            ..ctx(
504                "feature/child",
505                "main",
506                &WorkingDirState::Clean,
507                &SyncState::Synced,
508                None,
509                true,
510            )
511        });
512        assert_eq!(
513            action,
514            NextAction::StackedBaseMerged {
515                base_branch: "feature/parent".to_string(),
516                base_sha: Some("abc1234".to_string()),
517            }
518        );
519    }
520
521    #[test]
522    fn test_open_pr_suggests_waiting() {
523        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
524        let action = NextAction::detect(&ctx(
525            "feature/test",
526            "main",
527            &WorkingDirState::Clean,
528            &SyncState::Synced,
529            Some(&pr),
530            true,
531        ));
532        assert_eq!(action, NextAction::WaitingForReview { pr_number: 42 });
533    }
534
535    #[test]
536    fn test_merged_pr_suggests_cleanup() {
537        let pr = merged_pr("main");
538        let action = NextAction::detect(&ctx(
539            "feature/test",
540            "main",
541            &WorkingDirState::Clean,
542            &SyncState::Synced,
543            Some(&pr),
544            true,
545        ));
546        assert_eq!(action, NextAction::Cleanup);
547    }
548
549    #[test]
550    fn test_closed_pr_suggests_reopen_or_cleanup() {
551        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Closed, "main");
552        let action = NextAction::detect(&ctx(
553            "feature/test",
554            "main",
555            &WorkingDirState::Clean,
556            &SyncState::Synced,
557            Some(&pr),
558            true,
559        ));
560        assert_eq!(action, NextAction::PrClosed { pr_number: 42 });
561    }
562
563    #[test]
564    fn test_behind_upstream_suggests_pull() {
565        let action = NextAction::detect(&ctx(
566            "feature/test",
567            "main",
568            &WorkingDirState::Clean,
569            &SyncState::Behind { count: 3 },
570            None,
571            true,
572        ));
573        assert_eq!(action, NextAction::PullUpstream { behind_count: 3 });
574    }
575
576    #[test]
577    fn test_behind_base_before_pr_suggests_sync() {
578        let action = NextAction::detect(&DetectContext {
579            behind_base: 2,
580            ..ctx(
581                "feature/test",
582                "main",
583                &WorkingDirState::Clean,
584                &SyncState::Synced,
585                None,
586                true,
587            )
588        });
589        assert_eq!(
590            action,
591            NextAction::BehindBase {
592                base: "origin/main".to_string(),
593                behind_count: 2
594            }
595        );
596    }
597
598    #[test]
599    fn test_behind_base_takes_priority_over_push() {
600        let action = NextAction::detect(&DetectContext {
601            behind_base: 1,
602            ..ctx(
603                "feature/test",
604                "main",
605                &WorkingDirState::Clean,
606                &SyncState::HasUnpushedCommits { count: 2 },
607                None,
608                true,
609            )
610        });
611        assert!(matches!(action, NextAction::BehindBase { .. }));
612    }
613
614    #[test]
615    fn test_behind_stacked_parent_names_the_parent() {
616        let action = NextAction::detect(&DetectContext {
617            recorded_base: Some("feature/parent"),
618            base_ref: "origin/feature/parent",
619            behind_base: 4,
620            ..ctx(
621                "feature/child",
622                "main",
623                &WorkingDirState::Clean,
624                &SyncState::Synced,
625                None,
626                true,
627            )
628        });
629        assert_eq!(
630            action,
631            NextAction::BehindBase {
632                base: "origin/feature/parent".to_string(),
633                behind_count: 4
634            }
635        );
636    }
637
638    #[test]
639    fn test_behind_base_with_open_pr_still_waits_for_review() {
640        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
641        let action = NextAction::detect(&DetectContext {
642            behind_base: 5,
643            ..ctx(
644                "feature/test",
645                "main",
646                &WorkingDirState::Clean,
647                &SyncState::Synced,
648                Some(&pr),
649                true,
650            )
651        });
652        assert_eq!(action, NextAction::WaitingForReview { pr_number: 42 });
653    }
654
655    #[test]
656    fn test_uncommitted_changes_take_priority_over_behind_base() {
657        let action = NextAction::detect(&DetectContext {
658            behind_base: 5,
659            ..ctx(
660                "feature/test",
661                "main",
662                &WorkingDirState::HasUnstagedChanges,
663                &SyncState::Synced,
664                None,
665                true,
666            )
667        });
668        assert_eq!(action, NextAction::CommitChanges);
669    }
670
671    #[test]
672    fn test_diverged_suggests_resolve() {
673        let action = NextAction::detect(&ctx(
674            "feature/test",
675            "main",
676            &WorkingDirState::Clean,
677            &SyncState::Diverged {
678                ahead: 2,
679                behind: 3,
680            },
681            None,
682            true,
683        ));
684        assert_eq!(action, NextAction::ResolveDivergence);
685    }
686
687    #[test]
688    fn test_uncommitted_changes_takes_priority_over_pr_open() {
689        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
690        let action = NextAction::detect(&ctx(
691            "feature/test",
692            "main",
693            &WorkingDirState::HasStagedChanges,
694            &SyncState::Synced,
695            Some(&pr),
696            true,
697        ));
698        assert_eq!(action, NextAction::CommitChanges);
699    }
700
701    #[test]
702    fn test_merged_pr_takes_priority_over_uncommitted_changes() {
703        let pr = merged_pr("main");
704        let action = NextAction::detect(&ctx(
705            "feature/test",
706            "main",
707            &WorkingDirState::HasUnstagedChanges,
708            &SyncState::Synced,
709            Some(&pr),
710            true,
711        ));
712        // Merged PR takes priority - cleanup first
713        assert_eq!(action, NextAction::Cleanup);
714    }
715
716    #[test]
717    fn test_base_pr_merged_suggests_sync() {
718        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "feature/base");
719        let action = NextAction::detect(&DetectContext {
720            base_pr_merged: Some("feature/base"),
721            ..ctx(
722                "feature/child",
723                "main",
724                &WorkingDirState::Clean,
725                &SyncState::Synced,
726                Some(&pr),
727                true,
728            )
729        });
730        assert_eq!(
731            action,
732            NextAction::SyncNeeded {
733                base_branch: "feature/base".to_string()
734            }
735        );
736    }
737
738    #[test]
739    fn test_base_pr_merged_takes_priority_over_waiting() {
740        let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "feature/base");
741        let action = NextAction::detect(&DetectContext {
742            base_pr_merged: Some("feature/base"),
743            ..ctx(
744                "feature/child",
745                "main",
746                &WorkingDirState::Clean,
747                &SyncState::Synced,
748                Some(&pr),
749                true,
750            )
751        });
752        // SyncNeeded should take priority over WaitingForReview
753        assert_eq!(
754            action,
755            NextAction::SyncNeeded {
756                base_branch: "feature/base".to_string()
757            }
758        );
759    }
760}