Files
github__gh-stack/internal/stack/stack.go
T
Sameen Karim 754d190490 Fork unmerged branches into new stack (#154)
* Fork unmerged branches into a new stack when the base stack is fully merged

Once every PR that is officially part of a stack on GitHub has been merged
-- especially after the merged branches are deleted upstream -- you can no
longer add to that stack. A new PR on top would target the trunk directly
instead of chaining onto the merged PRs, so the remote stack's "each PR's
base ref is the previous PR's head ref" invariant no longer holds. On the
next `gh stack submit`, the stack update was rejected and surfaced as a
confusing, dead-end warning:

    Failed to update stack on GitHub: Pull requests must form a stack,
    where each PR's base ref is the previous PR's head ref

`submit` had no handling for this: `syncStack` always sent the full PR list
(including the merged-and-deleted ones), so the API rejected the broken
chain even though the new PRs had already been created with correct bases.

Fork the survivors into a fresh stack instead of failing. After syncing PR
state and before pushing, `runSubmit` now calls `maybeForkFromMergedBase`:

- It triggers only when every PR officially part of the tracked remote
  stack (`s.ID`) has merged. Membership is read from the stacks API, so
  open PRs that are not part of the remote stack do not count, and -- this
  is the key guard -- a normal partial, bottom-up merge (where the remote
  stack still lists an open PR) is left completely untouched. A cheap
  pre-check (the local stack must have at least one merged branch) avoids
  an extra ListStacks call on the common path.
- The local branches are partitioned: those still in the merged remote
  stack stay behind; everything else (new branches, plus open PRs that were
  never part of that remote stack) is lifted into a brand-new stack rooted
  at the original trunk, with an empty remote ID. The bottom survivor is
  re-based onto the trunk.
- `runSubmit` continues with the new stack, so the push loop, PR creation,
  and `syncStack` all operate on it; the empty ID routes `syncStack` through
  the adopt/create path and a fresh stack is created on GitHub.
- The original, fully merged stack is left untouched on GitHub. Locally it
  is kept as a record only if at least one of its branches still exists in
  the working copy; otherwise it is dropped. No data is lost -- those PRs
  are already merged on GitHub.

To restructure the stack file safely, add `StackFile.IndexOfStack`, which
locates a stack by pointer identity so the fork can capture what it needs
before `AddStack`/`RemoveStack` reallocate the underlying slice.

Also soften the partial-merge case that does not fork: when an `UpdateStack`
call fails with the "must form a stack" 422 and the stack still contains
merged branches, report it as an informational note (the unmerged PRs were
pushed and re-based onto the trunk) rather than a scary failure warning.

Scope is limited to `submit`. `add` and `checkout` keep their existing
"refuse and suggest `gh stack init`" behavior on fully merged stacks.

Tests:
- cmd/submit_test.go: TestSubmit_ForksWhenRemoteStackFullyMerged covers both
  disposition variants (the old stack is removed when its merged branches
  are gone locally, kept when they still exist) and asserts that only the
  new branches are pushed, the fork message is printed, a fresh stack is
  created, and the local stack file is split into two stacks.
  TestSubmit_NoForkWhenRemoteStackHasOpenPR verifies the everyday bottom-up
  merge is not forked and that the broken-chain 422 is reported calmly.
  TestUpdateStack_BrokenChainAfterMerge checks the calm-vs-warn branch.
- internal/stack/stack_test.go: TestIndexOfStack covers identity lookup and
  the not-found case.

Docs: README, the CLI reference, the stacked-PRs guide, the FAQ, and the
agent SKILL.md note that submitting onto a fully merged stack starts a new
stack rooted at the trunk.

* Handle fully merged stacks gracefully in the view and modify TUIs

Merged branches (and their PRs) are not selectable, so once an entire stack
has landed there is nothing to act on -- yet the TUIs did not reflect that:

- `gh stack view` still drew a highlighted cursor on the top branch even
  though it could not be selected. Navigation, checkout, and the per-branch
  toggles all silently did nothing, with no indication of why.
- `gh stack modify` opened its full editor on a stack with nothing left to
  restructure, instead of short-circuiting like `gh stack submit` does when
  there is nothing to submit.

Reflect the "nothing actionable" state in both TUIs.

View (internal/tui/stackview/model.go):

- Hide the cursor when every branch is merged. `New` now starts the cursor
  at -1 and only lands it on the current or first non-merged branch; when
  none exists the cursor stays hidden, so no row is rendered as focused. The
  existing `m.cursor >= 0` guards and merged-skipping `moveCursor` already
  make every cursor action a no-op in that state, and mouse-wheel scrolling
  still works for tall merged stacks.
- Dim the shortcuts that depend on the cursor. `buildHeaderConfig` marks
  navigate, commits, files, open PR, and checkout as `Disabled` (rendered
  gray via the existing ShortcutEntry.Disabled styling) when all branches
  are merged, leaving only `q quit` active.

Modify (cmd/modify.go):

- Short-circuit before opening the TUI. After preconditions pass and PR
  state is synced, `runModify` now returns early when the stack is fully
  merged, printing "All branches in this stack have been merged" and
  pointing at `gh stack init`, exiting cleanly (exit 0) like submit's
  "nothing to submit" path. The linearity and merge-queue precondition
  checks already skip merged branches, so they do not fire spuriously.

Tests:
- internal/tui/stackview/model_test.go: the cursor is hidden (-1) when all
  branches are merged; up/down/enter do not move it or trigger a checkout;
  View renders without panicking on a hidden cursor; buildHeaderConfig
  disables every cursor-dependent shortcut (and only those) when all merged,
  and leaves them all enabled when active branches remain.
- cmd/modify_test.go: runModify short-circuits on a fully merged stack,
  printing the message and returning no error without launching the TUI.
2026-06-29 20:11:09 -04:00

417 lines
12 KiB
Go

package stack
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
schemaVersion = 1
stackFileName = "gh-stack"
)
// PullRequestRef holds relatively immutable metadata about an associated PR.
type PullRequestRef struct {
Number int `json:"number"`
ID string `json:"id,omitempty"`
URL string `json:"url,omitempty"`
Merged bool `json:"merged,omitempty"`
}
// BranchRef represents a branch and its associated commit hash.
// For the trunk, Head stores the HEAD commit SHA.
// For stacked branches, Base stores the parent branch's HEAD SHA
// at the time of last sync/rebase, used to identify unique commits.
type BranchRef struct {
Branch string `json:"branch"`
Head string `json:"head,omitempty"`
Base string `json:"base,omitempty"`
PullRequest *PullRequestRef `json:"pullRequest,omitempty"`
// Queued is a transient (not persisted) flag indicating the branch's
// PR is currently in a merge queue. It is populated by syncStackPRs
// from the GitHub API on each command run.
Queued bool `json:"-"`
}
// Stack represents a single stack of branches.
type Stack struct {
ID string `json:"id,omitempty"`
Prefix string `json:"prefix,omitempty"`
Numbered bool `json:"numbered,omitempty"`
Trunk BranchRef `json:"trunk"`
Branches []BranchRef `json:"branches"`
}
// DisplayChain returns a human-readable chain representation of the stack.
// Format: (trunk) <- branch1 <- branch2 <- branch3
func (s *Stack) DisplayChain() string {
parts := []string{"(" + s.Trunk.Branch + ")"}
for _, b := range s.Branches {
parts = append(parts, b.Branch)
}
return strings.Join(parts, " <- ")
}
// BranchNames returns the list of branch names in order.
func (s *Stack) BranchNames() []string {
names := make([]string, len(s.Branches))
for i, b := range s.Branches {
names[i] = b.Branch
}
return names
}
// IndexOf returns the index of the given branch in the stack, or -1 if not found.
func (s *Stack) IndexOf(branch string) int {
for i, b := range s.Branches {
if b.Branch == branch {
return i
}
}
return -1
}
// Contains returns true if the branch is part of this stack (including trunk).
func (s *Stack) Contains(branch string) bool {
if s.Trunk.Branch == branch {
return true
}
return s.IndexOf(branch) >= 0
}
// BaseBranch returns the base branch for the given branch in the stack.
// For the first branch, this is the trunk. For others, it's the previous branch.
func (s *Stack) BaseBranch(branch string) string {
idx := s.IndexOf(branch)
if idx <= 0 {
return s.Trunk.Branch
}
return s.Branches[idx-1].Branch
}
// IsMerged returns whether a branch's PR has been merged.
func (b *BranchRef) IsMerged() bool {
return b.PullRequest != nil && b.PullRequest.Merged
}
// IsQueued returns whether a branch's PR is currently in a merge queue.
// This is a transient state populated from the GitHub API on each run.
func (b *BranchRef) IsQueued() bool {
return b.Queued
}
// IsSkipped returns whether a branch should be skipped during push/sync/submit.
// A branch is skipped if its PR has been merged or is currently queued.
func (b *BranchRef) IsSkipped() bool {
return b.IsMerged() || b.IsQueued()
}
// ActiveBranches returns only branches that are pushable (not merged, not queued).
func (s *Stack) ActiveBranches() []BranchRef {
var active []BranchRef
for _, b := range s.Branches {
if !b.IsSkipped() {
active = append(active, b)
}
}
return active
}
// MergedBranches returns only merged branches, preserving order.
func (s *Stack) MergedBranches() []BranchRef {
var merged []BranchRef
for _, b := range s.Branches {
if b.IsMerged() {
merged = append(merged, b)
}
}
return merged
}
// QueuedBranches returns only queued branches, preserving order.
func (s *Stack) QueuedBranches() []BranchRef {
var queued []BranchRef
for _, b := range s.Branches {
if b.IsQueued() {
queued = append(queued, b)
}
}
return queued
}
// FirstActiveBranchIndex returns the index of the first active (not merged, not queued) branch, or -1.
func (s *Stack) FirstActiveBranchIndex() int {
for i, b := range s.Branches {
if !b.IsSkipped() {
return i
}
}
return -1
}
// ActiveBranchIndices returns the indices of all active (not merged, not queued) branches.
func (s *Stack) ActiveBranchIndices() []int {
var indices []int
for i, b := range s.Branches {
if !b.IsSkipped() {
indices = append(indices, i)
}
}
return indices
}
// ActiveBaseBranch returns the effective parent for a branch, skipping merged
// and queued ancestors. For the first active branch (or any branch whose
// downstack is all merged/queued), this returns the trunk.
func (s *Stack) ActiveBaseBranch(branch string) string {
idx := s.IndexOf(branch)
if idx <= 0 {
return s.Trunk.Branch
}
for j := idx - 1; j >= 0; j-- {
if !s.Branches[j].IsSkipped() {
return s.Branches[j].Branch
}
}
return s.Trunk.Branch
}
// IsFullyMerged returns true if all branches in the stack have been merged.
func (s *Stack) IsFullyMerged() bool {
for _, b := range s.Branches {
if !b.IsMerged() {
return false
}
}
return len(s.Branches) > 0
}
// StackFile represents the JSON file stored in .git/gh-stack.
type StackFile struct {
SchemaVersion int `json:"schemaVersion"`
Repository string `json:"repository"`
Stacks []Stack `json:"stacks"`
// loadChecksum is the SHA-256 of the raw file bytes at Load time.
// Save uses it to detect concurrent modifications (optimistic concurrency).
// nil means the file did not exist when loaded.
loadChecksum []byte
}
// FindAllStacksForBranch returns all stacks that contain the given branch.
func (sf *StackFile) FindAllStacksForBranch(branch string) []*Stack {
var stacks []*Stack
for i := range sf.Stacks {
if sf.Stacks[i].Contains(branch) {
stacks = append(stacks, &sf.Stacks[i])
}
}
return stacks
}
// IndexOfStack returns the index of the given stack within the file by identity
// (pointer), or -1 if it is not part of this file. Use it to locate a stack
// obtained from FindAllStacksForBranch before mutating the Stacks slice.
func (sf *StackFile) IndexOfStack(s *Stack) int {
for i := range sf.Stacks {
if &sf.Stacks[i] == s {
return i
}
}
return -1
}
// FindStackByPRNumber returns the first stack and branch whose PR number matches.
// Returns nil, nil if no match is found.
func (sf *StackFile) FindStackByPRNumber(prNumber int) (*Stack, *BranchRef) {
for i := range sf.Stacks {
for j := range sf.Stacks[i].Branches {
b := &sf.Stacks[i].Branches[j]
if b.PullRequest != nil && b.PullRequest.Number == prNumber {
return &sf.Stacks[i], b
}
}
}
return nil, nil
}
// ValidateNoDuplicateBranch checks that the branch is not already in any stack.
func (sf *StackFile) ValidateNoDuplicateBranch(branch string) error {
for _, s := range sf.Stacks {
if s.Contains(branch) {
return fmt.Errorf("branch %q is already part of a stack", branch)
}
}
return nil
}
// AddStack adds a new stack to the file.
func (sf *StackFile) AddStack(s Stack) {
sf.Stacks = append(sf.Stacks, s)
}
// RemoveStack removes the stack at the given index.
func (sf *StackFile) RemoveStack(idx int) {
sf.Stacks = append(sf.Stacks[:idx], sf.Stacks[idx+1:]...)
}
// RemoveStackForBranch removes the stack containing the given branch.
func (sf *StackFile) RemoveStackForBranch(branch string) bool {
for i := range sf.Stacks {
if sf.Stacks[i].Contains(branch) {
sf.RemoveStack(i)
return true
}
}
return false
}
// stackFilePath returns the path to the gh-stack file.
func stackFilePath(gitDir string) string {
return filepath.Join(gitDir, stackFileName)
}
// Load reads the stack file from the given git directory.
// Returns an empty StackFile if the file does not exist.
// The returned StackFile records a checksum of the on-disk content so that
// Save can detect concurrent modifications.
func Load(gitDir string) (*StackFile, error) {
path := stackFilePath(gitDir)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// loadChecksum stays nil — sentinel for "file absent at load time".
return &StackFile{
SchemaVersion: schemaVersion,
Stacks: []Stack{},
}, nil
}
return nil, fmt.Errorf("reading stack file: %w", err)
}
var sf StackFile
if err := json.Unmarshal(data, &sf); err != nil {
return nil, fmt.Errorf("parsing stack file: %w", err)
}
if sf.SchemaVersion > schemaVersion {
return nil, fmt.Errorf("stack file has schema version %d, but this version of gh-stack only supports up to version %d — please upgrade gh-stack", sf.SchemaVersion, schemaVersion)
}
sum := sha256.Sum256(data)
sf.loadChecksum = sum[:]
return &sf, nil
}
// Save acquires an exclusive lock on the stack file, verifies the file hasn't
// been modified since Load (optimistic concurrency), writes sf as JSON, and
// releases the lock. The lock is held only for the read-compare-write window.
// Returns *LockError if the lock times out, or *StaleError if another process
// modified the file since it was loaded.
func Save(gitDir string, sf *StackFile) error {
lock, err := Lock(gitDir)
if err != nil {
return err // *LockError for contention, plain error for I/O failures
}
defer lock.Unlock()
if err := checkStale(gitDir, sf); err != nil {
return err
}
return writeStackFile(gitDir, sf)
}
// SaveWithLock writes the stack file while the caller already holds the lock.
// The caller is responsible for acquiring and releasing the lock.
// Panics if lock is nil to catch programming errors.
func SaveWithLock(gitDir string, sf *StackFile, lock *FileLock) error {
if lock == nil {
panic("SaveWithLock called with nil lock")
}
return writeStackFile(gitDir, sf)
}
// SaveNonBlocking attempts to save without blocking. If another process holds
// the lock or the file was modified since Load, the save is silently skipped.
// Use this for best-effort metadata persistence (e.g. syncing PR state in view).
func SaveNonBlocking(gitDir string, sf *StackFile) {
path := filepath.Join(gitDir, lockFileName)
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return
}
if tryLockFile(f) != nil {
f.Close()
return
}
lock := &FileLock{f: f}
defer lock.Unlock()
if checkStale(gitDir, sf) != nil {
return
}
_ = writeStackFile(gitDir, sf)
}
// checkStale compares the current on-disk content against the checksum
// captured at Load time. Returns *StaleError if the file was modified
// by another process. The caller must hold the lock.
func checkStale(gitDir string, sf *StackFile) error {
path := stackFilePath(gitDir)
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
// File absent on disk.
if sf.loadChecksum == nil {
return nil // was absent at Load time too — no conflict
}
// File existed at Load but is now gone. Allow the write to
// recreate it rather than erroring; this is not a lost-update.
return nil
}
if err != nil {
return fmt.Errorf("reading stack file for staleness check: %w", err)
}
// File exists on disk.
if sf.loadChecksum == nil {
// File was absent at Load but another process created it.
return &StaleError{Err: fmt.Errorf(
"stack file was created by another process since it was loaded")}
}
sum := sha256.Sum256(data)
if !bytes.Equal(sf.loadChecksum, sum[:]) {
return &StaleError{Err: fmt.Errorf(
"stack file was modified by another process since it was loaded")}
}
return nil
}
func writeStackFile(gitDir string, sf *StackFile) error {
sf.SchemaVersion = schemaVersion
if sf.Stacks == nil {
sf.Stacks = []Stack{}
}
data, err := json.MarshalIndent(sf, "", " ")
if err != nil {
return fmt.Errorf("marshaling stack file: %w", err)
}
path := stackFilePath(gitDir)
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing stack file: %w", err)
}
// Refresh checksum so a second Save on the same StackFile doesn't
// spuriously fail the staleness check.
sum := sha256.Sum256(data)
sf.loadChecksum = sum[:]
return nil
}