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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Whenever you need to push these branches again, or update the PRs, you can run `
| `continue` | Resume operation after conflict resolution |
| `abort` | Cancel in-progress operation |
| `sync` | Full sync: fetch, cleanup merged PRs, cascade all |
| `undo` | Undo the last destructive operation |

## Command Reference

Expand Down Expand Up @@ -313,6 +314,29 @@ This is the command to run when upstream changes have occurred (e.g., a PR in yo
| `--no-cascade` | Skip cascading 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.

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

- Branch refs (SHAs)
- Stack configuration (parent, PR number, fork point)
- Any auto-stashed uncommitted changes

Snapshots are stored in `.git/stack-undo/` and archived to `.git/stack-undo/done/` after successful undo.

> [!NOTE]
>
> `undo` only affects local state. It cannot undo remote changes like force-pushes. If you've already pushed, you may need to force-push again after undoing.

#### undo Flags

| Flag | Description |
| ----------- | ---------------------------------------- |
| `--force` | Skip confirmation prompt |
| `--dry-run` | Show what would be restored without doing it |

## How It Works

**gh-stack** stores metadata in your local `.git/config`:
Expand Down
8 changes: 8 additions & 0 deletions cmd/abort.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ func runAbort(cmd *cobra.Command, args []string) error {
// Clean up state (ignore error - best effort cleanup)
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // best effort cleanup

// Restore auto-stashed changes if any
if st.StashRef != "" {
fmt.Println("Restoring auto-stashed changes...")
if popErr := g.StashPop(st.StashRef); popErr != nil {
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr)
}
}

fmt.Printf("Cascade aborted. Original HEAD was %s\n", st.OriginalHead)
return nil
}
182 changes: 168 additions & 14 deletions cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/boneskull/gh-stack/internal/git"
"github.com/boneskull/gh-stack/internal/state"
"github.com/boneskull/gh-stack/internal/tree"
"github.com/boneskull/gh-stack/internal/undo"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -47,15 +48,6 @@ func runCascade(cmd *cobra.Command, args []string) error {

g := git.New(cwd)

// Check for dirty working tree
dirty, err := g.IsDirty()
if err != nil {
return err
}
if dirty {
return fmt.Errorf("working tree has uncommitted changes; commit or stash first")
}

// 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'")
Expand Down Expand Up @@ -85,16 +77,33 @@ func runCascade(cmd *cobra.Command, args []string) error {
branches = append(branches, tree.GetDescendants(node)...)
}

return doCascade(g, cfg, branches, cascadeDryRunFlag)
}
// Save undo snapshot (unless dry-run)
var stashRef string
if !cascadeDryRunFlag {
var saveErr error
stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "cascade", "gh stack cascade")
if saveErr != nil {
fmt.Printf("Warning: could not save undo state: %v\n", saveErr)
}
}

err = doCascadeWithState(g, cfg, branches, cascadeDryRunFlag, state.OperationCascade, false, false, nil, stashRef)

// Restore auto-stashed changes after operation (unless conflict, which saves stash in state)
if stashRef != "" && err != ErrConflict {
fmt.Println("Restoring auto-stashed changes...")
if popErr := g.StashPop(stashRef); popErr != nil {
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(stashRef), popErr)
}
}

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

// 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, web bool, allBranches []string) error {
// stashRef is the commit hash of auto-stashed changes (if any), persisted to state on conflict.
func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun bool, operation string, updateOnly, web bool, allBranches []string, stashRef string) error {
originalBranch, err := g.CurrentBranch()
if err != nil {
return err
Expand Down Expand Up @@ -173,11 +182,15 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d
UpdateOnly: updateOnly,
Web: web,
Branches: allBranches,
StashRef: stashRef,
}
_ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually

fmt.Printf("\nCONFLICT: Resolve conflicts and run 'gh stack continue', or 'gh stack abort' to cancel.\n")
fmt.Printf("Remaining branches: %v\n", remaining)
if stashRef != "" {
fmt.Printf("Note: Your uncommitted changes are stashed and will be restored when you continue or abort.\n")
}
return ErrConflict
}

Expand All @@ -197,3 +210,144 @@ func doCascadeWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, d

return nil
}

// 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)
// deletedBranches: branches that will be deleted (for sync)
// Returns the stash ref (commit hash) if changes were stashed, empty string otherwise.
func saveUndoSnapshot(g *git.Git, cfg *config.Config, branches []*tree.Node, deletedBranches []*tree.Node, operation, command string) (string, error) {
gitDir := g.GetGitDir()

// Get current branch for original head
currentBranch, err := g.CurrentBranch()
if err != nil {
return "", err
}

// Create snapshot
snapshot := undo.NewSnapshot(operation, command, currentBranch)

// Auto-stash if dirty
var stashRef string
dirty, err := g.IsDirty()
if err != nil {
return "", err
}
if dirty {
var stashErr error
stashRef, stashErr = g.Stash("gh-stack auto-stash before " + operation)
if stashErr != nil {
return "", fmt.Errorf("failed to stash changes: %w", stashErr)
}
if stashRef != "" {
snapshot.StashRef = stashRef
fmt.Println("Auto-stashed uncommitted changes")
}
}

// Capture state of branches that will be modified
for _, node := range branches {
bs, captureErr := captureBranchState(g, cfg, node.Name)
if captureErr != nil {
// Non-fatal: log warning and continue
fmt.Printf("Warning: could not capture state for %s: %v\n", node.Name, captureErr)
continue
}
snapshot.Branches[node.Name] = bs
}

// Capture state of branches that will be deleted
for _, node := range deletedBranches {
bs, captureErr := captureBranchState(g, cfg, node.Name)
if captureErr != nil {
fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", node.Name, captureErr)
continue
}
snapshot.DeletedBranches[node.Name] = bs
}

// Save snapshot
if saveErr := undo.Save(gitDir, snapshot); saveErr != nil {
return "", saveErr
}
return stashRef, nil
}

// saveUndoSnapshotByName is like saveUndoSnapshot but takes branch names instead of tree nodes.
// Useful for sync where we don't always have tree nodes.
// Returns the stash ref (commit hash) if changes were stashed, empty string otherwise.
func saveUndoSnapshotByName(g *git.Git, cfg *config.Config, branchNames []string, deletedBranchNames []string, operation, command string) (string, error) {
gitDir := g.GetGitDir()

// Get current branch for original head
currentBranch, err := g.CurrentBranch()
if err != nil {
return "", err
}

// Create snapshot
snapshot := undo.NewSnapshot(operation, command, currentBranch)

// Auto-stash if dirty
var stashRef string
dirty, err := g.IsDirty()
if err != nil {
return "", err
}
if dirty {
var stashErr error
stashRef, stashErr = g.Stash("gh-stack auto-stash before " + operation)
if stashErr != nil {
return "", fmt.Errorf("failed to stash changes: %w", stashErr)
}
if stashRef != "" {
snapshot.StashRef = stashRef
fmt.Println("Auto-stashed uncommitted changes")
}
}

// Capture state of branches that will be modified
for _, name := range branchNames {
bs, captureErr := captureBranchState(g, cfg, name)
if captureErr != nil {
fmt.Printf("Warning: could not capture state for %s: %v\n", name, captureErr)
continue
}
snapshot.Branches[name] = bs
}

// Capture state of branches that will be deleted
for _, name := range deletedBranchNames {
bs, captureErr := captureBranchState(g, cfg, name)
if captureErr != nil {
fmt.Printf("Warning: could not capture state for deleted branch %s: %v\n", name, captureErr)
continue
}
snapshot.DeletedBranches[name] = bs
}

// Save snapshot
if saveErr := undo.Save(gitDir, snapshot); saveErr != nil {
return "", saveErr
}
return stashRef, nil
}

// captureBranchState captures the current state of a single branch.
func captureBranchState(g *git.Git, cfg *config.Config, branch string) (undo.BranchState, error) {
bs := undo.BranchState{}

sha, err := g.GetTip(branch)
if err != nil {
return bs, err
}
bs.SHA = sha

// Capture config (errors are non-fatal - empty values are fine)
bs.StackParent, _ = cfg.GetParent(branch) //nolint:errcheck // empty is fine
bs.StackPR, _ = cfg.GetPR(branch) //nolint:errcheck // 0 is fine
bs.StackForkPoint, _ = cfg.GetForkPoint(branch) //nolint:errcheck // empty is fine

return bs, nil
}
29 changes: 26 additions & 3 deletions cmd/continue.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,15 @@ func runContinue(cmd *cobra.Command, args []string) error {
// 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.Web, st.Branches); err != nil {
return err // Another conflict - state saved
if cascadeErr := doCascadeWithState(g, cfg, branches, false, st.Operation, st.UpdateOnly, st.Web, st.Branches, st.StashRef); cascadeErr != nil {
// Stash handling is done by doCascadeWithState (conflict saves in state, errors restore)
if cascadeErr != ErrConflict && st.StashRef != "" {
fmt.Println("Restoring auto-stashed changes...")
if popErr := g.StashPop(st.StashRef); popErr != nil {
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr)
}
}
return cascadeErr // Another conflict - state saved
}
} else {
// No more branches to cascade - cleanup state
Expand Down Expand Up @@ -104,7 +111,23 @@ func runContinue(cmd *cobra.Command, args []string) error {
allBranches = append(allBranches, node)
}

return doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly, st.Web)
err = doSubmitPushAndPR(g, cfg, root, allBranches, false, st.UpdateOnly, st.Web)
// Restore stash after submit completes
if st.StashRef != "" {
fmt.Println("Restoring auto-stashed changes...")
if popErr := g.StashPop(st.StashRef); popErr != nil {
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr)
}
}
return err
}

// Restore stash after cascade completes
if st.StashRef != "" {
fmt.Println("Restoring auto-stashed changes...")
if popErr := g.StashPop(st.StashRef); popErr != nil {
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(st.StashRef), popErr)
}
}

fmt.Println("Cascade complete!")
Expand Down
42 changes: 30 additions & 12 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,6 @@ func runSubmit(cmd *cobra.Command, args []string) error {

g := git.New(cwd)

// Check for dirty working tree
dirty, err := g.IsDirty()
if err != nil {
return err
}
if dirty {
return fmt.Errorf("working tree has uncommitted changes; commit or stash first")
}

// Check if operation already in progress
if state.Exists(g.GetGitDir()) {
return fmt.Errorf("operation already in progress; use 'gh stack continue' or 'gh stack abort'")
Expand Down Expand Up @@ -121,14 +112,41 @@ func runSubmit(cmd *cobra.Command, args []string) error {
branchNames[i] = b.Name
}

// Save undo snapshot (unless dry-run)
var stashRef string
if !submitDryRunFlag {
var saveErr error
stashRef, saveErr = saveUndoSnapshot(g, cfg, branches, nil, "submit", "gh stack submit")
if saveErr != nil {
fmt.Printf("Warning: could not save undo state: %v\n", saveErr)
}
}

// Phase 1: Cascade
fmt.Println("=== Phase 1: Cascade ===")
if err := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, branchNames); err != nil {
return err // Conflict or error - state saved, user can continue
if cascadeErr := doCascadeWithState(g, cfg, branches, submitDryRunFlag, state.OperationSubmit, submitUpdateOnlyFlag, submitWebFlag, branchNames, stashRef); cascadeErr != nil {
// Stash is saved in state for conflicts; restore on other errors
if cascadeErr != ErrConflict && stashRef != "" {
fmt.Println("Restoring auto-stashed changes...")
if popErr := g.StashPop(stashRef); popErr != nil {
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(stashRef), popErr)
}
}
return cascadeErr
}

// Phases 2 & 3
return doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag, submitWebFlag)
err = doSubmitPushAndPR(g, cfg, root, branches, submitDryRunFlag, submitUpdateOnlyFlag, submitWebFlag)

// Restore auto-stashed changes after operation completes
if stashRef != "" {
fmt.Println("Restoring auto-stashed changes...")
if popErr := g.StashPop(stashRef); popErr != nil {
fmt.Printf("Warning: could not restore stashed changes (commit %s): %v\n", git.AbbrevSHA(stashRef), popErr)
}
}

return err
}

// doSubmitPushAndPR handles push and PR creation/update phases.
Expand Down
Loading