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
55 changes: 41 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,38 +68,66 @@ main
└── feature-auth-tests
```

### Create PRs for Your Stack
### Rebase After Parent Changes

```bash
gh stack pr
gh stack cascade
```

Creates a PR targeting the parent branch. If a PR already exists, updates its base.
Rebases the current branch onto its parent, then cascades to all descendants. If conflicts occur:

```bash
# Resolve conflicts, then:
gh stack continue

# Or abort:
gh stack abort
```

### Push Your Stack
### Submit Your Stack

```bash
gh stack push
gh stack submit
```

Force-pushes (with lease) all branches from trunk to your current branch, updating PR bases as needed.
The all-in-one command for getting your work onto GitHub. Submit:

### Rebase After Parent Changes
1. **Cascades** the current branch and its descendants onto their parents
2. **Pushes** all affected branches with `--force-with-lease`
3. **Creates/updates PRs** for each branch (creates as draft if mid-stack)

This is typically what you run after making changes:

```bash
gh stack cascade
# Make some changes
git add . && git commit -m "fix: address review feedback"

# Ship it
gh stack submit
```

Rebases the current branch onto its parent, then cascades to all descendants. If conflicts occur:
#### Flags

| Flag | Description |
| ---------------- | ------------------------------------------------ |
| `--dry-run` | Show what would happen without doing it |
| `--current-only` | Only submit the current branch, not descendants |
| `--update-only` | Only update existing PRs, don't create new ones |

#### Conflict Resolution

If a rebase conflict occurs during submit:

```bash
# Resolve conflicts, then:
# Resolve the conflicts, then:
gh stack continue

# Or abort:
gh stack abort
```

After continuing, submit resumes with push and PR phases.

### Sync Everything

```bash
Expand All @@ -119,11 +147,10 @@ Fetches from origin, fast-forwards trunk, detects merged PRs, cleans up merged b
| `orphan` | Stop tracking a branch |
| `link` | Associate PR number with branch |
| `unlink` | Remove PR association |
| `pr` | Create or update PR targeting parent |
| `push` | Force-push stack with `--force-with-lease` |
| `submit` | Cascade, push, and create/update PRs in one command |
| `cascade` | Rebase branch and descendants onto parents |
| `continue` | Resume cascade after conflict resolution |
| `abort` | Cancel cascade operation |
| `continue` | Resume operation after conflict resolution |
| `abort` | Cancel in-progress operation |
| `sync` | Full sync: fetch, cleanup merged PRs, cascade all |

## How It Works
Expand Down
9 changes: 9 additions & 0 deletions cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ func runCascade(cmd *cobra.Command, args []string) error {
}

func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool) error {
return doCascadeWithState(g, cfg, branches, dryRun, state.OperationCascade, false, nil)
}

// doCascadeWithState performs cascade and saves state with the given operation type.
// allBranches is the complete list of branches for submit operations (used for push/PR after continue).
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly bool, allBranches []string) error {
originalBranch, err := g.CurrentBranch()
if err != nil {
return err
Expand Down Expand Up @@ -162,6 +168,9 @@ func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun boo
Current: b.Name,
Pending: remaining,
OriginalHead: originalHead,
Operation: operation,
UpdateOnly: updateOnly,
Branches: allBranches,
}
_ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually

Expand Down
69 changes: 51 additions & 18 deletions cmd/continue.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import (

var continueCmd = &cobra.Command{
Use: "continue",
Short: "Continue a cascade after resolving conflicts",
Long: `Continue a cascade operation after resolving rebase conflicts.`,
Short: "Continue an operation after resolving conflicts",
Long: `Continue a cascade or submit operation after resolving rebase conflicts.`,
RunE: runContinue,
}

Expand All @@ -31,10 +31,10 @@ func runContinue(cmd *cobra.Command, args []string) error {

g := git.New(cwd)

// Check if cascade in progress
// Check if operation in progress
st, err := state.Load(g.GetGitDir())
if err != nil {
return fmt.Errorf("no cascade in progress")
return fmt.Errorf("no operation in progress")
}

// Complete the in-progress rebase
Expand All @@ -47,13 +47,6 @@ func runContinue(cmd *cobra.Command, args []string) error {

fmt.Printf("Completed %s\n", st.Current)

// Continue with remaining branches
if len(st.Pending) == 0 {
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
fmt.Println("Cascade complete!")
return nil
}

cfg, err := config.Load(cwd)
if err != nil {
return err
Expand All @@ -65,15 +58,55 @@ func runContinue(cmd *cobra.Command, args []string) error {
return err
}

var branches []*tree.Node
for _, name := range st.Pending {
if node := tree.FindNode(root, name); node != nil {
branches = append(branches, node)
// If there are more branches to cascade, continue cascading
if len(st.Pending) > 0 {
var branches []*tree.Node
for _, name := range st.Pending {
if node := tree.FindNode(root, name); node != nil {
branches = append(branches, node)
}
}

// Remove state file before continuing (will be recreated if conflict)
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup

if err := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Branches); err != nil {
return err // Another conflict - state saved
}
} else {
// No more branches to cascade - cleanup state
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
}

// Remove state file before continuing (will be recreated if conflict)
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
// If this was a submit operation, continue with push + PR phases
if st.Operation == state.OperationSubmit {
// Rebuild branches list from the original set of submit branches if available.
// Fall back to the current + pending branches for backward compatibility.
var branchNames []string
if len(st.Branches) > 0 {
branchNames = st.Branches
} else {
branchNames = append(branchNames, st.Current)
branchNames = append(branchNames, st.Pending...)
}

var allBranches []*tree.Node
for _, name := range branchNames {
node := tree.FindNode(root, name)
if node == nil {
// Preserve existing behaviour: fail fast if a branch from state
// cannot be found in the current tree.
if name == st.Current {
return fmt.Errorf("branch %q not found in tree", st.Current)
}
continue
}
allBranches = append(allBranches, node)
}

return doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly)
}

return doCascade(g, cfg, branches, false)
fmt.Println("Cascade complete!")
return nil
}
Loading