Skip to content
Open
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.
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

The docs claim abort leaves branches in their pre-operation state, but the implementation only aborts the current rebase and removes the state file; any branches already successfully rebased earlier in the operation are not rolled back. Please adjust this description (and/or mention using gh stack undo for a full rollback) so user expectations match behavior.

Suggested change
This aborts any in-progress rebase and cleans up the operation state. Your branches will be left in their pre-operation state.
This aborts the currently in-progress rebase and cleans up the operation state. It does not roll back branches that were already successfully restacked earlier in the operation; use `gh stack undo` if you need to fully revert previously applied changes.

Copilot uses AI. Check for mistakes.

### 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
13 changes: 9 additions & 4 deletions cmd/abort.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package cmd
import (
"fmt"
"os"
"strings"

"github.com/boneskull/gh-stack/internal/git"
"github.com/boneskull/gh-stack/internal/state"
Expand All @@ -13,8 +14,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 or submit operation and restore the original state.`,
Comment on lines +17 to +18
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

The abort help text says it “restore[s] the original state”, but runAbort only does git rebase --abort, removes the state file, and restores the stash; it does not roll back any branches already successfully rebased earlier in the operation. To avoid misleading users, adjust the Short/Long description to reflect what actually happens (and optionally point users to gh stack undo for a full rollback).

Suggested change
Short: "Abort an operation in progress",
Long: `Abort a restack or submit operation and restore the original state.`,
Short: "Abort a restack or submit operation in progress",
Long: `Abort a restack or submit operation in progress.
This stops the current operation, aborts any in-progress rebase, cleans up
internal state, and restores any auto-stashed local changes.
It does not roll back branches that were already successfully rebased or
otherwise modified earlier in the operation. For a full rollback of changes
made by a restack or submit, use "gh stack undo".`,

Copilot uses AI. Check for mistakes.
RunE: runAbort,
}

Expand All @@ -35,7 +36,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 +58,10 @@ func runAbort(cmd *cobra.Command, args []string) error {
}
}

fmt.Printf("%s Cascade aborted. Original HEAD was %s\n", s.WarningIcon(), st.OriginalHead)
opName := st.Operation
if opName == "" {
opName = "Operation"
}
fmt.Printf("%s %s aborted. Original HEAD was %s\n", s.WarningIcon(), strings.ToUpper(opName[:1])+opName[1:], st.OriginalHead)
Comment on lines +63 to +65
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

abort prints the operation name based on st.Operation, but restack (and sync's restack phase) store OperationCascade ("cascade") in the state file. This means aborting a gh stack restack run will currently print “Cascade aborted…”, which conflicts with the renamed command. Consider mapping "cascade" to the display name "restack" (while still keeping "submit" as-is), or deriving the display name from the command invoked rather than the internal operation key.

Suggested change
opName = "Operation"
}
fmt.Printf("%s %s aborted. Original HEAD was %s\n", s.WarningIcon(), strings.ToUpper(opName[:1])+opName[1:], st.OriginalHead)
opName = "operation"
}
if opName == state.OperationCascade {
opName = "restack"
}
displayName := strings.ToUpper(opName[:1]) + opName[1:]
fmt.Printf("%s %s aborted. Original HEAD was %s\n", s.WarningIcon(), displayName, st.OriginalHead)

Copilot uses AI. Check for mistakes.
return nil
}
23 changes: 12 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
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

This comment says “Check if cascade already in progress”, but the command is now user-facing as restack and the error message is generalized to “operation already in progress”. Updating the comment to match the new terminology will make the flow easier to follow.

Suggested change
// Check if cascade already in progress
// Check if a restack operation is already in progress

Copilot uses AI. Check for mistakes.
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 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 or submit operation after resolving rebase conflicts.`,
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

continue can be triggered by a conflict during sync as well (the same state file is used), but the Long help text only mentions “restack or submit”. Update the Long text to include sync (or describe it generically as “an operation”) so CLI help matches actual behavior.

Suggested change
Long: `Continue a restack or submit operation after resolving rebase conflicts.`,
Long: `Continue a restack, submit, or sync operation after resolving rebase conflicts.`,

Copilot uses AI. Check for mistakes.
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
Loading