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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,8 @@ Creates a Stacked PR for every branch in the stack, pushing branches to the remo

After creating PRs, `submit` automatically creates a **Stack** on GitHub to link the PRs together. If the stack already exists on GitHub (e.g., from a previous submit), new PRs will be added to the top of the stack.

If every PR in the stack has already been merged, that stack is complete and can't be extended — a new PR on top would target the trunk directly rather than chaining onto the merged PRs. In that case `submit` automatically starts a **new** stack rooted at the trunk for your unmerged branches and creates it on GitHub, leaving the merged stack untouched.

In an interactive terminal, `submit` opens a full-screen, mouse- and keyboard-driven editor on a single screen. Every branch without a PR is included by default — deselect any you don't want on the left panel (<kbd>Ctrl</kbd>+<kbd>X</kbd>). Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it. Draft each PR's title, description (with a markdown preview and `$EDITOR` escape), and choose ready-for-review or draft on the right, then submit them all at once with <kbd>Ctrl</kbd>+<kbd>S</kbd>. Pass `--auto` (or run in CI) to skip the editor and use auto-generated titles.

In the editor, new PRs default to ready for review; flip any PR to draft with the ready ↔ draft toggle. With `--auto`, new PRs are created as drafts unless you pass `--open`.
Expand Down
8 changes: 8 additions & 0 deletions cmd/modify.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ func runModify(cfg *config.Config) error {
s := result.Stack
currentBranch := result.CurrentBranch

// A fully merged stack has nothing left to restructure. Short-circuit
// before opening the TUI, mirroring submit's "nothing to submit" behavior.
if s.IsFullyMerged() {
cfg.Warningf("All branches in this stack have been merged")
cfg.Printf("There's nothing to modify — start a new stack with `%s`", cfg.ColorCyan("gh stack init"))
return nil
}

// Load branch data for the TUI
viewNodes := stackview.LoadBranchNodes(cfg, s, currentBranch, result.PRDetails)

Expand Down
45 changes: 45 additions & 0 deletions cmd/modify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"encoding/json"
"io"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -636,6 +637,50 @@ func TestCheckModifyPreconditions_AllPass(t *testing.T) {
assert.Equal(t, "b1", result.CurrentBranch)
}

func TestRunModify_FullyMergedStack_ShortCircuits(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

mock := &git.MockOps{
GitDirFn: func() (string, error) { return tmpDir, nil },
CurrentBranchFn: func() (string, error) { return "b1", nil },
IsRebaseInProgressFn: func() bool { return false },
HasUncommittedChangesFn: func() (bool, error) { return false, nil },
BranchExistsFn: func(string) bool { return true },
IsAncestorFn: func(a, d string) (bool, error) { return true, nil },
LogMergesFn: func(base, head string) ([]git.CommitInfo, error) { return nil, nil },
}
restore := git.SetOps(mock)
defer restore()

cfg, _, errR := config.NewTestConfig()
cfg.ForceInteractive = true
cfg.GitHubClientOverride = &github.MockClient{
FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil },
}

// runModify must short-circuit (and never launch the TUI) on a fully
// merged stack, returning cleanly like submit's "nothing to submit" path.
err := runModify(cfg)

cfg.Out.Close()
cfg.Err.Close()
out, _ := io.ReadAll(errR)
output := string(out)

assert.NoError(t, err)
assert.Contains(t, output, "All branches in this stack have been merged")
assert.Contains(t, output, "gh stack init")
}

// ---------------------------------------------------------------------------
// 5. State file path / exists edge cases
// ---------------------------------------------------------------------------
Expand Down
156 changes: 156 additions & 0 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
// Sync PR state to detect merged/queued PRs before pushing.
prDetails := syncStackPRs(cfg, s)

// If the active branches now sit on top of a fully-merged base, they can no
// longer extend the existing remote stack. Fork them into a fresh stack
// rooted at the trunk and continue the submit with that new stack.
if stacksAvailable {
s = maybeForkFromMergedBase(cfg, client, sf, s, gitDir)
}

// Resolve remote for pushing
remote, err := pickRemote(cfg, currentBranch, opts.remote)
if err != nil {
Expand Down Expand Up @@ -477,6 +484,144 @@ func humanize(s string) string {
}, s)
}

