use super::helpers;
use crate::error::{GwError, Result};
use crate::git;
use crate::github::{self, PrState};
use crate::output;
use crate::state::{RepoType, SyncState, WorkingDirState};
struct Plan {
new_base: String,
boundary: Option<String>,
retarget_pr: Option<u64>,
unstack: bool,
rerecord_base: bool,
}
pub fn run(verbose: bool) -> Result<()> {
if !git::is_git_repo() {
return Err(GwError::NotAGitRepository);
}
let working_dir = WorkingDirState::detect();
if !working_dir.is_clean() {
output::error(&format!(
"You have uncommitted changes ({}).",
working_dir.description()
));
output::action("git add <files> && git commit -m \"...\" # commit first");
output::action("gw pause # or park the work as WIP");
return Err(GwError::UncommittedChanges);
}
let repo_type = RepoType::detect()?;
let home_branch = repo_type.home_branch();
let current = git::current_branch()?;
if current == home_branch {
println!();
output::info(&format!("Branch: {}", output::bold(¤t)));
output::info("Fetching from origin...");
git::fetch_prune(verbose)?;
output::success("Fetched (stale remote branches pruned)");
let default_remote = git::get_default_remote_branch()?;
let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
helpers::pull_with_output(&default_remote, default_branch, verbose)?;
output::ready("Ready", home_branch);
return Ok(());
}
println!();
output::info(&format!("Branch: {}", output::bold(¤t)));
output::info("Fetching from origin...");
git::fetch_prune(verbose)?;
let pr = if github::is_gh_available() {
match github::get_pr_for_branch(¤t) {
Ok(pr) => pr,
Err(e) => {
output::warn(&format!("Could not fetch PR info: {e}"));
output::warn("Assuming no PR; syncing with the locally known base.");
None
}
}
} else {
output::warn("GitHub CLI (gh) not available; syncing with the locally known base.");
None
};
let default_remote = git::get_default_remote_branch()?;
let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
let recorded_base = git::branch_base(¤t).filter(|b| b != default_branch && b != ¤t);
let recorded_base_sha = recorded_base
.as_ref()
.and_then(|_| git::branch_base_sha(¤t))
.filter(|sha| git::is_ancestor(sha, "HEAD"));
let plan = match pr {
Some(pr) => {
output::info(&format!("PR: #{} ({})", pr.number, pr.title));
output::info(&format!("Base: {}", pr.base_branch));
match &pr.state {
PrState::Merged { .. } => {
output::success(&format!("PR #{} is merged. Nothing to sync.", pr.number));
output::hints(&["gw cleanup # Delete the merged branch"]);
return Ok(());
}
PrState::Closed => {
output::warn(&format!(
"PR #{} was closed without merging. Nothing to sync.",
pr.number
));
output::hints(&[&format!("gh pr reopen {} # Reopen it first", pr.number)]);
return Ok(());
}
PrState::Open => {}
}
if pr.base_branch == default_branch {
match retargeted_boundary(recorded_base.as_deref(), recorded_base_sha.as_deref()) {
Some(boundary) => Plan {
new_base: default_remote.clone(),
boundary: Some(boundary),
retarget_pr: None,
unstack: true,
rerecord_base: false,
},
None => Plan {
new_base: default_remote.clone(),
boundary: None,
retarget_pr: None,
unstack: false,
rerecord_base: false,
},
}
} else {
match plan_for_stacked_pr(
&pr.base_branch,
pr.number,
&default_remote,
recorded_base_sha.as_deref(),
)? {
Some(plan) => plan,
None => return Ok(()),
}
}
}
None => match &recorded_base {
Some(base) => {
match plan_for_recorded_base(base, &default_remote, recorded_base_sha.as_deref())? {
Some(plan) => plan,
None => return Ok(()),
}
}
None => Plan {
new_base: default_remote.clone(),
boundary: None,
retarget_pr: None,
unstack: false,
rerecord_base: false,
},
},
};
execute(&plan, ¤t, default_branch, verbose)
}
fn retargeted_boundary(
recorded_base: Option<&str>,
recorded_base_sha: Option<&str>,
) -> Option<String> {
let base = recorded_base?;
if !recorded_base_pr_merged(base) {
return None;
}
output::info(&format!(
"Recorded base '{}' merged and the PR now targets the default branch — restacking",
base
));
boundary_for_merged_base(base, recorded_base_sha)
}
fn recorded_base_pr_merged(base: &str) -> bool {
if !github::is_gh_available() {
return false;
}
match github::get_pr_for_branch(base) {
Ok(Some(base_pr)) => base_pr.state.is_merged(),
Ok(None) => false,
Err(e) => {
output::warn(&format!("Could not check PR for base '{}': {}", base, e));
false
}
}
}
fn boundary_for_merged_base(base: &str, recorded_base_sha: Option<&str>) -> Option<String> {
if let Some(sha) = recorded_base_sha {
return Some(sha.to_string());
}
let remote_ref = format!("origin/{base}");
if git::ref_exists(&remote_ref) {
return Some(remote_ref);
}
output::warn(&format!(
"Cannot find where '{}' was forked from (no recorded base tip, origin/{} is gone).",
base, base
));
output::hints(&[&format!(
"git rebase --onto origin/main <last commit of {base}> # replay only your commits"
)]);
None
}
fn plan_for_stacked_pr(
base: &str,
pr_number: u64,
default_remote: &str,
recorded_base_sha: Option<&str>,
) -> Result<Option<Plan>> {
let base_pr = github::get_pr_for_branch(base)?;
match base_pr.as_ref().map(|p| &p.state) {
Some(PrState::Merged { .. }) => {
let base_pr = base_pr.as_ref().expect("matched Some");
output::success(&format!(
"Base PR #{} ({}) is merged ✓",
base_pr.number, base
));
Ok(
boundary_for_merged_base(base, recorded_base_sha).map(|boundary| Plan {
new_base: default_remote.to_string(),
boundary: Some(boundary),
retarget_pr: Some(pr_number),
unstack: true,
rerecord_base: false,
}),
)
}
Some(PrState::Closed) => {
let base_pr = base_pr.as_ref().expect("matched Some");
output::warn(&format!(
"Base PR #{} ({}) was closed without merging.",
base_pr.number, base
));
output::hints(&[&format!(
"gh pr reopen {} # or retarget this PR with gh pr edit --base",
base_pr.number
)]);
Ok(None)
}
Some(PrState::Open) | None => {
if base_pr.is_none() {
output::warn(&format!(
"No PR found for base branch '{}'; following origin/{}.",
base, base
));
}
let base_ref = format!("origin/{base}");
if !git::ref_exists(&base_ref) {
output::warn(&format!("origin/{} does not exist. Nothing to sync.", base));
return Ok(None);
}
Ok(Some(Plan {
new_base: base_ref,
boundary: recorded_base_sha.map(String::from),
retarget_pr: None,
unstack: false,
rerecord_base: recorded_base_sha.is_some(),
}))
}
}
}
fn plan_for_recorded_base(
base: &str,
default_remote: &str,
recorded_base_sha: Option<&str>,
) -> Result<Option<Plan>> {
output::info(&format!("Base: {} (stacked, PR not created yet)", base));
if recorded_base_pr_merged(base) {
output::success(&format!("Base '{}' merged ✓ — restacking onto main", base));
return Ok(
boundary_for_merged_base(base, recorded_base_sha).map(|boundary| Plan {
new_base: default_remote.to_string(),
boundary: Some(boundary),
retarget_pr: None,
unstack: true,
rerecord_base: false,
}),
);
}
let remote_ref = format!("origin/{base}");
let base_ref = if git::ref_exists(&remote_ref) {
remote_ref
} else if git::branch_exists(base) {
base.to_string()
} else {
output::warn(&format!(
"Base branch '{}' no longer exists locally or on origin. Nothing to sync.",
base
));
return Ok(None);
};
Ok(Some(Plan {
new_base: base_ref,
boundary: recorded_base_sha.map(String::from),
retarget_pr: None,
unstack: false,
rerecord_base: recorded_base_sha.is_some(),
}))
}
fn execute(plan: &Plan, current: &str, default_branch: &str, verbose: bool) -> Result<()> {
let upstream_exists = git::has_remote_tracking(current);
println!();
if git::is_ancestor(&plan.new_base, "HEAD") && plan.retarget_pr.is_none() {
output::success(&format!("Already up to date with {}", plan.new_base));
if upstream_exists && matches!(SyncState::detect(current), Ok(SyncState::Diverged { .. })) {
output::info("Local history was rewritten but not pushed — publishing...");
git::force_push_with_lease(current, verbose)?;
output::success("Force pushed");
}
if plan.unstack {
git::unset_branch_base(current, verbose)?;
}
output::ready("Synced", current);
return Ok(());
}
let behind = git::behind_base_count("HEAD", &plan.new_base);
output::info("Syncing...");
let result = match &plan.boundary {
Some(boundary) => {
output::info(&format!(
" Rebasing commits after {} onto {} ({} new commit(s))...",
short(boundary),
plan.new_base,
behind
));
git::rebase_onto(&plan.new_base, boundary, verbose)
}
None => {
output::info(&format!(
" Rebasing onto {} ({} new commit(s))...",
plan.new_base, behind
));
git::rebase(&plan.new_base, verbose)
}
};
if let Err(e) = result {
output::error("Rebase failed. You may need to resolve conflicts manually.");
output::action("git rebase --continue # After resolving conflicts, then: gw sync");
output::action("git rebase --abort # To cancel");
return Err(e);
}
if let Some(pr_number) = plan.retarget_pr {
output::info(&format!(" Updating PR base to {}...", default_branch));
github::update_pr_base(pr_number, default_branch)?;
}
if upstream_exists {
output::info(" Force pushing...");
git::force_push_with_lease(current, verbose)?;
}
if plan.unstack {
git::unset_branch_base(current, verbose)?;
} else if plan.rerecord_base {
if let Ok(sha) = git::rev_parse(&plan.new_base) {
git::set_branch_base_sha(current, &sha, verbose)?;
}
}
println!();
output::ready("Synced", current);
let mut hints: Vec<String> = Vec::new();
if let Some(pr_number) = plan.retarget_pr {
hints.push(format!(
"PR #{} base is now '{}'",
pr_number, default_branch
));
}
if !upstream_exists {
hints.push(format!(
"git push -u origin {current} # Publish when ready"
));
}
hints.push("gw status # Check status".to_string());
let hint_refs: Vec<&str> = hints.iter().map(String::as_str).collect();
output::hints(&hint_refs);
Ok(())
}
fn short(reference: &str) -> &str {
if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
&reference[..7]
} else {
reference
}
}