diff --git a/README.md b/README.md index c0b3864..9e9da99 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`: diff --git a/cmd/abort.go b/cmd/abort.go index f476912..e39b5a2 100644 --- a/cmd/abort.go +++ b/cmd/abort.go @@ -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 } diff --git a/cmd/cascade.go b/cmd/cascade.go index 8b98f76..482934f 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -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" ) @@ -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'") @@ -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 @@ -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 } @@ -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 +} diff --git a/cmd/continue.go b/cmd/continue.go index 26f232d..f99d071 100644 --- a/cmd/continue.go +++ b/cmd/continue.go @@ -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 @@ -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!") diff --git a/cmd/submit.go b/cmd/submit.go index 576c971..ea1e90d 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -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'") @@ -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. diff --git a/cmd/sync.go b/cmd/sync.go index 3292f2c..a5cba6d 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -9,6 +9,7 @@ import ( "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/github" "github.com/boneskull/gh-stack/internal/prompt" + "github.com/boneskull/gh-stack/internal/state" "github.com/boneskull/gh-stack/internal/tree" "github.com/spf13/cobra" ) @@ -98,6 +99,34 @@ func runSync(cmd *cobra.Command, args []string) error { return err } + // Save undo snapshot of all tracked branches (unless dry-run) + // This captures state before any modifications (fetch, delete, rebase) + var stashRef string + if !syncDryRunFlag { + allBranches, listErr := cfg.ListTrackedBranches() + if listErr == nil && len(allBranches) > 0 { + var saveErr error + stashRef, saveErr = saveUndoSnapshotByName(g, cfg, allBranches, nil, "sync", "gh stack sync") + if saveErr != nil { + fmt.Printf("Warning: could not save undo state: %v\n", saveErr) + } + } + } + + // Track if we hit a conflict (stash is saved in state for conflicts) + var hitConflict bool + + // Ensure stash is restored on any exit (success or error, except conflicts) + // This defer must be after stashRef is set + defer func() { + if stashRef != "" && !hitConflict { + 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) + } + } + }() + // Fetch fmt.Println("Fetching from origin...") if !syncDryRunFlag { @@ -325,7 +354,10 @@ func runSync(cmd *cobra.Command, args []string) error { for _, child := range root.Children { allBranches := []*tree.Node{child} allBranches = append(allBranches, tree.GetDescendants(child)...) - if err := doCascade(g, cfg, allBranches, syncDryRunFlag); err != nil { + if err := doCascadeWithState(g, cfg, allBranches, syncDryRunFlag, state.OperationCascade, false, false, nil, stashRef); err != nil { + if err == ErrConflict { + hitConflict = true + } return err } } @@ -340,6 +372,7 @@ func runSync(cmd *cobra.Command, args []string) error { } fmt.Println("\nSync complete!") + // Stash restoration handled by defer return nil } diff --git a/cmd/undo.go b/cmd/undo.go new file mode 100644 index 0000000..eb3b148 --- /dev/null +++ b/cmd/undo.go @@ -0,0 +1,276 @@ +// cmd/undo.go +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/git" + "github.com/boneskull/gh-stack/internal/prompt" + "github.com/boneskull/gh-stack/internal/state" + "github.com/boneskull/gh-stack/internal/undo" + "github.com/spf13/cobra" +) + +var undoCmd = &cobra.Command{ + Use: "undo", + Short: "Undo the last destructive operation", + Long: `Undo the last cascade, submit, or sync operation by restoring branches +to their previous state. This includes: + +- Resetting branch refs to their pre-operation SHAs +- Recreating any deleted branches +- Restoring stack config (stackParent, stackPR, stackForkPoint) +- Restoring any auto-stashed working tree changes + +Note: This does not undo remote changes (force pushes). Use with caution.`, + RunE: runUndo, +} + +var ( + undoForce bool + undoDryRun bool +) + +func init() { + rootCmd.AddCommand(undoCmd) + undoCmd.Flags().BoolVarP(&undoForce, "force", "f", false, "Skip confirmation prompt") + undoCmd.Flags().BoolVar(&undoDryRun, "dry-run", false, "Show what would be restored without making changes") +} + +func runUndo(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + + g := git.New(cwd) + gitDir := g.GetGitDir() + + // Check if a cascade/submit is in progress + if state.Exists(gitDir) { + return errors.New("cannot undo while a cascade/submit is in progress; run 'gh stack continue' or 'gh stack abort' first") + } + + // Check if a rebase is in progress + if g.IsRebaseInProgress() { + return errors.New("cannot undo while a rebase is in progress; resolve or abort the rebase first") + } + + // Load the most recent snapshot + snapshot, snapshotPath, err := undo.LoadLatest(gitDir) + if err != nil { + if errors.Is(err, undo.ErrNoSnapshot) { + fmt.Println("Nothing to undo.") + return nil + } + return fmt.Errorf("failed to load undo state: %w", err) + } + + // Display what will be restored + fmt.Printf("About to undo '%s' from %s\n\n", snapshot.Command, snapshot.Timestamp.Local().Format("2006-01-02 15:04:05")) + fmt.Println("This will restore:") + + // Show branch changes + for name, bs := range snapshot.Branches { + currentSHA, tipErr := g.GetTip(name) + if tipErr != nil { + fmt.Printf(" - %s: (branch missing) → %s\n", name, git.AbbrevSHA(bs.SHA)) + } else if currentSHA != bs.SHA { + fmt.Printf(" - %s: %s → %s\n", name, git.AbbrevSHA(currentSHA), git.AbbrevSHA(bs.SHA)) + } else { + fmt.Printf(" - %s: (unchanged)\n", name) + } + } + + // Show deleted branches to recreate + for name, bs := range snapshot.DeletedBranches { + if g.BranchExists(name) { + fmt.Printf(" - %s: (already exists, will skip)\n", name) + } else { + fmt.Printf(" - Recreate deleted branch: %s at %s\n", name, git.AbbrevSHA(bs.SHA)) + } + } + + // Show stash info + if snapshot.StashRef != "" { + fmt.Printf(" - Restore stashed changes\n") + } + + fmt.Println() + + // Dry run exits here + if undoDryRun { + fmt.Println("Dry run: no changes made.") + return nil + } + + // Confirm unless forced + if !undoForce { + confirmed, confirmErr := prompt.Confirm("Proceed with undo?", false) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + fmt.Println("Undo cancelled.") + return nil + } + } + + // Load config for restoring stack metadata + cfg, err := config.Load(cwd) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Verify all SHAs exist before making changes + for name, bs := range snapshot.Branches { + if !g.CommitExists(bs.SHA) { + return fmt.Errorf("commit %s for branch '%s' no longer exists (reflog may have expired)", bs.SHA, name) + } + } + for name, bs := range snapshot.DeletedBranches { + if !g.CommitExists(bs.SHA) { + return fmt.Errorf("commit %s for deleted branch '%s' no longer exists (reflog may have expired)", bs.SHA, name) + } + } + + // Perform the undo + fmt.Println("Restoring branches...") + + // Get current branch - we may need to checkout something else first + currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty is fine + + // Build a list of branches we need to reset + // We need to handle the case where current branch is one we want to reset + branchesToReset := make(map[string]undo.BranchState) + for name, bs := range snapshot.Branches { + currentSHA, err := g.GetTip(name) + if err != nil { + // Branch doesn't exist, create it (can be done while checked out) + fmt.Printf(" Creating %s at %s\n", name, git.AbbrevSHA(bs.SHA)) + if createErr := g.CreateBranchAt(name, bs.SHA); createErr != nil { + return fmt.Errorf("failed to create branch %s: %w", name, createErr) + } + } else if currentSHA != bs.SHA { + branchesToReset[name] = bs + } + + // Restore config + if configErr := restoreBranchConfig(cfg, name, bs); configErr != nil { + fmt.Printf(" Warning: failed to restore config for %s: %v\n", name, configErr) + } + } + + // If current branch needs resetting, checkout a temp detached HEAD first + if _, needsReset := branchesToReset[currentBranch]; needsReset && len(branchesToReset) > 0 { + // Detach HEAD so we can reset the current branch + currentSHA, _ := g.GetTip("HEAD") //nolint:errcheck // if this fails, checkout will too + detachErr := g.Checkout(currentSHA) + if detachErr != nil { + // Fall back: try to find a branch we don't need to reset + foundSafe := false + for name := range snapshot.Branches { + if _, needsReset := branchesToReset[name]; !needsReset { + if safeCheckoutErr := g.Checkout(name); safeCheckoutErr == nil { + foundSafe = true + break + } + } + } + if !foundSafe { + return fmt.Errorf("cannot checkout to reset current branch: %w", detachErr) + } + } + } + + // Now reset all branches that need it + for name, bs := range branchesToReset { + fmt.Printf(" Resetting %s to %s\n", name, git.AbbrevSHA(bs.SHA)) + if resetErr := g.SetBranchRef(name, bs.SHA); resetErr != nil { + return fmt.Errorf("failed to reset branch %s: %w", name, resetErr) + } + } + + // Recreate deleted branches + for name, bs := range snapshot.DeletedBranches { + if g.BranchExists(name) { + fmt.Printf(" Skipping %s (already exists)\n", name) + continue + } + fmt.Printf(" Recreating %s at %s\n", name, git.AbbrevSHA(bs.SHA)) + if createErr := g.CreateBranchAt(name, bs.SHA); createErr != nil { + return fmt.Errorf("failed to recreate branch %s: %w", name, createErr) + } + + // Restore config for deleted branch + if configErr := restoreBranchConfig(cfg, name, bs); configErr != nil { + fmt.Printf(" Warning: failed to restore config for %s: %v\n", name, configErr) + } + } + + // Checkout original HEAD + if snapshot.OriginalHead != "" { + fmt.Printf(" Checking out %s\n", snapshot.OriginalHead) + if checkoutErr := g.Checkout(snapshot.OriginalHead); checkoutErr != nil { + fmt.Printf(" Warning: failed to checkout %s: %v\n", snapshot.OriginalHead, checkoutErr) + } + } + + // Restore stash using the specific stash commit hash + if snapshot.StashRef != "" { + fmt.Println(" Restoring stashed changes...") + if err := g.StashPop(snapshot.StashRef); err != nil { + fmt.Printf(" Warning: could not cleanly restore stashed changes. Your changes are still in stash (commit %s).\n", git.AbbrevSHA(snapshot.StashRef)) + } + } + + // Archive the snapshot + if err := undo.Archive(gitDir, snapshotPath); err != nil { + fmt.Printf("Warning: failed to archive undo state: %v\n", err) + } + + fmt.Printf("\nUndo complete. Restored state from before '%s'.\n", snapshot.Command) + return nil +} + +// restoreBranchConfig restores the stack config for a branch. +func restoreBranchConfig(cfg *config.Config, branch string, bs undo.BranchState) error { + // Restore stackParent + if bs.StackParent != "" { + if err := cfg.SetParent(branch, bs.StackParent); err != nil { + return err + } + } else { + // No parent was set - ensure it's removed + if err := cfg.RemoveParent(branch); err != nil { + return err + } + } + + // Restore stackPR + if bs.StackPR != 0 { + if err := cfg.SetPR(branch, bs.StackPR); err != nil { + return err + } + } else { + if err := cfg.RemovePR(branch); err != nil { + return err + } + } + + // Restore stackForkPoint + if bs.StackForkPoint != "" { + if err := cfg.SetForkPoint(branch, bs.StackForkPoint); err != nil { + return err + } + } else { + if err := cfg.RemoveForkPoint(branch); err != nil { + return err + } + } + + return nil +} diff --git a/e2e/error_cases_test.go b/e2e/error_cases_test.go index 06a76f4..be8ea83 100644 --- a/e2e/error_cases_test.go +++ b/e2e/error_cases_test.go @@ -54,11 +54,11 @@ func TestCascadeWithDirtyTree(t *testing.T) { result := env.Run("cascade") - // Should fail gracefully - if result.Success() { - t.Error("cascade should fail with dirty tree") + // Should auto-stash and succeed + if result.Failed() { + t.Errorf("cascade should auto-stash and succeed, got: %s", result.Stderr) } - if !result.ContainsStderr("uncommitted") { - t.Errorf("expected error about uncommitted changes, got: %s", result.Stderr) + if !result.ContainsStdout("Auto-stashed") { + t.Errorf("expected auto-stash message, got: %s", result.Stdout) } } diff --git a/e2e/submit_test.go b/e2e/submit_test.go index 402af1e..ed1fa97 100644 --- a/e2e/submit_test.go +++ b/e2e/submit_test.go @@ -146,24 +146,24 @@ func TestSubmitUpdateOnlyDryRun(t *testing.T) { } } -func TestSubmitRequiresCleanWorkTree(t *testing.T) { +func TestSubmitDryRunAllowsDirtyWorkTree(t *testing.T) { env := NewTestEnvWithRemote(t) env.MustRun("init") env.MustRun("create", "feature-1") + env.CreateCommit("feature-1 work") + env.Git("push", "-u", "origin", "feature-1") // Create uncommitted changes env.WriteFile("dirty.txt", "uncommitted") env.Git("add", "dirty.txt") - // Submit should fail - result := env.Run("submit") + // Submit --dry-run should succeed even with dirty working tree + // (dry-run skips the undo snapshot which includes auto-stashing) + result := env.Run("submit", "--dry-run") - if result.Success() { - t.Error("expected submit to fail with dirty working tree") - } - if !strings.Contains(result.Stderr, "uncommitted changes") { - t.Errorf("expected error about uncommitted changes, got: %s", result.Stderr) + if result.Failed() { + t.Errorf("submit --dry-run should succeed with dirty tree, got: %s", result.Stderr) } } diff --git a/e2e/undo_test.go b/e2e/undo_test.go new file mode 100644 index 0000000..1296953 --- /dev/null +++ b/e2e/undo_test.go @@ -0,0 +1,312 @@ +// e2e/undo_test.go +package e2e_test + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUndoCascade(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + // Create stack + env.MustRun("create", "feature-a") + env.CreateCommit("feature a work") + featureASha := env.BranchTip("feature-a") + + env.MustRun("create", "feature-b") + env.CreateCommit("feature b work") + featureBSha := env.BranchTip("feature-b") + + // Add commit to main + env.Git("checkout", "main") + env.CreateCommit("main moved forward") + + // Cascade from feature-a + env.Git("checkout", "feature-a") + env.MustRun("cascade") + + // Verify branches moved + newFeatureASha := env.BranchTip("feature-a") + newFeatureBSha := env.BranchTip("feature-b") + if newFeatureASha == featureASha { + t.Error("feature-a should have been rebased") + } + if newFeatureBSha == featureBSha { + t.Error("feature-b should have been rebased") + } + + // Undo the cascade + env.MustRun("undo", "--force") + + // Verify branches restored + restoredFeatureASha := env.BranchTip("feature-a") + restoredFeatureBSha := env.BranchTip("feature-b") + if restoredFeatureASha != featureASha { + t.Errorf("feature-a not restored: expected %s, got %s", featureASha, restoredFeatureASha) + } + if restoredFeatureBSha != featureBSha { + t.Errorf("feature-b not restored: expected %s, got %s", featureBSha, restoredFeatureBSha) + } +} + +func TestUndoDryRun(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + // Create stack and cascade + env.MustRun("create", "feature-a") + env.CreateCommit("feature a work") + featureASha := env.BranchTip("feature-a") + + env.Git("checkout", "main") + env.CreateCommit("main moved forward") + + env.Git("checkout", "feature-a") + env.MustRun("cascade") + + // Verify branch moved + cascadedSha := env.BranchTip("feature-a") + if cascadedSha == featureASha { + t.Fatal("cascade didn't change SHA") + } + + // Dry run should not change anything + result := env.MustRun("undo", "--dry-run") + if !result.ContainsStdout("Dry run") { + t.Error("expected dry-run message in output") + } + + // Branch should still be at cascaded SHA + afterDryRunSha := env.BranchTip("feature-a") + if afterDryRunSha != cascadedSha { + t.Error("dry-run should not have changed branch") + } +} + +func TestUndoNothingToUndo(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + result := env.MustRun("undo", "--force") + if !result.ContainsStdout("Nothing to undo") { + t.Error("expected 'Nothing to undo' message") + } +} + +func TestUndoRestoresConfig(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + // Create stack + env.MustRun("create", "feature-a") + env.CreateCommit("feature a work") + originalForkPoint := env.GetStackConfig("branch.feature-a.stackforkpoint") + + env.Git("checkout", "main") + env.CreateCommit("main moved forward") + + env.Git("checkout", "feature-a") + env.MustRun("cascade") + + // Cascade updates fork point + newForkPoint := env.GetStackConfig("branch.feature-a.stackforkpoint") + if newForkPoint == originalForkPoint { + // Fork point should have changed (or been set) + t.Log("Fork point unchanged (may be expected if not set initially)") + } + + // Undo + env.MustRun("undo", "--force") + + // Fork point should be restored + restoredForkPoint := env.GetStackConfig("branch.feature-a.stackforkpoint") + if restoredForkPoint != originalForkPoint { + t.Errorf("fork point not restored: expected %q, got %q", originalForkPoint, restoredForkPoint) + } +} + +func TestUndoArchivesSnapshot(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + env.MustRun("create", "feature-a") + env.CreateCommit("feature a work") + + env.Git("checkout", "main") + env.CreateCommit("main moved forward") + + env.Git("checkout", "feature-a") + env.MustRun("cascade") + + // Verify snapshot exists + undoDir := filepath.Join(env.WorkDir, ".git", "stack-undo") + entries, err := os.ReadDir(undoDir) + if err != nil { + t.Fatalf("failed to read undo dir: %v", err) + } + snapshotCount := 0 + for _, e := range entries { + if !e.IsDir() { + snapshotCount++ + } + } + if snapshotCount != 1 { + t.Errorf("expected 1 snapshot, got %d", snapshotCount) + } + + // Undo + env.MustRun("undo", "--force") + + // Snapshot should be archived + entries, _ = os.ReadDir(undoDir) + snapshotCount = 0 + for _, e := range entries { + if !e.IsDir() { + snapshotCount++ + } + } + if snapshotCount != 0 { + t.Error("snapshot should have been archived after undo") + } + + // Check done/ directory + doneDir := filepath.Join(undoDir, "done") + entries, err = os.ReadDir(doneDir) + if err != nil { + t.Fatalf("failed to read done dir: %v", err) + } + if len(entries) != 1 { + t.Errorf("expected 1 archived snapshot, got %d", len(entries)) + } +} + +func TestCascadeWithAutoStashRestoresAfterSuccess(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + env.MustRun("create", "feature-a") + env.CreateCommit("feature a work") + + env.Git("checkout", "main") + env.CreateCommit("main moved forward") + + env.Git("checkout", "feature-a") + + // Create uncommitted changes + env.WriteFile("uncommitted.txt", "uncommitted content\n") + + // Cascade should auto-stash and then restore after success + result := env.MustRun("cascade") + if !result.ContainsStdout("Auto-stashed") { + t.Error("expected auto-stash message") + } + if !result.ContainsStdout("Restoring auto-stashed") { + t.Error("expected restore message after successful cascade") + } + + // Uncommitted file should be restored after successful cascade + content, err := os.ReadFile(filepath.Join(env.WorkDir, "uncommitted.txt")) + if err != nil { + t.Errorf("uncommitted file should be present after cascade: %v", err) + } else if string(content) != "uncommitted content\n" { + t.Errorf("uncommitted file has wrong content: %q", content) + } +} + +func TestUndoRestoresOriginalBranch(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + env.MustRun("create", "feature-a") + env.CreateCommit("feature a work") + + env.MustRun("create", "feature-b") + env.CreateCommit("feature b work") + + env.Git("checkout", "main") + env.CreateCommit("main moved forward") + + // Cascade from feature-a + env.Git("checkout", "feature-a") + env.MustRun("cascade") + + // Should still be on feature-a + env.AssertBranch("feature-a") + + // Now checkout something else + env.Git("checkout", "feature-b") + + // Undo should restore to feature-a (original head at time of cascade) + env.MustRun("undo", "--force") + env.AssertBranch("feature-a") +} + +func TestUndoBlockedDuringCascade(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + conflictFile := env.CreateStackWithConflict() + _ = conflictFile + + // Start cascade (will conflict) + result := env.Run("cascade") + if result.Success() { + t.Fatal("expected cascade to fail on conflict") + } + env.AssertRebaseInProgress() + + // Undo should be blocked + result = env.Run("undo", "--force") + if result.Success() { + t.Error("undo should fail during cascade in progress") + } + if !result.ContainsStdout("cascade/submit is in progress") && !result.ContainsStderr("cascade/submit is in progress") { + t.Errorf("expected message about cascade in progress, got stdout: %s, stderr: %s", result.Stdout, result.Stderr) + } + + // Abort the cascade + env.MustRun("abort") +} + +func TestMultipleCascadesUndoLatest(t *testing.T) { + env := NewTestEnv(t) + env.MustRun("init") + + // Create stack + env.MustRun("create", "feature-a") + env.CreateCommit("feature a v1") + featureAv1 := env.BranchTip("feature-a") + + // First cascade trigger + env.Git("checkout", "main") + env.CreateCommit("main update 1") + env.Git("checkout", "feature-a") + env.MustRun("cascade") + + featureAAfterFirst := env.BranchTip("feature-a") + if featureAAfterFirst == featureAv1 { + t.Fatal("first cascade didn't change SHA") + } + + // Second cascade trigger + env.Git("checkout", "main") + env.CreateCommit("main update 2") + env.Git("checkout", "feature-a") + env.MustRun("cascade") + + featureAAfterSecond := env.BranchTip("feature-a") + if featureAAfterSecond == featureAAfterFirst { + t.Fatal("second cascade didn't change SHA") + } + + // Undo should restore to state before SECOND cascade (not first) + env.MustRun("undo", "--force") + featureAAfterUndo := env.BranchTip("feature-a") + if featureAAfterUndo != featureAAfterFirst { + t.Errorf("undo should restore to state before second cascade: expected %s, got %s", + featureAAfterFirst, featureAAfterUndo) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index d56ebdb..1714c8b 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -281,6 +281,105 @@ func (g *Git) DeleteBranch(branch string) error { return g.runSilent("branch", "-D", branch) } +// SetBranchRef sets a branch ref to point to a specific SHA. +// This is equivalent to `git branch -f `. +func (g *Git) SetBranchRef(branch, sha string) error { + return g.runSilent("branch", "-f", branch, sha) +} + +// CreateBranchAt creates a new branch at a specific SHA. +// This is equivalent to `git branch `. +func (g *Git) CreateBranchAt(name, sha string) error { + return g.runSilent("branch", name, sha) +} + +// Stash creates a stash with the given message and returns the stash commit hash. +// Returns an empty string if there was nothing to stash. +// Includes untracked files (-u) to capture all working tree changes. +// The returned hash is stable and can be used to restore this specific stash +// even if other stashes are created later. +func (g *Git) Stash(message string) (string, error) { + // Check if there's anything to stash first + dirty, err := g.IsDirty() + if err != nil { + return "", err + } + if !dirty { + return "", nil + } + + // Create the stash with -u to include untracked files + if stashErr := g.runSilent("stash", "push", "-u", "-m", message); stashErr != nil { + return "", stashErr + } + + // Resolve the created stash to a stable identifier (its commit hash) + // This is more reliable than stash@{0} which can shift if user creates more stashes + out, parseErr := g.run("rev-parse", "stash@{0}") + if parseErr != nil { + return "", parseErr + } + return out, nil +} + +// StashPop restores a specific stash entry when a reference is provided. +// If ref is empty, it pops the most recent stash entry (matching `git stash pop`). +// When a commit hash is provided (from Stash()), uses apply+drop since pop only accepts stash refs. +// Returns an error if there are conflicts or no matching stash entry. +func (g *Git) StashPop(ref string) error { + if ref == "" { + return g.runInteractive("stash", "pop") + } + + // git stash pop only accepts stash references (stash@{n}), not commit hashes. + // Use apply instead which accepts commit hashes. + if err := g.runInteractive("stash", "apply", ref); err != nil { + return err + } + + // Find and drop the stash entry by matching its commit hash + stashRef, err := g.findStashByHash(ref) + if err != nil { + // Apply succeeded but we couldn't find stash to drop - not fatal + return nil + } + if stashRef != "" { + // Silently drop; errors aren't critical since apply already succeeded + _ = g.runSilent("stash", "drop", stashRef) //nolint:errcheck // best effort cleanup + } + return nil +} + +// findStashByHash finds the stash@{n} reference for a given commit hash. +// Returns empty string if not found. +func (g *Git) findStashByHash(hash string) (string, error) { + // List stashes with their hashes + out, err := g.run("stash", "list", "--format=%H %gd") + if err != nil { + return "", err + } + for line := range strings.SplitSeq(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, " ", 2) + if len(parts) == 2 && parts[0] == hash { + return parts[1], nil + } + } + return "", nil +} + +// StashList returns true if there are any stash entries. +func (g *Git) StashList() (bool, error) { + out, err := g.run("stash", "list") + if err != nil { + return false, err + } + return len(out) > 0, nil +} + // Commit represents a git commit with its subject and body. type Commit struct { Subject string // First line of the commit message @@ -323,3 +422,12 @@ func (g *Git) GetCommits(base, head string) ([]Commit, error) { return commits, nil } + +// AbbrevSHA safely abbreviates a SHA to 7 characters. +// Returns the full string if it's shorter than 7 characters. +func AbbrevSHA(sha string) string { + if len(sha) <= 7 { + return sha + } + return sha[:7] +} diff --git a/internal/state/state.go b/internal/state/state.go index a87cad2..38ab2c3 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -33,6 +33,9 @@ type CascadeState struct { // Branches (submit only) - the complete list of branches being submitted. // Used to rebuild the full set for push/PR phases after cascade completes. Branches []string `json:"branches,omitempty"` + // StashRef is the commit hash of auto-stashed changes (if any). + // Used to restore working tree changes when operation completes or is aborted. + StashRef string `json:"stash_ref,omitempty"` } // Save persists cascade state to .git/STACK_CASCADE_STATE. diff --git a/internal/undo/undo.go b/internal/undo/undo.go new file mode 100644 index 0000000..6d8b3b4 --- /dev/null +++ b/internal/undo/undo.go @@ -0,0 +1,197 @@ +// internal/undo/undo.go +package undo + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + undoDir = "stack-undo" + archiveDir = "done" + timeFormat = "20060102T150405.000000000Z" // Compact ISO8601 with nanoseconds to avoid collisions +) + +// ErrNoSnapshot is returned when no undo snapshot exists. +var ErrNoSnapshot = errors.New("nothing to undo") + +// BranchState captures the state of a single branch for undo. +type BranchState struct { + SHA string `json:"sha"` + StackParent string `json:"stack_parent,omitempty"` + StackPR int `json:"stack_pr,omitempty"` + StackForkPoint string `json:"stack_fork_point,omitempty"` +} + +// Snapshot represents the complete state before a destructive operation. +type Snapshot struct { + Timestamp time.Time `json:"timestamp"` + Operation string `json:"operation"` + Command string `json:"command"` + OriginalHead string `json:"original_head"` + StashRef string `json:"stash_ref,omitempty"` + Branches map[string]BranchState `json:"branches"` + DeletedBranches map[string]BranchState `json:"deleted_branches,omitempty"` +} + +// NewSnapshot creates a new snapshot with the current timestamp. +func NewSnapshot(operation, command, originalHead string) *Snapshot { + return &Snapshot{ + Timestamp: time.Now().UTC(), + Operation: operation, + Command: command, + OriginalHead: originalHead, + Branches: make(map[string]BranchState), + DeletedBranches: make(map[string]BranchState), + } +} + +// Save persists the snapshot to .git/stack-undo/{timestamp}-{operation}.json. +func Save(gitDir string, snapshot *Snapshot) error { + dir := filepath.Join(gitDir, undoDir) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + filename := snapshot.Timestamp.Format(timeFormat) + "-" + snapshot.Operation + ".json" + path := filepath.Join(dir, filename) + + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// LoadLatest reads the most recent snapshot from .git/stack-undo/. +// Returns the snapshot, its file path, and any error. +func LoadLatest(gitDir string) (*Snapshot, string, error) { + dir := filepath.Join(gitDir, undoDir) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, "", ErrNoSnapshot + } + return nil, "", err + } + + // Filter to only .json files (not the done/ directory) + var jsonFiles []os.DirEntry + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") { + jsonFiles = append(jsonFiles, entry) + } + } + + if len(jsonFiles) == 0 { + return nil, "", ErrNoSnapshot + } + + // Sort by name (timestamp prefix) descending to get most recent + sort.Slice(jsonFiles, func(i, j int) bool { + return jsonFiles[i].Name() > jsonFiles[j].Name() + }) + + path := filepath.Join(dir, jsonFiles[0].Name()) + data, err := os.ReadFile(path) + if err != nil { + return nil, "", err + } + + var snapshot Snapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return nil, "", err + } + return &snapshot, path, nil +} + +// Load reads a snapshot from a specific path. +func Load(path string) (*Snapshot, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var snapshot Snapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return nil, err + } + return &snapshot, nil +} + +// Archive moves a snapshot file to the done/ subdirectory. +func Archive(gitDir, snapshotPath string) error { + archivePath := filepath.Join(gitDir, undoDir, archiveDir) + if err := os.MkdirAll(archivePath, 0755); err != nil { + return err + } + + filename := filepath.Base(snapshotPath) + dest := filepath.Join(archivePath, filename) + return os.Rename(snapshotPath, dest) +} + +// List returns all available (non-archived) snapshots, sorted newest first. +func List(gitDir string) ([]*Snapshot, error) { + dir := filepath.Join(gitDir, undoDir) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + // Filter to only .json files + var jsonFiles []os.DirEntry + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") { + jsonFiles = append(jsonFiles, entry) + } + } + + // Sort by name (timestamp prefix) descending + sort.Slice(jsonFiles, func(i, j int) bool { + return jsonFiles[i].Name() > jsonFiles[j].Name() + }) + + var snapshots []*Snapshot + for _, file := range jsonFiles { + path := filepath.Join(dir, file.Name()) + snapshot, err := Load(path) + if err != nil { + continue // Skip malformed files + } + snapshots = append(snapshots, snapshot) + } + return snapshots, nil +} + +// Exists checks if any undo snapshots exist. +func Exists(gitDir string) bool { + dir := filepath.Join(gitDir, undoDir) + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") { + return true + } + } + return false +} + +// Remove deletes a snapshot file. +func Remove(snapshotPath string) error { + err := os.Remove(snapshotPath) + if os.IsNotExist(err) { + return nil + } + return err +} diff --git a/internal/undo/undo_test.go b/internal/undo/undo_test.go new file mode 100644 index 0000000..195a535 --- /dev/null +++ b/internal/undo/undo_test.go @@ -0,0 +1,322 @@ +// internal/undo/undo_test.go +package undo_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/boneskull/gh-stack/internal/undo" +) + +func TestSaveAndLoadLatest(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + snapshot := undo.NewSnapshot("cascade", "gh stack cascade", "feature-a") + snapshot.Branches["feature-a"] = undo.BranchState{ + SHA: "abc123def456", + StackParent: "main", + StackPR: 42, + } + snapshot.Branches["feature-b"] = undo.BranchState{ + SHA: "789xyz000111", + StackParent: "feature-a", + StackForkPoint: "abc123def456", + } + + if err := undo.Save(gitDir, snapshot); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, path, err := undo.LoadLatest(gitDir) + if err != nil { + t.Fatalf("LoadLatest failed: %v", err) + } + + if loaded.Operation != "cascade" { + t.Errorf("Operation mismatch: %q != %q", loaded.Operation, "cascade") + } + if loaded.Command != "gh stack cascade" { + t.Errorf("Command mismatch: %q != %q", loaded.Command, "gh stack cascade") + } + if loaded.OriginalHead != "feature-a" { + t.Errorf("OriginalHead mismatch: %q != %q", loaded.OriginalHead, "feature-a") + } + if len(loaded.Branches) != 2 { + t.Errorf("Expected 2 branches, got %d", len(loaded.Branches)) + } + + // Check branch state + featureA, ok := loaded.Branches["feature-a"] + if !ok { + t.Error("feature-a not found in branches") + } else { + if featureA.SHA != "abc123def456" { + t.Errorf("feature-a SHA mismatch: %q", featureA.SHA) + } + if featureA.StackPR != 42 { + t.Errorf("feature-a StackPR mismatch: %d", featureA.StackPR) + } + } + + // Verify path is not empty + if path == "" { + t.Error("LoadLatest returned empty path") + } +} + +func TestLoadLatestReturnsNewest(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + // Create first snapshot + snapshot1 := undo.NewSnapshot("cascade", "gh stack cascade", "feature-a") + snapshot1.Timestamp = time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + snapshot1.Branches["feature-a"] = undo.BranchState{SHA: "first"} + if err := undo.Save(gitDir, snapshot1); err != nil { + t.Fatal(err) + } + + // Create second snapshot (newer) + snapshot2 := undo.NewSnapshot("submit", "gh stack submit", "feature-b") + snapshot2.Timestamp = time.Date(2024, 1, 2, 12, 0, 0, 0, time.UTC) + snapshot2.Branches["feature-b"] = undo.BranchState{SHA: "second"} + if err := undo.Save(gitDir, snapshot2); err != nil { + t.Fatal(err) + } + + loaded, _, err := undo.LoadLatest(gitDir) + if err != nil { + t.Fatal(err) + } + + // Should return the newer snapshot + if loaded.Operation != "submit" { + t.Errorf("Expected submit (newer), got %q", loaded.Operation) + } +} + +func TestNoSnapshot(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + _, _, err := undo.LoadLatest(gitDir) + if err != undo.ErrNoSnapshot { + t.Errorf("Expected ErrNoSnapshot, got %v", err) + } +} + +func TestArchive(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + snapshot := undo.NewSnapshot("cascade", "gh stack cascade", "main") + snapshot.Branches["feature"] = undo.BranchState{SHA: "abc123"} + if err := undo.Save(gitDir, snapshot); err != nil { + t.Fatal(err) + } + + // Load to get the path + _, path, err := undo.LoadLatest(gitDir) + if err != nil { + t.Fatal(err) + } + + // Archive it + if archiveErr := undo.Archive(gitDir, path); archiveErr != nil { + t.Fatalf("Archive failed: %v", archiveErr) + } + + // Original should no longer exist + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Error("Original file should be deleted after archive") + } + + // Should be in done/ directory + doneDir := filepath.Join(gitDir, "stack-undo", "done") + entries, err := os.ReadDir(doneDir) + if err != nil { + t.Fatalf("Failed to read done dir: %v", err) + } + if len(entries) != 1 { + t.Errorf("Expected 1 file in done/, got %d", len(entries)) + } + + // LoadLatest should now return ErrNoSnapshot + _, _, err = undo.LoadLatest(gitDir) + if err != undo.ErrNoSnapshot { + t.Errorf("Expected ErrNoSnapshot after archive, got %v", err) + } +} + +func TestList(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + // Create multiple snapshots + for i, op := range []string{"cascade", "submit", "sync"} { + snapshot := undo.NewSnapshot(op, "gh stack "+op, "main") + snapshot.Timestamp = time.Date(2024, 1, i+1, 12, 0, 0, 0, time.UTC) + snapshot.Branches["feature"] = undo.BranchState{SHA: "abc"} + if err := undo.Save(gitDir, snapshot); err != nil { + t.Fatal(err) + } + } + + snapshots, err := undo.List(gitDir) + if err != nil { + t.Fatal(err) + } + + if len(snapshots) != 3 { + t.Errorf("Expected 3 snapshots, got %d", len(snapshots)) + } + + // Should be sorted newest first + if snapshots[0].Operation != "sync" { + t.Errorf("Expected sync (newest) first, got %q", snapshots[0].Operation) + } + if snapshots[2].Operation != "cascade" { + t.Errorf("Expected cascade (oldest) last, got %q", snapshots[2].Operation) + } +} + +func TestExists(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + if undo.Exists(gitDir) { + t.Error("Exists should return false when no snapshots") + } + + snapshot := undo.NewSnapshot("cascade", "gh stack cascade", "main") + snapshot.Branches["feature"] = undo.BranchState{SHA: "abc"} + if err := undo.Save(gitDir, snapshot); err != nil { + t.Fatal(err) + } + + if !undo.Exists(gitDir) { + t.Error("Exists should return true after saving snapshot") + } +} + +func TestDeletedBranches(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + snapshot := undo.NewSnapshot("sync", "gh stack sync", "main") + snapshot.Branches["feature-a"] = undo.BranchState{ + SHA: "abc123", + StackParent: "main", + } + snapshot.DeletedBranches["feature-merged"] = undo.BranchState{ + SHA: "deleted123", + StackParent: "main", + StackPR: 99, + } + + if err := undo.Save(gitDir, snapshot); err != nil { + t.Fatal(err) + } + + loaded, _, err := undo.LoadLatest(gitDir) + if err != nil { + t.Fatal(err) + } + + if len(loaded.DeletedBranches) != 1 { + t.Errorf("Expected 1 deleted branch, got %d", len(loaded.DeletedBranches)) + } + + deleted, ok := loaded.DeletedBranches["feature-merged"] + if !ok { + t.Error("feature-merged not found in deleted branches") + } else { + if deleted.SHA != "deleted123" { + t.Errorf("Deleted branch SHA mismatch: %q", deleted.SHA) + } + if deleted.StackPR != 99 { + t.Errorf("Deleted branch StackPR mismatch: %d", deleted.StackPR) + } + } +} + +func TestStashRef(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + snapshot := undo.NewSnapshot("cascade", "gh stack cascade", "main") + snapshot.StashRef = "stash@{0}" + snapshot.Branches["feature"] = undo.BranchState{SHA: "abc"} + + if err := undo.Save(gitDir, snapshot); err != nil { + t.Fatal(err) + } + + loaded, _, err := undo.LoadLatest(gitDir) + if err != nil { + t.Fatal(err) + } + + if loaded.StashRef != "stash@{0}" { + t.Errorf("StashRef mismatch: %q", loaded.StashRef) + } +} + +func TestRemove(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + + snapshot := undo.NewSnapshot("cascade", "gh stack cascade", "main") + snapshot.Branches["feature"] = undo.BranchState{SHA: "abc"} + if err := undo.Save(gitDir, snapshot); err != nil { + t.Fatal(err) + } + + _, path, err := undo.LoadLatest(gitDir) + if err != nil { + t.Fatal(err) + } + + if err := undo.Remove(path); err != nil { + t.Fatalf("Remove failed: %v", err) + } + + if undo.Exists(gitDir) { + t.Error("Snapshot should not exist after Remove") + } + + // Remove non-existent should not error + if err := undo.Remove(path); err != nil { + t.Errorf("Remove non-existent should not error: %v", err) + } +}