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
16 changes: 8 additions & 8 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,22 @@ When a multi-branch rebase is interrupted by a conflict, gh-stack saves the oper
| `current` | Branch where the conflict occurred |
| `pending` | Remaining branches to rebase |
| `original_head` | HEAD before the operation started (for abort) |
| `operation` | `"cascade"` or `"submit"` |
| `operation` | `"cascade"` or `"submit"` (`"cascade"` is used by the `restack` command) |
| `stash_ref` | Commit hash of auto-stashed uncommitted changes |
| `branches` | Full branch list (submit only; used to rebuild the set for push/PR phases) |
| `update_only`, `web`, `push_only` | Submit-specific flags preserved across continue |

### Cascade State Lifecycle

1. **Created** when a rebase conflict interrupts `cascade`, `submit`, or `sync`.
1. **Created** when a rebase conflict interrupts `restack`, `submit`, or `sync`.
2. **Removed** before `continue` resumes (will be recreated if another conflict occurs).
3. **Removed** on successful completion or `abort`.

### Cascade State Trade-offs

This is an ephemeral, single-operation state file. It is not designed to survive beyond the operation that created it.

- **Single-operation scope.** Only one cascade/submit/sync can be in progress at a time. The code checks for this file and refuses to start a new operation if one exists.
- **Single-operation scope.** Only one restack/submit/sync can be in progress at a time. The code checks for this file and refuses to start a new operation if one exists.
- **Best-effort persistence.** Save errors are ignored (`//nolint:errcheck`) because if we can't save state, the user can still recover manually by aborting the rebase and re-running the command.

