diff --git a/src/commands/new.rs b/src/commands/new.rs index cf8bd8d..818e434 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -142,6 +142,12 @@ pub fn run(branch_name: Option, stack: bool, verbose: bool) -> Result<() // guard work to handle; `gh pr create` errors loudly on a missing base.) if let Some(base) = &pr_base { git::set_branch_base(&branch_name, base, verbose)?; + // Record the base tip SHA (= the fork point, since --stack branches off + // the current HEAD) so a later restack can `rebase --onto` even if the + // base branch has since been deleted. + if let Ok(sha) = git::head_commit() { + git::set_branch_base_sha(&branch_name, &sha, verbose)?; + } } if behind_count > 0 { diff --git a/src/commands/status.rs b/src/commands/status.rs index 1fff1da..b24bcba 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -117,6 +117,11 @@ pub fn run() -> Result<()> { recorded_base_merged = check_base_pr_merged(base).is_some(); } } + // The recorded base tip is the `rebase --onto` boundary that survives the + // base branch being deleted (used when the base merged before this PR). + let recorded_base_sha = recorded_base + .as_ref() + .and_then(|_| git::branch_base_sha(¤t)); // Stash count let stash_count = git::stash_count(); @@ -135,6 +140,7 @@ pub fn run() -> Result<()> { base_pr_merged: base_pr_merged.as_deref(), recorded_base: recorded_base.as_deref(), recorded_base_merged, + recorded_base_sha: recorded_base_sha.as_deref(), }); next_action.display(¤t); diff --git a/src/git/mutation.rs b/src/git/mutation.rs index b570838..3cb0e5e 100644 --- a/src/git/mutation.rs +++ b/src/git/mutation.rs @@ -173,17 +173,33 @@ pub fn set_branch_base(branch: &str, base: &str, verbose: bool) -> Result<()> { ) } -/// Clear a branch's recorded base (`branch..gwBase`). +/// Record the base tip SHA a branch was stacked on (`branch..gwBaseSha`). /// -/// A no-op (not an error) when the key is absent, so callers can clear -/// unconditionally — e.g. `gw sync` after restacking a branch onto the default -/// branch, where it is no longer stacked. +/// A `git rebase --onto` boundary that survives the base branch being deleted. +pub fn set_branch_base_sha(branch: &str, sha: &str, verbose: bool) -> Result<()> { + git_run( + &["config", &format!("branch.{branch}.gwBaseSha"), sha], + verbose, + ) +} + +/// Clear a branch's recorded base info (`branch..gwBase` and `.gwBaseSha`). +/// +/// Each unset is a no-op (not an error) when the key is absent, so callers can +/// clear unconditionally — e.g. `gw sync` after restacking a branch onto the +/// default branch, where it is no longer stacked. pub fn unset_branch_base(branch: &str, verbose: bool) -> Result<()> { + unset_config_key(&format!("branch.{branch}.gwBase"), verbose)?; + unset_config_key(&format!("branch.{branch}.gwBaseSha"), verbose) +} + +/// `git config --unset `, treating "key absent" (exit 5) as success. +fn unset_config_key(key: &str, verbose: bool) -> Result<()> { if verbose { - output::action(&format!("git config --unset branch.{branch}.gwBase")); + output::action(&format!("git config --unset {key}")); } let output = Command::new("git") - .args(["config", "--unset", &format!("branch.{branch}.gwBase")]) + .args(["config", "--unset", key]) .output() .map_err(|e| GwError::GitCommandFailed(format!("Failed to execute git: {e}")))?; // Exit code 5 = "key was not present"; treat as already-clear. diff --git a/src/git/query.rs b/src/git/query.rs index 40ec667..cf22835 100644 --- a/src/git/query.rs +++ b/src/git/query.rs @@ -107,6 +107,16 @@ pub fn branch_base(branch: &str) -> Option { if value.is_empty() { None } else { Some(value) } } +/// Read the recorded base tip SHA for a branch (`branch..gwBaseSha`). +/// +/// Recorded by `gw new --stack` as the parent's HEAD at stack time. Serves as a +/// `git rebase --onto` boundary that survives even if the base branch ref is +/// later deleted (e.g. cleaned up after its PR merged). `None` when unset. +pub fn branch_base_sha(branch: &str) -> Option { + let value = git_output(&["config", "--get", &format!("branch.{branch}.gwBaseSha")]).ok()?; + if value.is_empty() { None } else { Some(value) } +} + /// Get the current HEAD commit hash pub fn head_commit() -> Result { git_output(&["rev-parse", "HEAD"]) diff --git a/src/state/next_action.rs b/src/state/next_action.rs index 9676951..e27e5c9 100644 --- a/src/state/next_action.rs +++ b/src/state/next_action.rs @@ -34,8 +34,13 @@ pub enum NextAction { /// Base PR was merged, should sync (update base to main, rebase, push) SyncNeeded { base_branch: String }, /// Recorded stacked base merged before this branch's PR was created; rebase - /// onto the default branch and open a normal (non-stacked) PR. - StackedBaseMerged { base_branch: String }, + /// onto the default branch and open a normal (non-stacked) PR. `base_sha` is + /// the recorded base tip used as the `--onto` boundary when present (it + /// survives the base branch being deleted). + StackedBaseMerged { + base_branch: String, + base_sha: Option, + }, } /// Inputs for [`NextAction::detect`]. Bundled into a struct because the detected @@ -56,6 +61,9 @@ pub struct DetectContext<'a> { /// The recorded base's PR has already merged (stale stacked base): the /// branch should rebase onto the default branch and open a normal PR. pub recorded_base_merged: bool, + /// Recorded base tip SHA (`gw new --stack`); the `--onto` boundary that + /// survives the base branch being deleted. + pub recorded_base_sha: Option<&'a str>, } impl NextAction { @@ -70,6 +78,7 @@ impl NextAction { 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; // On home branch if current_branch == home_branch { // Behind upstream → sync first @@ -131,6 +140,7 @@ impl NextAction { if let Some(base) = recorded_base { return NextAction::StackedBaseMerged { base_branch: base.to_string(), + base_sha: recorded_base_sha.map(String::from), }; } } @@ -244,7 +254,10 @@ impl NextAction { println!(); println!(" gw sync"); } - NextAction::StackedBaseMerged { base_branch } => { + NextAction::StackedBaseMerged { + base_branch, + base_sha, + } => { output::action(&format!( "Next: base '{}' merged — rebase onto main", base_branch @@ -253,10 +266,13 @@ impl NextAction { // `--onto` replays only THIS branch's commits. A plain // `git rebase origin/main` would re-apply the (squash-)merged // base's commits too, producing a doubled/conflicting diff. + // Prefer the recorded base SHA: it still resolves even though + // the merged base branch has likely been deleted. + let onto = base_sha.as_deref().unwrap_or(base_branch); println!(" git fetch --prune"); println!( " git rebase --onto origin/main {} # replay only your commits", - base_branch + onto ); println!(" # then open a normal PR (base is now main):"); println!(" gh pr create -a \"@me\" -t \"...\""); @@ -310,6 +326,7 @@ mod tests { base_pr_merged: None, recorded_base: None, recorded_base_merged: false, + recorded_base_sha: None, } } @@ -442,7 +459,32 @@ mod tests { assert_eq!( action, NextAction::StackedBaseMerged { - base_branch: "feature/parent".to_string() + 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()), } ); } diff --git a/tests/new_test.rs b/tests/new_test.rs index 44e3507..a18e178 100644 --- a/tests/new_test.rs +++ b/tests/new_test.rs @@ -108,6 +108,12 @@ fn test_stack_branches_off_current_branch() { run_git(dir, &["config", "--get", "branch.feature/child.gwBase"]), "feature/parent" ); + // The base tip SHA is recorded too (the rebase --onto boundary that + // survives the base branch being deleted) and equals the parent's HEAD. + assert_eq!( + run_git(dir, &["config", "--get", "branch.feature/child.gwBaseSha"]), + parent_head + ); } #[test]