// maybeForkFromMergedBase detects when every PR that is officially part of the
// stack on GitHub has already been merged, and forks the remaining local
// branches (new branches, or open PRs that were never part of that remote stack)
// into a brand-new stack rooted at the trunk.
//
// Once all of a stack's remote PRs are merged — especially after their branches
// are deleted upstream — you can no longer add to that stack: a new PR on top
// would target the trunk, breaking the remote stack's "each PR's base ref ==
// previous PR's head ref" chain. Rather than failing the stack update on GitHub,
// we lift the survivors into a new local stack with no remote ID so the
// subsequent submit creates a fresh stack on GitHub. The original (fully merged)
// remote stack is left untouched on GitHub.
//
// It returns the stack submit should continue with: the new forked stack when a
// fork happens, or the original stack otherwise.
func maybeForkFromMergedBase(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, s *stack.Stack, gitDir string) *stack.Stack {
// Only meaningful when there is a tracked remote stack to evaluate. A fork
// can only happen if every remote-stack PR is merged, which implies at least
// one locally tracked branch is merged — checking that first avoids an extra
// ListStacks call on the common path.
if s.ID == "" || len(s.MergedBranches()) == 0 {
return s
}

remotePRs := remoteStackPRs(client, s.ID)
if len(remotePRs) == 0 {
return s
}

// Every PR officially in the remote stack must be merged. Open PRs that are
// not part of the remote stack do not count.
merged := mergedPRNumbers(s)
for _, n := range remotePRs {
if !merged[n] {
return s // a remote-stack PR is still open — not a fork situation
}
}

stackIdx := sf.IndexOfStack(s)
if stackIdx < 0 {
return s
}

// Partition the local branches: those that are part of the merged remote
// stack stay behind; everything else (new branches and open PRs that were
// never part of the remote stack) is forked into a new stack.
remoteSet := make(map[int]bool, len(remotePRs))
for _, n := range remotePRs {
remoteSet[n] = true
}
var keepBranches, forkBranches []stack.BranchRef
for _, b := range s.Branches {
if b.PullRequest != nil && remoteSet[b.PullRequest.Number] {
keepBranches = append(keepBranches, b)
} else {
forkBranches = append(forkBranches, b)
}
}
if len(forkBranches) == 0 {
return s // nothing new to fork — the whole stack is merged and done
}

// Capture trunk/prefix before mutating sf.Stacks (RemoveStack/AddStack can
// reallocate the slice and invalidate the s pointer).
trunk := s.Trunk
prefix := s.Prefix
numbered := s.Numbered

// The bottom surviving branch re-bases onto the trunk.
if base, err := git.MergeBase(forkBranches[0].Branch, trunk.Branch); err == nil {
forkBranches[0].Base = base
}

cfg.Warningf("Every PR in this stack has already been merged on GitHub")
cfg.Printf("Adding to a fully merged stack isn't supported — starting a new stack for your %d unmerged %s based on %s",
len(forkBranches), plural(len(forkBranches), "branch", "branches"), cfg.ColorCyan(trunk.Branch))

// Decide the fate of the original (fully merged) stack: keep it as a record

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind of enjoying the dramatic description of these operations 😄 Accurate and reads like an action thriller

// only if at least one of its branches still exists locally; otherwise drop
// it. The merged stack is left intact on GitHub either way.
removeOld := true
for _, b := range keepBranches {
if git.BranchExists(b.Branch) {
removeOld = false
break
}
}

if removeOld {
sf.RemoveStack(stackIdx)
} else {
sf.Stacks[stackIdx].Branches = keepBranches
}

sf.AddStack(stack.Stack{
Prefix: prefix,
Numbered: numbered,
Trunk: trunk,
Branches: forkBranches,
})

if err := stack.Save(gitDir, sf); err != nil {
// Persisting the split failed, but the in-memory model is correct;
// surface the error and continue so the PRs still get submitted.
_ = handleSaveError(cfg, err)
}

return &sf.Stacks[len(sf.Stacks)-1]
}

// remoteStackPRs returns the PR numbers that are officially part of the remote
// stack identified by stackID, or nil if it can't be determined.
func remoteStackPRs(client github.ClientOps, stackID string) []int {
stacks, err := client.ListStacks()
if err != nil {
return nil
}
for _, rs := range stacks {
if strconv.Itoa(rs.ID) == stackID {
return rs.PullRequests
}
}
return nil
}

// mergedPRNumbers returns the set of PR numbers whose local branch is marked
// merged. Call after syncStackPRs so the merge flags reflect the remote state.
func mergedPRNumbers(s *stack.Stack) map[int]bool {
merged := make(map[int]bool)
for i := range s.Branches {
b := &s.Branches[i]
if b.IsMerged() && b.PullRequest != nil {
merged[b.PullRequest.Number] = true
}
}
return merged
}

// handlePendingModify handles the stack recreation after a modify operation.
// It deletes the old remote stack and clears s.ID so syncStack creates a new
// one. The state file is NOT cleared here — it is cleared after syncStack
Expand Down Expand Up @@ -665,6 +810,17 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr
// immediately try to re-create it.
s.ID = ""
createNewStack(cfg, client, s, prNumbers)
case 422:
// A merged branch whose ref has been deleted upstream breaks the
// stack's base→head chain, so the update is rejected. This is
// expected once part of the stack has landed; the unmerged PRs
// were still pushed and re-based, so explain it calmly rather
// than alarming the user with a raw API error.
if strings.Contains(httpErr.Message, "must form a stack") && len(s.MergedBranches()) > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of string matching, would be a bit more maintainable to match on error type. But that's just a nit thought. Would require a refactor that doesn't seem worth the effort rn

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, the plan is to have better, more descriptive error types with the upcoming REST API for stacks. We should definitely update this when moving to the new API.

cfg.Infof("Merged PRs have left the stack on GitHub, so it wasn't updated — your unmerged PRs were pushed and re-based onto the trunk")
return
}
cfg.Warningf("Failed to update stack on GitHub: %s", httpErr.Message)
default:
cfg.Warningf("Failed to update stack on GitHub: %s", httpErr.Message)
}
Expand Down
Loading
Loading