## Undo Snapshots
Expand All @@ -116,7 +116,7 @@ Before any destructive operation, gh-stack captures a snapshot of every affected
{
"timestamp": "2026-02-05T12:00:00Z",
"operation": "cascade",
"command": "gh stack cascade",
"command": "gh stack restack",
"original_head": "abc123...",
"stash_ref": "",
"branches": {
Expand All @@ -133,7 +133,7 @@ Before any destructive operation, gh-stack captures a snapshot of every affected

### Snapshot Lifecycle

1. **Created** before destructive operations (`cascade`, `submit`, `sync`).
1. **Created** before destructive operations (`restack`, `submit`, `sync`).
2. **Used** by `undo`, which restores branch refs and config keys from the snapshot.
3. **Archived** to `done/` after a successful undo.
4. **Pruned** automatically: max 50 active snapshots and 50 archived. Oldest are removed first.
Expand All @@ -153,7 +153,7 @@ flowchart TD
init[init]
create[create / adopt]
submit[submit / link]
cascade[cascade / sync]
restack[restack / sync]
undoCmd[undo]
end

Expand All @@ -166,8 +166,8 @@ flowchart TD
init -->|set trunk| config
create -->|set parent, fork point| config
submit -->|set PR number| config
cascade -->|on conflict| state
cascade -->|before start| snapshots
restack -->|on conflict| state
restack -->|before start| snapshots
undoCmd -->|restore from| snapshots
undoCmd -->|restore| config
```
Expand Down
54 changes: 27 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,26 +76,26 @@ main
Say we've made changes in `feature-auth`. To keep the stack in sync, we will need to rebase `feature-auth-tests` onto `feature-auth`. From branch `feature-auth`, execute:

```bash
gh stack cascade
gh stack restack
```

If you run into conflicts, resolve them and run `gh stack continue` to resume the cascade (or `gh stack abort` to cancel). Once complete, your local stacks will be in sync. _They won't yet be pushed to the remote repository._
If you run into conflicts, resolve them and run `gh stack continue` to resume the restack (or `gh stack abort` to cancel). Once complete, your local stacks will be in sync. _They won't yet be pushed to the remote repository._

#### Scenario 2: Changes in the local trunk

Maybe we pulled down `main` and it has new commits. We'll use the same strategy as above, but this time from the `main` branch:

```bash
gh stack cascade
gh stack restack
```

> [!NOTE]
>
> Since `main` (the trunk) is the parent of every stack, `gh stack cascade` will naturally cascade _all_ stacks.
> Since `main` (the trunk) is the parent of every stack, `gh stack restack` will naturally restack _all_ stacks.

#### Scenario 3: Upstream changes

Say `feature-auth` has been merged into the remote `main`. We now need to cascade the changes, but also retarget `feature-auth-tests` to `main` from `feature-auth`. You'll want to run:
Say `feature-auth` has been merged into the remote `main`. We now need to restack the changes, but also retarget `feature-auth-tests` to `main` from `feature-auth`. You'll want to run:

```bash
gh stack sync
Expand All @@ -108,7 +108,7 @@ This will:
3. Detect merged PRs
4. Clean up merged branches
5. Retarget orphaned children to trunk
6. Cascade all branches
6. Restack all branches

What it _won't_ do is push back up to the remote; see the [next section](#creating--updating-prs) for that.

Expand All @@ -124,7 +124,7 @@ Whenever you need to push these branches again, or update the PRs, you can run `

> [!TIP]
>
> `gh stack submit` does everything `gh stack cascade` does, and then some. Generally, if you want to make local mid-stack changes _without_ pushing to the remote, you'll want `gh stack cascade`; otherwise just use `gh stack submit`.
> `gh stack submit` does everything `gh stack restack` does, and then some. Generally, if you want to make local mid-stack changes _without_ pushing to the remote, you'll want `gh stack restack`; otherwise just use `gh stack submit`.

## Commands

Expand All @@ -137,11 +137,11 @@ Whenever you need to push these branches again, or update the PRs, you can run `
| `orphan` | Stop tracking a branch |
| `link` | Associate PR number with branch |
| `unlink` | Remove PR association |
| `submit` | Cascade, push, and create/update PRs in one command |
| `cascade` | Rebase branch and descendants onto parents |
| `submit` | Restack, push, and create/update PRs in one command |
| `restack` | Rebase branch and descendants onto parents |
| `continue` | Resume operation after conflict resolution |
| `abort` | Cancel in-progress operation |
| `sync` | Full sync: fetch, cleanup merged PRs, cascade all |
| `sync` | Full sync: fetch, cleanup merged PRs, restack all |
| `undo` | Undo the last destructive operation |

## Command Reference
Expand Down Expand Up @@ -254,11 +254,11 @@ The PR itself is not affected; this only removes the local tracking.

### submit

Cascade, push, and create/update PRs for current branch and descendants.
Restack, push, and create/update PRs for current branch and descendants.

This is the primary workflow command. It performs three phases:

1. **Cascade**: Rebase current branch and descendants onto their parents
1. **Restack**: Rebase current branch and descendants onto their parents
2. **Push**: Force-push all affected branches (using `--force-with-lease`)
3. **PR**: Create PRs for branches without them; update PR bases for existing PRs

Expand All @@ -273,51 +273,51 @@ If a rebase conflict occurs, resolve it and run `gh stack continue`.
| `--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 |
| `--push-only` | Skip PR creation/update, only cascade and push |
| `--push-only` | Skip PR creation/update, only restack and push |

### cascade
### restack

Rebase the current branch and its descendants onto their parents.
Rebase the current branch and its descendants onto their parents. Aliased as `cascade`.

Use this when you've made local changes and want to keep your stack in sync without pushing or creating PRs. For a full submit workflow, use `gh stack submit` instead.

If a rebase conflict occurs, resolve it and run `gh stack continue`.

#### cascade Flags
#### restack Flags

| Flag | Description |
| ----------- | -------------------------------------------- |
| `--only` | Only cascade current branch, not descendants |
| `--dry-run` | Show what would be done |
| Flag | Description |
| ----------- | --------------------------------------------- |
| `--only` | Only restack current branch, not descendants |
| `--dry-run` | Show what would be done |

### continue

Continue a cascade or submit operation after resolving rebase conflicts.
Continue a restack or submit operation after resolving rebase conflicts.

After resolving conflicts and staging the changes, run this command to resume the operation.

### abort

Abort a cascade or submit operation in progress.
Abort a restack or submit operation in progress.

This aborts any in-progress rebase and cleans up the operation state. Your branches will be left in their pre-operation state.

### sync

Full sync: fetch from origin, detect merged PRs, clean up merged branches, retarget orphaned children, and cascade all branches.
Full sync: fetch from origin, detect merged PRs, clean up merged branches, retarget orphaned children, and restack all branches.

This is the command to run when upstream changes have occurred (e.g., a PR in your stack was merged). It handles the bookkeeping of updating your local stack to match remote state.

#### sync Flags

| Flag | Description |
| -------------- | ----------------------- |
| `--no-cascade` | Skip cascading branches |
| `--no-restack` | Skip restacking branches |
| `--dry-run` | Show what would be done |

### undo

Undo the last destructive operation (cascade, submit, or sync) by restoring branches to their pre-operation state.
Undo the last destructive operation (restack, submit, or sync) by restoring branches to their pre-operation state.

Before any destructive operation, gh-stack automatically captures a snapshot of affected branches. If something goes wrong or you change your mind, `undo` restores:

Expand Down Expand Up @@ -416,8 +416,6 @@ If you want the kitchen sink—stack navigation, branch surgery, a web UI, AI re
- **Easier debugging.** You can inspect and repair state with `git config --edit` or a text editor. No need for `git cat-file` or `git log` on an internal ref.
- **No state history.** **git-spice** gets a full audit log for free. **gh-stack** provides multi-level undo via separate snapshot files instead, which covers the common case (undoing the last operation) without the overhead.

See [ARCHITECTURE.md](ARCHITECTURE.md) for a detailed breakdown of **gh-stack**'s data storage approach.

## Project Scope

- **gh-stack** aims to be a minimal alternative to Graphite for those who do not need its full feature set
Expand All @@ -437,6 +435,8 @@ make lint # Run linter
make gh-install # Install as gh extension locally
```

See [ARCHITECTURE.md](ARCHITECTURE.md) for a detailed breakdown of **gh-stack**'s data storage approach.

## Acknowledgements

- Inspired by [Graphite][].
Expand Down
8 changes: 4 additions & 4 deletions cmd/abort.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import (

var abortCmd = &cobra.Command{
Use: "abort",
Short: "Abort a cascade in progress",
Long: `Abort a cascade operation and restore the original state.`,
Short: "Abort an operation in progress",
Long: `Abort a restack, submit, or sync operation and restore the original state.`,
RunE: runAbort,
}

Expand All @@ -35,7 +35,7 @@ func runAbort(cmd *cobra.Command, args []string) error {
// Check if cascade 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")
}

// Abort rebase if in progress
Expand All @@ -57,6 +57,6 @@ func runAbort(cmd *cobra.Command, args []string) error {
}
}

fmt.Printf("%s Cascade aborted. Original HEAD was %s\n", s.WarningIcon(), st.OriginalHead)
fmt.Printf("%s %s aborted. Original HEAD was %s\n", s.WarningIcon(), displayOperationName(st.Operation), st.OriginalHead)
return nil
}
35 changes: 24 additions & 11 deletions cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import (
var ErrConflict = errors.New("rebase conflict: resolve and run 'gh stack continue', or 'gh stack abort'")

var cascadeCmd = &cobra.Command{
Use: "cascade",
Short: "Rebase current branch and descendants onto their parents",
Long: `Rebase the current branch onto its parent, then recursively cascade to descendants.`,
RunE: runCascade,
Use: "restack",
Aliases: []string{"cascade"},
Short: "Rebase current branch and descendants onto their parents",
Long: `Rebase the current branch onto its parent, then recursively restack descendants.`,
RunE: runCascade,
}

var (
Expand All @@ -31,7 +32,7 @@ var (
)

func init() {
cascadeCmd.Flags().BoolVar(&cascadeOnlyFlag, "only", false, "only cascade current branch, not descendants")
cascadeCmd.Flags().BoolVar(&cascadeOnlyFlag, "only", false, "only restack current branch, not descendants")
cascadeCmd.Flags().BoolVar(&cascadeDryRunFlag, "dry-run", false, "show what would be done")
rootCmd.AddCommand(cascadeCmd)
}
Expand All @@ -53,7 +54,7 @@ func runCascade(cmd *cobra.Command, args []string) error {

// Check if cascade already in progress
if state.Exists(g.GetGitDir()) {
return fmt.Errorf("cascade already in progress; use 'gh stack continue' or 'gh stack abort'")
return fmt.Errorf("operation already in progress; use 'gh stack continue' or 'gh stack abort'")
}

currentBranch, err := g.CurrentBranch()
Expand Down Expand Up @@ -84,7 +85,7 @@ func runCascade(cmd *cobra.Command, args []string) error {
var stashRef string
if !cascadeDryRunFlag {
var saveErr error
stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "cascade", "gh stack cascade", s)
stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "cascade", "gh stack restack", s)
if saveErr != nil {
fmt.Printf("%s could not save undo state: %v\n", s.WarningIcon(), saveErr)
}
Expand Down Expand Up @@ -129,7 +130,7 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
}

if !needsRebase {
fmt.Printf("Cascading %s... %s\n", s.Branch(b.Name), s.Muted("already up to date"))
fmt.Printf("Restacking %s... %s\n", s.Branch(b.Name), s.Muted("already up to date"))
continue
}

Expand All @@ -153,9 +154,9 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
}

if useOnto {
fmt.Printf("Cascading %s onto %s %s...\n", s.Branch(b.Name), s.Branch(parent), s.Muted("(using fork point)"))
fmt.Printf("Restacking %s onto %s %s...\n", s.Branch(b.Name), s.Branch(parent), s.Muted("(using fork point)"))
} else {
fmt.Printf("Cascading %s onto %s...\n", s.Branch(b.Name), s.Branch(parent))
fmt.Printf("Restacking %s onto %s...\n", s.Branch(b.Name), s.Branch(parent))
}

// Checkout and rebase
Expand Down Expand Up @@ -198,7 +199,7 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
return ErrConflict
}

fmt.Printf("Cascading %s... %s\n", s.Branch(b.Name), s.Success("ok"))
fmt.Printf("Restacking %s... %s\n", s.Branch(b.Name), s.Success("ok"))

// Update fork point to current parent tip
parentTip, tipErr := g.GetTip(parent)
Expand All @@ -215,6 +216,18 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
return nil
}

