Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ pub fn run(branch_name: Option<String>, 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 {
Expand Down
6 changes: 6 additions & 0 deletions src/commands/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&current));

// Stash count
let stash_count = git::stash_count();
Expand All @@ -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(&current);

Expand Down
28 changes: 22 additions & 6 deletions src/git/mutation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,33 @@ pub fn set_branch_base(branch: &str, base: &str, verbose: bool) -> Result<()> {
)
}

/// Clear a branch's recorded base (`branch.<name>.gwBase`).
/// Record the base tip SHA a branch was stacked on (`branch.<name>.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.<name>.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 <key>`, 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.
Expand Down
10 changes: 10 additions & 0 deletions src/git/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ pub fn branch_base(branch: &str) -> Option<String> {
if value.is_empty() { None } else { Some(value) }
}

/// Read the recorded base tip SHA for a branch (`branch.<name>.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<String> {
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<String> {
git_output(&["rev-parse", "HEAD"])
Expand Down
52 changes: 47 additions & 5 deletions src/state/next_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
}

/// Inputs for [`NextAction::detect`]. Bundled into a struct because the detected
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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),
};
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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 \"...\"");
Expand Down Expand Up @@ -310,6 +326,7 @@ mod tests {
base_pr_merged: None,
recorded_base: None,
recorded_base_merged: false,
recorded_base_sha: None,
}
}

Expand Down Expand Up @@ -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()),
}
);
}
Expand Down
6 changes: 6 additions & 0 deletions tests/new_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading