use crate::github::PrInfo;
use crate::output;
use super::{SyncState, WorkingDirState};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NextAction {
StartNewWork,
SyncHomeWithUpstream { behind_count: usize },
CommitChanges,
PushChanges,
CreatePr { base: Option<String> },
WaitingForReview { pr_number: u64 },
Cleanup,
BehindBase { base: String, behind_count: usize },
PullUpstream { behind_count: usize },
ResolveDivergence,
PrClosed { pr_number: u64 },
SyncNeeded { base_branch: String },
StackedBaseMerged {
base_branch: String,
base_sha: Option<String>,
},
}
pub struct DetectContext<'a> {
pub current_branch: &'a str,
pub home_branch: &'a str,
pub working_dir: &'a WorkingDirState,
pub sync_state: &'a SyncState,
pub pr_info: Option<&'a PrInfo>,
pub has_remote: bool,
pub base_pr_merged: Option<&'a str>,
pub recorded_base: Option<&'a str>,
pub recorded_base_merged: bool,
pub recorded_base_sha: Option<&'a str>,
pub base_ref: &'a str,
pub behind_base: usize,
}
impl NextAction {
pub fn detect(ctx: &DetectContext) -> Self {
let current_branch = ctx.current_branch;
let home_branch = ctx.home_branch;
let working_dir = ctx.working_dir;
let sync_state = ctx.sync_state;
let pr_info = ctx.pr_info;
let has_remote = ctx.has_remote;
let base_pr_merged = ctx.base_pr_merged;
let recorded_base = ctx.recorded_base;
let recorded_base_merged = ctx.recorded_base_merged;
let recorded_base_sha = ctx.recorded_base_sha;
let base_ref = ctx.base_ref;
let behind_base = ctx.behind_base;
if current_branch == home_branch {
if let SyncState::Behind { count } = sync_state {
return NextAction::SyncHomeWithUpstream {
behind_count: *count,
};
}
return NextAction::StartNewWork;
}
if let Some(pr) = pr_info {
if pr.state.is_merged() {
return NextAction::Cleanup;
}
if pr.state.is_closed() {
return NextAction::PrClosed {
pr_number: pr.number,
};
}
}
if let Some(base_branch) = base_pr_merged {
return NextAction::SyncNeeded {
base_branch: base_branch.to_string(),
};
}
if !matches!(working_dir, WorkingDirState::Clean) {
return NextAction::CommitChanges;
}
if matches!(sync_state, SyncState::Diverged { .. }) {
return NextAction::ResolveDivergence;
}
if let SyncState::Behind { count } = sync_state {
return NextAction::PullUpstream {
behind_count: *count,
};
}
let pr_open = pr_info.is_some_and(|pr| pr.state.is_open());
if behind_base > 0 && !pr_open {
return NextAction::BehindBase {
base: base_ref.to_string(),
behind_count: behind_base,
};
}
if matches!(
sync_state,
SyncState::HasUnpushedCommits { .. } | SyncState::NoUpstream
) {
return NextAction::PushChanges;
}
if pr_info.is_none() && has_remote {
if recorded_base_merged {
if let Some(base) = recorded_base {
return NextAction::StackedBaseMerged {
base_branch: base.to_string(),
base_sha: recorded_base_sha.map(String::from),
};
}
}
return NextAction::CreatePr {
base: recorded_base.map(String::from),
};
}
if let Some(pr) = pr_info {
if pr.state.is_open() {
return NextAction::WaitingForReview {
pr_number: pr.number,
};
}
}
NextAction::WaitingForReview { pr_number: 0 }
}
pub fn display(&self, branch: &str) {
println!();
output::separator();
match self {
NextAction::StartNewWork => {
output::action("Next: start new work");
println!();
println!(" gw new feature/your-feature");
}
NextAction::SyncHomeWithUpstream { behind_count } => {
output::action(&format!(
"Next: sync with upstream ({} commit(s) behind)",
behind_count
));
println!();
println!(" gw home");
}
NextAction::CommitChanges => {
output::action("Next: commit changes");
println!();
println!(
" git add <files> && git commit -m \"feat: ...\" # stage deliberately, not -A"
);
}
NextAction::PushChanges => {
output::action("Next: push to remote");
println!();
println!(" git push -u origin {}", branch);
}
NextAction::CreatePr { base } => {
output::action("Next: create pull request");
println!();
match base {
Some(base) => {
println!(
" gh pr create -a \"@me\" -B {} -t \"...\" # stacked on {}",
base, base
)
}
None => println!(" gh pr create -a \"@me\" -t \"...\""),
}
}
NextAction::WaitingForReview { pr_number } => {
if *pr_number > 0 {
output::action(&format!("Waiting: PR #{} in review", pr_number));
println!();
println!(
" gw await {} --open # Wait for merge, then cleanup",
pr_number
);
println!(" gw open # Open PR in browser");
} else {
output::action("Waiting: PR in review");
println!();
println!(" gw open # Open PR in browser");
}
}
NextAction::Cleanup => {
output::action("Next: cleanup merged branch");
println!();
println!(" gw cleanup");
}
NextAction::BehindBase { base, behind_count } => {
output::action(&format!(
"Next: sync with {} ({} commit(s) behind)",
base, behind_count
));
println!();
println!(" gw sync # rebase onto the latest {base}");
}
NextAction::PullUpstream { behind_count } => {
output::action(&format!(
"Next: pull upstream changes ({} commit(s) behind origin/{})",
behind_count, branch
));
println!();
println!(" git pull --rebase # someone pushed to this branch");
}
NextAction::ResolveDivergence => {
output::action(&format!("Next: resolve divergence from origin/{}", branch));
println!();
println!(" # Option 1: You rewrote history locally (rebase/amend) — publish it");
println!(" git push --force-with-lease");
println!();
println!(" # Option 2: Someone else pushed to this branch — take their commits");
println!(" git pull --rebase");
}
NextAction::PrClosed { pr_number } => {
output::action(&format!("PR #{} was closed without merging", pr_number));
println!();
println!(" # Option 1: Reopen the PR");
println!(" gh pr reopen {}", pr_number);
println!();
println!(" # Option 2: Cleanup and start fresh");
println!(" gw cleanup");
}
NextAction::SyncNeeded { base_branch } => {
output::action(&format!("Next: sync (base '{}' was merged)", base_branch));
println!();
println!(" gw sync");
}
NextAction::StackedBaseMerged { base_branch, .. } => {
output::action(&format!(
"Next: base '{}' merged — restack onto main",
base_branch
));
println!();
println!(" gw sync # replay only your commits onto main");
println!(" # then open a normal PR (base is now main):");
println!(" gh pr create -a \"@me\" -t \"...\"");
}
}
output::separator();
}
pub fn short_description(&self) -> &'static str {
match self {
NextAction::StartNewWork => "start new work",
NextAction::SyncHomeWithUpstream { .. } => "sync with upstream",
NextAction::CommitChanges => "commit changes",
NextAction::PushChanges => "push to remote",
NextAction::CreatePr { .. } => "create PR",
NextAction::WaitingForReview { .. } => "waiting for review",
NextAction::Cleanup => "cleanup branch",
NextAction::BehindBase { .. } => "sync with base",
NextAction::PullUpstream { .. } => "pull upstream",
NextAction::ResolveDivergence => "resolve divergence",
NextAction::PrClosed { .. } => "PR closed",
NextAction::SyncNeeded { .. } => "sync needed",
NextAction::StackedBaseMerged { .. } => "rebase (base merged)",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::github::{PrInfo, PrState};
fn ctx<'a>(
current: &'a str,
home: &'a str,
working_dir: &'a WorkingDirState,
sync_state: &'a SyncState,
pr_info: Option<&'a PrInfo>,
has_remote: bool,
) -> DetectContext<'a> {
DetectContext {
current_branch: current,
home_branch: home,
working_dir,
sync_state,
pr_info,
has_remote,
base_pr_merged: None,
recorded_base: None,
recorded_base_merged: false,
recorded_base_sha: None,
base_ref: "origin/main",
behind_base: 0,
}
}
fn merged_pr(base: &str) -> PrInfo {
PrInfo::new(
42,
"Test PR",
"https://...",
PrState::Merged {
method: crate::github::MergeMethod::Squash,
merge_commit: None,
},
base,
)
}
#[test]
fn test_on_home_branch_suggests_start_new_work() {
let action = NextAction::detect(&ctx(
"main",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
None,
false,
));
assert_eq!(action, NextAction::StartNewWork);
}
#[test]
fn test_on_home_branch_behind_suggests_sync() {
let action = NextAction::detect(&ctx(
"main",
"main",
&WorkingDirState::Clean,
&SyncState::Behind { count: 5 },
None,
false,
));
assert_eq!(action, NextAction::SyncHomeWithUpstream { behind_count: 5 });
}
#[test]
fn test_uncommitted_changes_suggests_commit() {
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::HasUnstagedChanges,
&SyncState::Synced,
None,
true,
));
assert_eq!(action, NextAction::CommitChanges);
}
#[test]
fn test_unpushed_commits_suggests_push() {
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::HasUnpushedCommits { count: 2 },
None,
true,
));
assert_eq!(action, NextAction::PushChanges);
}
#[test]
fn test_no_upstream_suggests_push() {
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::NoUpstream,
None,
false,
));
assert_eq!(action, NextAction::PushChanges);
}
#[test]
fn test_pushed_no_pr_suggests_create_pr() {
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
None,
true,
));
assert_eq!(action, NextAction::CreatePr { base: None });
}
#[test]
fn test_pushed_no_pr_with_recorded_base_suggests_stacked_pr() {
let action = NextAction::detect(&DetectContext {
recorded_base: Some("feature/parent"),
..ctx(
"feature/child",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
None,
true,
)
});
assert_eq!(
action,
NextAction::CreatePr {
base: Some("feature/parent".to_string())
}
);
}
#[test]
fn test_recorded_base_merged_before_pr_suggests_rebase() {
let action = NextAction::detect(&DetectContext {
recorded_base: Some("feature/parent"),
recorded_base_merged: true,
..ctx(
"feature/child",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
None,
true,
)
});
assert_eq!(
action,
NextAction::StackedBaseMerged {
base_branch: "feature/parent".to_string(),
base_sha: None,
}
);
}
#[test]
fn test_recorded_base_merged_carries_recorded_sha() {
let action = NextAction::detect(&DetectContext {
recorded_base: Some("feature/parent"),
recorded_base_merged: true,
recorded_base_sha: Some("abc1234"),
..ctx(
"feature/child",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
None,
true,
)
});
assert_eq!(
action,
NextAction::StackedBaseMerged {
base_branch: "feature/parent".to_string(),
base_sha: Some("abc1234".to_string()),
}
);
}
#[test]
fn test_open_pr_suggests_waiting() {
let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
Some(&pr),
true,
));
assert_eq!(action, NextAction::WaitingForReview { pr_number: 42 });
}
#[test]
fn test_merged_pr_suggests_cleanup() {
let pr = merged_pr("main");
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
Some(&pr),
true,
));
assert_eq!(action, NextAction::Cleanup);
}
#[test]
fn test_closed_pr_suggests_reopen_or_cleanup() {
let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Closed, "main");
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
Some(&pr),
true,
));
assert_eq!(action, NextAction::PrClosed { pr_number: 42 });
}
#[test]
fn test_behind_upstream_suggests_pull() {
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::Behind { count: 3 },
None,
true,
));
assert_eq!(action, NextAction::PullUpstream { behind_count: 3 });
}
#[test]
fn test_behind_base_before_pr_suggests_sync() {
let action = NextAction::detect(&DetectContext {
behind_base: 2,
..ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
None,
true,
)
});
assert_eq!(
action,
NextAction::BehindBase {
base: "origin/main".to_string(),
behind_count: 2
}
);
}
#[test]
fn test_behind_base_takes_priority_over_push() {
let action = NextAction::detect(&DetectContext {
behind_base: 1,
..ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::HasUnpushedCommits { count: 2 },
None,
true,
)
});
assert!(matches!(action, NextAction::BehindBase { .. }));
}
#[test]
fn test_behind_stacked_parent_names_the_parent() {
let action = NextAction::detect(&DetectContext {
recorded_base: Some("feature/parent"),
base_ref: "origin/feature/parent",
behind_base: 4,
..ctx(
"feature/child",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
None,
true,
)
});
assert_eq!(
action,
NextAction::BehindBase {
base: "origin/feature/parent".to_string(),
behind_count: 4
}
);
}
#[test]
fn test_behind_base_with_open_pr_still_waits_for_review() {
let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
let action = NextAction::detect(&DetectContext {
behind_base: 5,
..ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
Some(&pr),
true,
)
});
assert_eq!(action, NextAction::WaitingForReview { pr_number: 42 });
}
#[test]
fn test_uncommitted_changes_take_priority_over_behind_base() {
let action = NextAction::detect(&DetectContext {
behind_base: 5,
..ctx(
"feature/test",
"main",
&WorkingDirState::HasUnstagedChanges,
&SyncState::Synced,
None,
true,
)
});
assert_eq!(action, NextAction::CommitChanges);
}
#[test]
fn test_diverged_suggests_resolve() {
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::Clean,
&SyncState::Diverged {
ahead: 2,
behind: 3,
},
None,
true,
));
assert_eq!(action, NextAction::ResolveDivergence);
}
#[test]
fn test_uncommitted_changes_takes_priority_over_pr_open() {
let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "main");
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::HasStagedChanges,
&SyncState::Synced,
Some(&pr),
true,
));
assert_eq!(action, NextAction::CommitChanges);
}
#[test]
fn test_merged_pr_takes_priority_over_uncommitted_changes() {
let pr = merged_pr("main");
let action = NextAction::detect(&ctx(
"feature/test",
"main",
&WorkingDirState::HasUnstagedChanges,
&SyncState::Synced,
Some(&pr),
true,
));
assert_eq!(action, NextAction::Cleanup);
}
#[test]
fn test_base_pr_merged_suggests_sync() {
let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "feature/base");
let action = NextAction::detect(&DetectContext {
base_pr_merged: Some("feature/base"),
..ctx(
"feature/child",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
Some(&pr),
true,
)
});
assert_eq!(
action,
NextAction::SyncNeeded {
base_branch: "feature/base".to_string()
}
);
}
#[test]
fn test_base_pr_merged_takes_priority_over_waiting() {
let pr = PrInfo::new(42, "Test PR", "https://...", PrState::Open, "feature/base");
let action = NextAction::detect(&DetectContext {
base_pr_merged: Some("feature/base"),
..ctx(
"feature/child",
"main",
&WorkingDirState::Clean,
&SyncState::Synced,
Some(&pr),
true,
)
});
assert_eq!(
action,
NextAction::SyncNeeded {
base_branch: "feature/base".to_string()
}
);
}
}