// displayOperationName maps internal operation constants to user-facing names.
func displayOperationName(op string) string {
switch op {
case state.OperationCascade:
return "Restack"
case state.OperationSubmit:
return "Submit"
default:
return "Operation"
}
}

// saveUndoSnapshot captures the current state of branches before a destructive operation.
// It auto-stashes any uncommitted changes if the working tree is dirty.
// branches: branches that will be modified (rebased)
Expand Down
4 changes: 2 additions & 2 deletions cmd/continue.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
var continueCmd = &cobra.Command{
Use: "continue",
Short: "Continue an operation after resolving conflicts",
Long: `Continue a cascade or submit operation after resolving rebase conflicts.`,
Long: `Continue a restack, submit, or sync operation after resolving rebase conflicts.`,
RunE: runContinue,
}

Expand Down Expand Up @@ -133,6 +133,6 @@ func runContinue(cmd *cobra.Command, args []string) error {
}
}

fmt.Println(s.SuccessMessage("Cascade complete!"))
fmt.Println(s.SuccessMessage("Restack complete!"))
return nil
}
10 changes: 5 additions & 5 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ import (

var submitCmd = &cobra.Command{
Use: "submit",
Short: "Cascade, push, and create/update PRs for current branch and descendants",
Short: "Restack, push, and create/update PRs for current branch and descendants",
Long: `Submit rebases the current branch and its descendants onto their parents,
pushes all affected branches, and creates or updates pull requests.

This is the typical workflow command after making changes in a stack:
1. Cascade: rebase current branch + descendants onto their parents
1. Restack: rebase current branch + descendants onto their parents
2. Push: force-push all affected branches (with --force-with-lease)
3. PR: create PRs for branches without them, update PR bases for those that have them

Expand All @@ -45,7 +45,7 @@ func init() {
submitCmd.Flags().BoolVar(&submitDryRunFlag, "dry-run", false, "show what would be done without doing it")
submitCmd.Flags().BoolVar(&submitCurrentOnlyFlag, "current-only", false, "only submit current branch, not descendants")
submitCmd.Flags().BoolVar(&submitUpdateOnlyFlag, "update-only", false, "only update existing PRs, don't create new ones")
submitCmd.Flags().BoolVar(&submitPushOnlyFlag, "push-only", false, "skip PR creation/update, only cascade and push")
submitCmd.Flags().BoolVar(&submitPushOnlyFlag, "push-only", false, "skip PR creation/update, only restack and push")
submitCmd.Flags().BoolVarP(&submitYesFlag, "yes", "y", false, "skip interactive prompts and use auto-generated title/description for PRs")
submitCmd.Flags().BoolVarP(&submitWebFlag, "web", "w", false, "open created/updated PRs in web browser")
rootCmd.AddCommand(submitCmd)
Expand Down Expand Up @@ -135,8 +135,8 @@ func runSubmit(cmd *cobra.Command, args []string) error {
}
}

// Phase 1: Cascade
fmt.Println(s.Bold("=== Phase 1: Cascade ==="))
// Phase 1: Restack
fmt.Println(s.Bold("=== Phase 1: Restack ==="))
if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, submitPushOnlyFlag, branchNames, stashRef, s); cascadeErr != nil {
// Stash is saved in state for conflicts; restore on other errors
if cascadeErr != ErrConflict && stashRef != "" {
Expand Down
Loading