1use crate::github::PrInfo;
6use crate::output;
7
8use super::{SyncState, WorkingDirState};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum NextAction {
13 StartNewWork,
15 SyncHomeWithUpstream { behind_count: usize },
17 CommitChanges,
19 PushChanges,
21 CreatePr { base: Option<String> },
24 WaitingForReview { pr_number: u64 },
26 Cleanup,
28 BehindBase { base: String, behind_count: usize },
31 PullUpstream { behind_count: usize },
33 ResolveDivergence,
35 PrClosed { pr_number: u64 },
37 SyncNeeded { base_branch: String },
39 StackedBaseMerged {
44 base_branch: String,
45 base_sha: Option<String>,
46 },
47}
48
49pub 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 pub base_pr_merged: Option<&'a str>,
61 pub recorded_base: Option<&'a str>,
64 pub recorded_base_merged: bool,
67 pub recorded_base_sha: Option<&'a str>,
70 pub base_ref: &'a str,
73 pub behind_base: usize,
75}
76
77impl NextAction {
78 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 if current_branch == home_branch {
94 if let SyncState::Behind { count } = sync_state {
96 return NextAction::SyncHomeWithUpstream {
97 behind_count: *count,
98 };
99 }
100 return NextAction::StartNewWork;
101 }
102
103 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 if let Some(base_branch) = base_pr_merged {
117 return NextAction::SyncNeeded {
118 base_branch: base_branch.to_string(),
119 };
120 }
121
122 if !matches!(working_dir, WorkingDirState::Clean) {
124 return NextAction::CommitChanges;
125 }
126
127 if matches!(sync_state, SyncState::Diverged { .. }) {
129 return NextAction::ResolveDivergence;
130 }
131
132 if let SyncState::Behind { count } = sync_state {
134 return NextAction::PullUpstream {
135 behind_count: *count,
136 };
137 }
138
139 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 if matches!(
153 sync_state,
154 SyncState::HasUnpushedCommits { .. } | SyncState::NoUpstream
155 ) {
156 return NextAction::PushChanges;
157 }
158
159 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 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 NextAction::WaitingForReview { pr_number: 0 }
187 }
188
189 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 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 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 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 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 assert_eq!(
754 action,
755 NextAction::SyncNeeded {
756 base_branch: "feature/base".to_string()
757 }
758 );
759 }
760}