diff --git a/README.md b/README.md index 445f69c..f25b75a 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,68 @@ gh stack rebase --continue gh stack rebase --abort ``` +### `gh stack modify` + +Interactively restructure the current stack. + +``` +gh stack modify [flags] +``` + +Opens a terminal UI for restructuring a stack. You can rename, drop, reorder, and fold branches into adjacent ones. All the changes are staged during the preview and applied at once on save. + +If the stack of PRs has been created on GitHub, run `gh stack submit` afterwards to push the changes and recreate the stack. + +| Flag | Description | +|------|-------------| +| `--continue` | Continue after resolving conflicts | +| `--abort` | Abort the modify session and restore the stack to its pre-modify state | + +**Operations:** + +- **Drop** (`x`): Remove a branch and its commits from the stack. Local branch and associated PR are preserved. +- **Fold down** (`d`): Absorb a branch's commits into the branch below (toward trunk). Folded branch removed from stack. +- **Fold up** (`u`): Absorb a branch's commits into the branch above (away from trunk). Folded branch removed from stack. +- **Reorder** (`Shift+↑`/`Shift+↓`): Move a branch up (away from trunk) or down (toward trunk) in the stack. +- **Rename** (`r`): Rename a branch locally and in the stack metadata. +- **Undo** (`z`): Undo the last staged action. + +**Keybindings:** + +| Key | Action | +|-----|--------| +| `↑`/`↓` | Navigate branch list | +| `f` | View files changed | +| `c` | View commits | +| `x` | Drop branch | +| `r` | Rename branch | +| `u/d` | Fold branch up/down | +| `Shift+↑`/`Shift+↓` | Move branch up/down | +| `z` | Undo last action | +| `Ctrl+S` | Apply all changes | +| `q`/`Esc` | Cancel and exit | +| `?` | Help | + +**Preconditions:** +- Must have an active stack checked out locally +- Working tree must be clean +- No rebase in progress +- No PR in the stack is queued for merge +- Commit history must be linear + +**Examples:** + +```sh +# Open the modify TUI +gh stack modify + +# Continue after resolving a conflict +gh stack modify --continue + +# Abort and restore to the previous state +gh stack modify --abort +``` + ### `gh stack sync` Fetch, rebase, push, and sync PR state in a single command. diff --git a/cmd/add.go b/cmd/add.go index 8015c65..0fdd1ab 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -7,6 +7,7 @@ import ( "github.com/github/gh-stack/internal/branch" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -54,6 +55,12 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return ErrNotInStack } gitDir := result.GitDir + + if err := modify.CheckStateGuard(gitDir); err != nil { + cfg.Errorf("%s", err) + return ErrModifyRecovery + } + sf := result.StackFile s := result.Stack currentBranch := result.CurrentBranch diff --git a/cmd/modify.go b/cmd/modify.go new file mode 100644 index 0000000..143bffb --- /dev/null +++ b/cmd/modify.go @@ -0,0 +1,332 @@ +package cmd + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/modify" + "github.com/github/gh-stack/internal/tui/modifyview" + "github.com/github/gh-stack/internal/tui/stackview" + "github.com/spf13/cobra" +) + +type modifyOptions struct { + abort bool + cont bool +} + +func ModifyCmd(cfg *config.Config) *cobra.Command { + opts := &modifyOptions{} + + cmd := &cobra.Command{ + Use: "modify", + Short: "Interactively restructure a stack", + Long: `Open an interactive TUI to restructure the current stack. + +Operations available: + • Drop branches from the stack + • Fold branches into adjacent branches + • Reorder branches + • Rename branches + +All changes are staged in the TUI and applied together when you press Ctrl+S. +After applying, run 'gh stack submit' to push changes and recreate the stack on GitHub.`, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.abort { + return runModifyAbort(cfg) + } + if opts.cont { + return runModifyContinue(cfg) + } + return runModify(cfg) + }, + } + + cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort the modify session and restore the stack to its pre-modify state") + cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue after resolving conflicts") + + return cmd +} + +func runModify(cfg *config.Config) error { + // Run all precondition checks + result, err := checkModifyPreconditions(cfg) + if err != nil { + return err + } + + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch + + // Load branch data for the TUI + viewNodes := stackview.LoadBranchNodes(cfg, s, currentBranch) + + // Reverse so index 0 = top of stack (matching visual order) + reversed := make([]stackview.BranchNode, len(viewNodes)) + for i, n := range viewNodes { + reversed[len(viewNodes)-1-i] = n + } + + // Convert to ModifyBranchNodes + modifyNodes := make([]modifyview.ModifyBranchNode, len(reversed)) + for i, n := range reversed { + modifyNodes[i] = modifyview.ModifyBranchNode{ + BranchNode: n, + OriginalPosition: i, + } + } + + // Run the TUI + model := modifyview.New(modifyNodes, s.Trunk, Version) + + p := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithMouseAllMotion(), + ) + + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("running TUI: %w", err) + } + + m, ok := finalModel.(modifyview.Model) + if !ok { + return fmt.Errorf("unexpected model type") + } + + // Handle TUI result + if m.Cancelled() { + return nil + } + + if !m.ApplyRequested() { + return nil + } + + // Apply the staged changes + // Re-reverse nodes back to stack order (bottom to top) for the apply engine + applyNodes := m.Nodes() + reordered := make([]modifyview.ModifyBranchNode, len(applyNodes)) + for i, n := range applyNodes { + reordered[len(applyNodes)-1-i] = n + } + + applyResult, conflict, applyErr := modify.ApplyPlan(cfg, gitDir, s, sf, reordered, currentBranch, updateBaseSHAs) + + if conflict != nil { + isCherryPick := applyErr != nil && strings.Contains(applyErr.Error(), "cherry-pick") + if isCherryPick { + cfg.Warningf("Cherry-pick conflict folding %s", conflict.Branch) + } else { + cfg.Warningf("Rebasing %s — conflict", conflict.Branch) + } + + printConflictDetailsWithContinue(cfg, conflict.Branch, "gh stack modify --continue") + cfg.Printf("") + + cfg.Printf("Or restore the stack to its pre-modify state with `%s`", + cfg.ColorCyan("gh stack modify --abort")) + return ErrConflict + } + + if applyErr != nil { + cfg.Errorf("failed to apply modifications: %s", applyErr) + return ErrSilent + } + + // Print success summary + printModifySuccess(cfg, applyResult, s.ID != "") + + return nil +} + +// printModifySuccess prints a summary of what was applied. +func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult, hasRemoteStack bool) { + if result == nil { + return + } + + cfg.Printf("") + cfg.Successf("Stack modified successfully") + + for _, r := range result.RenamedBranches { + cfg.Printf(" Renamed: %s → %s", r.OldName, r.NewName) + } + + for _, d := range result.DroppedPRs { + cfg.Printf(" Dropped: %s (PR #%d remains open — close with `%s`)", + d.Branch, d.PRNumber, cfg.ColorCyan(fmt.Sprintf("gh pr close %d", d.PRNumber))) + } + + if result.MovedBranches > 0 { + cfg.Printf(" Rebased %d %s", result.MovedBranches, + plural(result.MovedBranches, "branch", "branches")) + } + + cfg.Printf("") + if hasRemoteStack { + cfg.Printf("Run `%s` to push your changes and update the stack of PRs on GitHub", + cfg.ColorCyan("gh stack submit")) + } +} + +// runModifyAbort handles recovery to a pre-modify state. +func runModifyAbort(cfg *config.Config) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return ErrNotInStack + } + + state, err := modify.LoadState(gitDir) + if err != nil { + cfg.Errorf("failed to read modify state: %s", err) + return ErrSilent + } + + if state == nil { + cfg.Printf("No modify session to abort") + return nil + } + + switch state.Phase { + case modify.PhaseApplying: + cfg.Printf("A modify session was interrupted during the apply phase") + cfg.Printf("Restoring stack to pre-modify state...") + if err := modify.UnwindFromStateFile(cfg, gitDir); err != nil { + cfg.Errorf("recovery failed: %s", err) + cfg.Printf("The stack may be in an inconsistent state.") + cfg.Printf("Try `%s` to fix, or `%s` + `%s` to recreate.", + cfg.ColorCyan("gh stack rebase"), cfg.ColorCyan("gh stack unstack --local"), + cfg.ColorCyan("gh stack init --adopt")) + return ErrSilent + } + cfg.Successf("Stack restored successfully") + return nil + + case modify.PhasePendingSubmit: + cfg.Printf("A modify completed but the stack has not been submitted") + cfg.Printf("Run `%s` to push changes and recreate the stack on GitHub", + cfg.ColorCyan("gh stack submit")) + return nil + + default: + cfg.Errorf("unexpected modify state phase: %s", state.Phase) + cfg.Printf("Clearing invalid state file...") + modify.ClearState(gitDir) + return nil + } +} + +// runModifyContinue continues applying after the user resolves a rebase conflict. +func runModifyContinue(cfg *config.Config) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return ErrNotInStack + } + + if err := modify.ContinueApply(cfg, gitDir, updateBaseSHAs); err != nil { + cfg.Errorf("%s", err) + return ErrConflict + } + + return nil +} + +// --------------------------------------------------------------------------- +// Preconditions +// --------------------------------------------------------------------------- + +// checkModifyPreconditions runs all precondition checks for the modify command. +func checkModifyPreconditions(cfg *config.Config) (*loadStackResult, error) { + if !cfg.IsInteractive() { + cfg.Errorf("modify requires an interactive terminal") + return nil, ErrSilent + } + + result, err := loadStack(cfg, "") + if err != nil { + return nil, ErrNotInStack + } + + gitDir := result.GitDir + s := result.Stack + + // No existing modify state file + if err := checkNoModifyInProgress(cfg, gitDir); err != nil { + return nil, err + } + + // No rebase in progress + if git.IsRebaseInProgress() { + cfg.Errorf("a rebase is currently in progress") + cfg.Printf("Complete the rebase with `%s` or abort with `%s`", + cfg.ColorCyan("gh stack rebase --continue"), + cfg.ColorCyan("gh stack rebase --abort")) + return nil, ErrRebaseActive + } + + // Clean working tree + if dirty, err := git.HasUncommittedChanges(); err != nil { + cfg.Errorf("failed to check working tree status: %s", err) + return nil, ErrSilent + } else if dirty { + cfg.Errorf("uncommitted changes in working tree") + cfg.Printf("Commit or stash your changes before running modify") + return nil, ErrSilent + } + + // Sync PR state and check merge queue + syncStackPRs(cfg, s) + if err := modify.CheckNoMergeQueuePRs(cfg, s); err != nil { + return nil, ErrSilent + } + + // Stack linearity check + if err := modify.CheckStackLinearity(cfg, s); err != nil { + return nil, ErrSilent + } + + return result, nil +} + +// checkNoModifyInProgress checks if a modify state file already exists. +func checkNoModifyInProgress(cfg *config.Config, gitDir string) error { + state, err := modify.LoadState(gitDir) + if err != nil { + cfg.Warningf("failed to read modify state: %v", err) + return nil + } + if state == nil { + return nil + } + + switch state.Phase { + case modify.PhaseApplying: + cfg.Errorf("a previous modify session was interrupted") + cfg.Printf("Run `%s` to restore your stack", + cfg.ColorCyan("gh stack modify --abort")) + return ErrModifyRecovery + case modify.PhaseConflict: + cfg.Errorf("a modify has unresolved conflicts") + cfg.Printf("Run `%s` to continue, or `%s` to restore your stack", + cfg.ColorCyan("gh stack modify --continue"), + cfg.ColorCyan("gh stack modify --abort")) + return ErrSilent + case modify.PhasePendingSubmit: + cfg.Errorf("a modify was completed but the stack has not been submitted yet") + cfg.Printf("Run `%s` to push changes and recreate the stack on GitHub", + cfg.ColorCyan("gh stack submit")) + return ErrSilent + default: + cfg.Errorf("unexpected modify state phase: %s", state.Phase) + return ErrSilent + } +} diff --git a/cmd/modify_test.go b/cmd/modify_test.go new file mode 100644 index 0000000..48d0ec7 --- /dev/null +++ b/cmd/modify_test.go @@ -0,0 +1,728 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/modify" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/modifyview" + "github.com/github/gh-stack/internal/tui/stackview" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// 1. State file management tests +// --------------------------------------------------------------------------- + +func TestModifyStateLifecycle(t *testing.T) { + gitDir := t.TempDir() + + // Initially no state file exists + assert.False(t, modify.StateExists(gitDir), "no state file should exist initially") + + loaded, err := modify.LoadState(gitDir) + assert.NoError(t, err) + assert.Nil(t, loaded, "loadModifyState should return nil when file does not exist") + + // Save a state file + state := &modify.StateFile{ + SchemaVersion: 1, + StackName: "main", + StartedAt: time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC), + Phase: "applying", + Snapshot: modify.Snapshot{ + Branches: []modify.BranchSnapshot{ + {Name: "b1", TipSHA: "aaa111", Position: 0}, + {Name: "b2", TipSHA: "bbb222", Position: 1}, + }, + StackMetadata: json.RawMessage(`{"trunk":{"branch":"main"}}`), + }, + Plan: []modify.Action{ + {Type: "drop", Branch: "b1"}, + {Type: "rename", Branch: "b2", NewName: "b2-new"}, + }, + } + + err = modify.SaveState(gitDir, state) + require.NoError(t, err) + assert.True(t, modify.StateExists(gitDir), "state file should exist after save") + + // Load it back and verify round-trip + loaded, err = modify.LoadState(gitDir) + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, 1, loaded.SchemaVersion) + assert.Equal(t, "main", loaded.StackName) + assert.Equal(t, "applying", loaded.Phase) + assert.Equal(t, state.StartedAt, loaded.StartedAt) + require.Len(t, loaded.Snapshot.Branches, 2) + assert.Equal(t, "b1", loaded.Snapshot.Branches[0].Name) + assert.Equal(t, "aaa111", loaded.Snapshot.Branches[0].TipSHA) + assert.Equal(t, 0, loaded.Snapshot.Branches[0].Position) + assert.Equal(t, "b2", loaded.Snapshot.Branches[1].Name) + assert.Equal(t, "bbb222", loaded.Snapshot.Branches[1].TipSHA) + assert.Equal(t, 1, loaded.Snapshot.Branches[1].Position) + require.Len(t, loaded.Plan, 2) + assert.Equal(t, "drop", loaded.Plan[0].Type) + assert.Equal(t, "b1", loaded.Plan[0].Branch) + assert.Equal(t, "rename", loaded.Plan[1].Type) + assert.Equal(t, "b2-new", loaded.Plan[1].NewName) + + // Clear the state + modify.ClearState(gitDir) + assert.False(t, modify.StateExists(gitDir), "state file should be removed after clear") + + loaded, err = modify.LoadState(gitDir) + assert.NoError(t, err) + assert.Nil(t, loaded, "loadModifyState should return nil after clear") +} + +func TestModifyStateAtomicWrite(t *testing.T) { + gitDir := t.TempDir() + + state := &modify.StateFile{ + SchemaVersion: 1, + StackName: "main", + Phase: "applying", + StartedAt: time.Now().UTC(), + } + + err := modify.SaveState(gitDir, state) + require.NoError(t, err) + + // The final file should exist + _, err = os.Stat(modify.StatePath(gitDir)) + assert.NoError(t, err, "state file should exist after atomic write") + + // No .tmp file should be left behind + _, err = os.Stat(modify.StatePath(gitDir) + ".tmp") + assert.True(t, os.IsNotExist(err), "no .tmp file should remain after successful write") +} + +func TestCheckModifyStateGuard(t *testing.T) { + t.Run("no state file", func(t *testing.T) { + gitDir := t.TempDir() + err := modify.CheckStateGuard(gitDir) + assert.NoError(t, err, "guard should pass when no state file exists") + }) + + t.Run("phase applying returns error", func(t *testing.T) { + gitDir := t.TempDir() + state := &modify.StateFile{ + SchemaVersion: 1, + Phase: "applying", + StartedAt: time.Now().UTC(), + } + require.NoError(t, modify.SaveState(gitDir, state)) + + err := modify.CheckStateGuard(gitDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "modify session was interrupted") + }) + + t.Run("phase pending_submit passes", func(t *testing.T) { + gitDir := t.TempDir() + state := &modify.StateFile{ + SchemaVersion: 1, + Phase: "pending_submit", + StartedAt: time.Now().UTC(), + } + require.NoError(t, modify.SaveState(gitDir, state)) + + err := modify.CheckStateGuard(gitDir) + assert.NoError(t, err, "guard should pass when phase is pending_submit") + }) +} + +// --------------------------------------------------------------------------- +// 2. Precondition check tests +// --------------------------------------------------------------------------- + +func TestCheckStackLinearity(t *testing.T) { + t.Run("linear stack passes", func(t *testing.T) { + mock := &git.MockOps{ + IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, + LogMergesFn: func(base, head string) ([]git.CommitInfo, error) { return nil, nil }, + } + restore := git.SetOps(mock) + defer restore() + + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + cfg, _, _ := config.NewTestConfig() + err := modify.CheckStackLinearity(cfg, s) + cfg.Out.Close() + cfg.Err.Close() + assert.NoError(t, err) + }) + + t.Run("diverged branch fails", func(t *testing.T) { + mock := &git.MockOps{ + IsAncestorFn: func(a, d string) (bool, error) { + // b1 is not an ancestor of b2 + if a == "b1" && d == "b2" { + return false, nil + } + return true, nil + }, + LogMergesFn: func(base, head string) ([]git.CommitInfo, error) { return nil, nil }, + } + restore := git.SetOps(mock) + defer restore() + + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + cfg, _, _ := config.NewTestConfig() + err := modify.CheckStackLinearity(cfg, s) + cfg.Out.Close() + cfg.Err.Close() + assert.Error(t, err) + }) + + t.Run("merge commit fails", func(t *testing.T) { + mock := &git.MockOps{ + IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, + LogMergesFn: func(base, head string) ([]git.CommitInfo, error) { + if head == "b2" { + return []git.CommitInfo{{SHA: "merge-sha"}}, nil + } + return nil, nil + }, + } + restore := git.SetOps(mock) + defer restore() + + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + cfg, _, _ := config.NewTestConfig() + err := modify.CheckStackLinearity(cfg, s) + cfg.Out.Close() + cfg.Err.Close() + assert.Error(t, err) + }) + + t.Run("skips merged branches", func(t *testing.T) { + var isAncestorCalls []string + mock := &git.MockOps{ + IsAncestorFn: func(a, d string) (bool, error) { + isAncestorCalls = append(isAncestorCalls, d) + return true, nil + }, + LogMergesFn: func(base, head string) ([]git.CommitInfo, error) { return nil, nil }, + } + restore := git.SetOps(mock) + defer restore() + + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}}, + {Branch: "b2"}, + }, + } + + cfg, _, _ := config.NewTestConfig() + err := modify.CheckStackLinearity(cfg, s) + cfg.Out.Close() + cfg.Err.Close() + assert.NoError(t, err) + + // b1 is merged so IsAncestor should only be called for b2 + assert.NotContains(t, isAncestorCalls, "b1", "merged branch b1 should be skipped") + assert.Contains(t, isAncestorCalls, "b2", "active branch b2 should be checked") + }) +} + +func TestCheckNoMergeQueuePRs(t *testing.T) { + t.Run("no queued PRs passes", func(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b2"}, + }, + } + + cfg, _, _ := config.NewTestConfig() + err := modify.CheckNoMergeQueuePRs(cfg, s) + cfg.Out.Close() + cfg.Err.Close() + assert.NoError(t, err) + }) + + t.Run("queued unmerged PR fails", func(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}, Queued: true}, + {Branch: "b2"}, + }, + } + + cfg, _, _ := config.NewTestConfig() + err := modify.CheckNoMergeQueuePRs(cfg, s) + cfg.Out.Close() + cfg.Err.Close() + assert.Error(t, err) + }) + + t.Run("queued merged PR passes", func(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}, Queued: true}, + }, + } + + cfg, _, _ := config.NewTestConfig() + err := modify.CheckNoMergeQueuePRs(cfg, s) + cfg.Out.Close() + cfg.Err.Close() + assert.NoError(t, err) + }) +} + +func TestCheckNoModifyInProgress(t *testing.T) { + t.Run("no state file passes", func(t *testing.T) { + gitDir := t.TempDir() + cfg, _, _ := config.NewTestConfig() + err := checkNoModifyInProgress(cfg, gitDir) + cfg.Out.Close() + cfg.Err.Close() + assert.NoError(t, err) + }) + + t.Run("applying phase returns ErrModifyRecovery", func(t *testing.T) { + gitDir := t.TempDir() + state := &modify.StateFile{ + SchemaVersion: 1, + Phase: "applying", + StartedAt: time.Now().UTC(), + } + require.NoError(t, modify.SaveState(gitDir, state)) + + cfg, _, _ := config.NewTestConfig() + err := checkNoModifyInProgress(cfg, gitDir) + cfg.Out.Close() + cfg.Err.Close() + assert.ErrorIs(t, err, ErrModifyRecovery) + }) + + t.Run("pending_submit phase returns ErrSilent", func(t *testing.T) { + gitDir := t.TempDir() + state := &modify.StateFile{ + SchemaVersion: 1, + Phase: "pending_submit", + StartedAt: time.Now().UTC(), + } + require.NoError(t, modify.SaveState(gitDir, state)) + + cfg, _, _ := config.NewTestConfig() + err := checkNoModifyInProgress(cfg, gitDir) + cfg.Out.Close() + cfg.Err.Close() + assert.Error(t, err) + }) +} + +// --------------------------------------------------------------------------- +// 3. Build functions tests +// --------------------------------------------------------------------------- + +func TestBuildModifySnapshot(t *testing.T) { + mock := &git.MockOps{ + RevParseFn: func(ref string) (string, error) { + return "sha-" + ref, nil + }, + } + restore := git.SetOps(mock) + defer restore() + + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + snapshot, err := modify.BuildSnapshot(s) + require.NoError(t, err) + + // Verify branch snapshots + require.Len(t, snapshot.Branches, 3) + assert.Equal(t, "b1", snapshot.Branches[0].Name) + assert.Equal(t, "sha-b1", snapshot.Branches[0].TipSHA) + assert.Equal(t, 0, snapshot.Branches[0].Position) + + assert.Equal(t, "b2", snapshot.Branches[1].Name) + assert.Equal(t, "sha-b2", snapshot.Branches[1].TipSHA) + assert.Equal(t, 1, snapshot.Branches[1].Position) + + assert.Equal(t, "b3", snapshot.Branches[2].Name) + assert.Equal(t, "sha-b3", snapshot.Branches[2].TipSHA) + assert.Equal(t, 2, snapshot.Branches[2].Position) + + // Verify stack metadata is valid JSON containing the stack + var restoredStack stack.Stack + err = json.Unmarshal(snapshot.StackMetadata, &restoredStack) + require.NoError(t, err) + assert.Equal(t, "main", restoredStack.Trunk.Branch) + require.Len(t, restoredStack.Branches, 3) + assert.Equal(t, "b1", restoredStack.Branches[0].Branch) + assert.Equal(t, "b2", restoredStack.Branches[1].Branch) + assert.Equal(t, "b3", restoredStack.Branches[2].Branch) +} + +func TestBuildModifyPlan(t *testing.T) { + t.Run("drop action", func(t *testing.T) { + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + PendingAction: &modifyview.PendingAction{Type: modifyview.ActionDrop}, + Removed: true, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, + }, + } + + plan := modify.BuildPlan(nodes) + // b1 is Removed=true so it's skipped in the main loop. + // b2 has no changes and is at its original position → nothing. + assert.Empty(t, plan, "removed nodes are skipped; unchanged nodes produce nothing") + }) + + t.Run("rename action", func(t *testing.T) { + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + PendingAction: &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "b1-new"}, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, + }, + } + + plan := modify.BuildPlan(nodes) + require.Len(t, plan, 1) + assert.Equal(t, "rename", plan[0].Type) + assert.Equal(t, "b1", plan[0].Branch) + assert.Equal(t, "b1-new", plan[0].NewName) + }) + + t.Run("mixed actions", func(t *testing.T) { + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + PendingAction: &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "feature-1"}, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, + PendingAction: &modifyview.PendingAction{Type: modifyview.ActionDrop}, + Removed: true, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b3"}}, + OriginalPosition: 2, + PendingAction: &modifyview.PendingAction{Type: modifyview.ActionFoldDown}, + Removed: true, + }, + } + + plan := modify.BuildPlan(nodes) + // b1 has a rename action → included + // b2 and b3 are Removed → skipped in the loop + require.Len(t, plan, 1) + assert.Equal(t, "rename", plan[0].Type) + assert.Equal(t, "feature-1", plan[0].NewName) + }) + + t.Run("no changes produces empty plan", func(t *testing.T) { + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, + }, + } + + plan := modify.BuildPlan(nodes) + assert.Empty(t, plan) + }) + + t.Run("position change produces move action", func(t *testing.T) { + // b2 moved to position 0, b1 moved to position 1 (swapped) + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, // was at 1, now at 0 + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, // was at 0, now at 1 + }, + } + + plan := modify.BuildPlan(nodes) + require.Len(t, plan, 2) + assert.Equal(t, "move", plan[0].Type) + assert.Equal(t, "b2", plan[0].Branch) + assert.Equal(t, 0, plan[0].NewPosition) + assert.Equal(t, "move", plan[1].Type) + assert.Equal(t, "b1", plan[1].Branch) + assert.Equal(t, 1, plan[1].NewPosition) + }) +} + +// --------------------------------------------------------------------------- +// 4. Full preconditions integration test +// --------------------------------------------------------------------------- + +func TestCheckModifyPreconditions_NotInteractive(t *testing.T) { + // cfg from NewTestConfig is not interactive by default (piped output) + cfg, _, _ := config.NewTestConfig() + // Ensure ForceInteractive is false (default) + cfg.ForceInteractive = false + + tmpDir := t.TempDir() + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + } + restore := git.SetOps(mock) + defer restore() + + _, err := checkModifyPreconditions(cfg) + cfg.Out.Close() + cfg.Err.Close() + assert.Error(t, err) +} + +func TestCheckModifyPreconditions_RebaseInProgress(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + IsRebaseInProgressFn: func() bool { return true }, + HasUncommittedChangesFn: func() (bool, error) { return false, nil }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + + _, err := checkModifyPreconditions(cfg) + cfg.Out.Close() + cfg.Err.Close() + assert.ErrorIs(t, err, ErrRebaseActive) +} + +func TestCheckModifyPreconditions_DirtyWorkingTree(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + IsRebaseInProgressFn: func() bool { return false }, + HasUncommittedChangesFn: func() (bool, error) { return true, nil }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + + _, err := checkModifyPreconditions(cfg) + cfg.Out.Close() + cfg.Err.Close() + assert.Error(t, err) +} + +func TestCheckModifyPreconditions_AllPass(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + IsRebaseInProgressFn: func() bool { return false }, + HasUncommittedChangesFn: func() (bool, error) { return false, nil }, + IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, + LogMergesFn: func(base, head string) ([]git.CommitInfo, error) { return nil, nil }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + // Inject mock GitHub client so syncStackPRs doesn't fail + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return nil, nil + }, + } + + result, err := checkModifyPreconditions(cfg) + cfg.Out.Close() + cfg.Err.Close() + assert.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, tmpDir, result.GitDir) + assert.Equal(t, "b1", result.CurrentBranch) +} + +// --------------------------------------------------------------------------- +// 5. State file path / exists edge cases +// --------------------------------------------------------------------------- + +func TestModifyStatePath(t *testing.T) { + p := modify.StatePath("/fake/git/dir") + assert.Equal(t, filepath.Join("/fake/git/dir", "gh-stack-modify-state"), p) +} + +func TestModifyStateExistsAfterSaveAndClear(t *testing.T) { + gitDir := t.TempDir() + + assert.False(t, modify.StateExists(gitDir)) + + state := &modify.StateFile{SchemaVersion: 1, Phase: "applying", StartedAt: time.Now().UTC()} + require.NoError(t, modify.SaveState(gitDir, state)) + assert.True(t, modify.StateExists(gitDir)) + + modify.ClearState(gitDir) + assert.False(t, modify.StateExists(gitDir)) +} + +func TestLoadModifyState_InvalidJSON(t *testing.T) { + gitDir := t.TempDir() + err := os.WriteFile(modify.StatePath(gitDir), []byte("not json"), 0644) + require.NoError(t, err) + + loaded, err := modify.LoadState(gitDir) + assert.Error(t, err) + assert.Nil(t, loaded) + assert.Contains(t, err.Error(), "parsing modify state") +} + +// --------------------------------------------------------------------------- +// 6. State round-trip with prior remote stack ID +// --------------------------------------------------------------------------- + +func TestModifyStateRoundTrip_WithPriorStackID(t *testing.T) { + gitDir := t.TempDir() + + state := &modify.StateFile{ + SchemaVersion: 1, + StackName: "main", + StartedAt: time.Now().UTC(), + Phase: "pending_submit", + PriorRemoteStackID: "stack-abc-123", + Snapshot: modify.Snapshot{ + Branches: []modify.BranchSnapshot{ + {Name: "b1", TipSHA: "aaa", Position: 0}, + }, + StackMetadata: json.RawMessage(`{}`), + }, + Plan: []modify.Action{ + {Type: "fold_down", Branch: "b2"}, + }, + } + + require.NoError(t, modify.SaveState(gitDir, state)) + + loaded, err := modify.LoadState(gitDir) + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, "pending_submit", loaded.Phase) + assert.Equal(t, "stack-abc-123", loaded.PriorRemoteStackID) +} + +// --------------------------------------------------------------------------- +// 7. checkModifyStateGuard edge cases +// --------------------------------------------------------------------------- + +func TestCheckModifyStateGuard_IgnoresReadErrors(t *testing.T) { + // Use a path that doesn't exist and isn't a directory — this tests + // the "ignore read errors" branch in checkModifyStateGuard. + err := modify.CheckStateGuard("/nonexistent/path/that/does/not/exist") + assert.NoError(t, err, "guard should silently ignore read errors") +} + +func TestCheckModifyStateGuard_UnknownPhase(t *testing.T) { + gitDir := t.TempDir() + state := &modify.StateFile{ + SchemaVersion: 1, + Phase: "unknown_phase", + StartedAt: time.Now().UTC(), + } + require.NoError(t, modify.SaveState(gitDir, state)) + + err := modify.CheckStateGuard(gitDir) + assert.NoError(t, err, "guard only blocks on 'applying' phase") +} diff --git a/cmd/push.go b/cmd/push.go index dbf07e3..1f0f857 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -7,6 +7,7 @@ import ( "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -38,6 +39,11 @@ func runPush(cfg *config.Config, opts *pushOptions) error { return ErrNotInStack } + if err := modify.CheckStateGuard(gitDir); err != nil { + cfg.Errorf("%s", err) + return ErrModifyRecovery + } + sf, err := stack.Load(gitDir) if err != nil { cfg.Errorf("failed to load stack state: %s", err) diff --git a/cmd/rebase.go b/cmd/rebase.go index a97199d..cfed5af 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -10,6 +10,7 @@ import ( "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -78,6 +79,11 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return abortRebase(cfg, gitDir) } + if err := modify.CheckStateGuard(gitDir); err != nil { + cfg.Errorf("%s", err) + return ErrModifyRecovery + } + result, err := loadStack(cfg, opts.branch) if err != nil { return ErrNotInStack @@ -610,22 +616,24 @@ func clearRebaseState(gitDir string) { } func printConflictDetails(cfg *config.Config, branch string) { - files, err := git.ConflictedFiles() - if err != nil || len(files) == 0 { - return - } + printConflictDetailsWithContinue(cfg, branch, "gh stack rebase --continue") +} - cfg.Printf("") - cfg.Printf("%s", cfg.ColorBold("Conflicted files:")) - for _, f := range files { - info, err := git.FindConflictMarkers(f) - if err != nil || len(info.Sections) == 0 { - cfg.Printf(" %s %s", cfg.ColorWarning("C"), f) - continue - } - for _, sec := range info.Sections { - cfg.Printf(" %s %s (lines %d–%d)", - cfg.ColorWarning("C"), f, sec.StartLine, sec.EndLine) +func printConflictDetailsWithContinue(cfg *config.Config, branch string, continueCmd string) { + files, err := git.ConflictedFiles() + if err == nil && len(files) > 0 { + cfg.Printf("") + cfg.Printf("%s", cfg.ColorBold("Conflicted files:")) + for _, f := range files { + info, err := git.FindConflictMarkers(f) + if err != nil || len(info.Sections) == 0 { + cfg.Printf(" %s %s", cfg.ColorWarning("C"), f) + continue + } + for _, sec := range info.Sections { + cfg.Printf(" %s %s (lines %d–%d)", + cfg.ColorWarning("C"), f, sec.StartLine, sec.EndLine) + } } } @@ -637,5 +645,5 @@ func printConflictDetails(cfg *config.Config, branch string) { cfg.Printf(" %s (changes being rebased)", cfg.ColorCyan(">>>>>>>")) cfg.Printf(" 2. Edit the file to keep the desired changes and remove the markers") cfg.Printf(" 3. Stage resolved files: `%s`", cfg.ColorCyan("git add ")) - cfg.Printf(" 4. Continue the rebase: `%s`", cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf(" 4. Continue: `%s`", cfg.ColorCyan(continueCmd)) } diff --git a/cmd/root.go b/cmd/root.go index 6e17b7c..c566aba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -42,6 +42,7 @@ func RootCmd() *cobra.Command { // Helper commands root.AddCommand(ViewCmd(cfg)) root.AddCommand(RebaseCmd(cfg)) + root.AddCommand(ModifyCmd(cfg)) // Navigation commands root.AddCommand(UpCmd(cfg)) diff --git a/cmd/submit.go b/cmd/submit.go index 987eddb..a830177 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -11,6 +11,7 @@ import ( "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -106,7 +107,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // Sync PR state to detect merged/queued PRs before pushing. syncStackPRs(cfg, s) - // Push all active branches atomically + // Resolve remote for pushing remote, err := pickRemote(cfg, currentBranch, opts.remote) if err != nil { if !errors.Is(err, errInterrupt) { @@ -127,101 +128,49 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { cfg.Printf("All branches are merged or queued, nothing to submit") return nil } - cfg.Printf("Pushing %d %s to %s...", len(activeBranches), plural(len(activeBranches), "branch", "branches"), remote) - if err := git.Push(remote, activeBranches, true, true); err != nil { - cfg.Errorf("failed to push: %s", err) - return ErrSilent + + // If a modification is pending, delete the old remote stack first so that + // PR base updates are allowed and force-pushes don't trigger auto-merges. + if stacksAvailable { + if err := handlePendingModify(cfg, client, s, gitDir); err != nil { + if errors.Is(err, errInterrupt) { + return ErrSilent + } + // DeleteStack or other failure — don't continue with stale state + return ErrSilent + } } - // Create or update PRs — ensure every active branch has a PR with the - // correct base branch. This makes submit idempotent: running it again - // fills gaps and fixes base branches before syncing the stack. + // Push each branch and create/update its PR in stack order (bottom to top). + // Sequential pushing ensures each branch's base is up-to-date on the + // remote before the next branch is pushed, preventing race conditions. + cfg.Printf("Pushing to %s...", remote) for i, b := range s.Branches { if s.Branches[i].IsMerged() || s.Branches[i].IsQueued() { continue } - baseBranch := s.ActiveBaseBranch(b.Branch) - pr, err := client.FindPRForBranch(b.Branch) - if err != nil { - cfg.Warningf("failed to check PR for %s: %v", b.Branch, err) - continue + // Push this branch + if err := git.Push(remote, []string{b.Branch}, true, false); err != nil { + cfg.Errorf("failed to push %s: %s", b.Branch, err) + return ErrSilent } - if pr == nil { - // Create new PR — auto-generate title from commits/branch name, - // then prompt interactively unless --auto or non-interactive. - baseBranchForDiff := s.ActiveBaseBranch(b.Branch) - title, commitBody := defaultPRTitleBody(baseBranchForDiff, b.Branch) - originalTitle := title - if !opts.auto && cfg.IsInteractive() { - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return ErrSilent - } - // Non-interrupt error: keep the auto-generated title. - } else if input != "" { - title = input - } - } - - // If the user changed the title and the commit had a multi-line - // message, put the full commit message in the PR body so no - // content is lost. - prBody := commitBody - if title != originalTitle && commitBody != "" { - prBody = originalTitle + "\n\n" + commitBody - } - body := generatePRBody(prBody) - - newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft) - if createErr != nil { - cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr) - continue - } - cfg.Successf("Created PR %s for %s", cfg.PRLink(newPR.Number, newPR.URL), b.Branch) - s.Branches[i].PullRequest = &stack.PullRequestRef{ - Number: newPR.Number, - ID: newPR.ID, - URL: newPR.URL, - } - } else { - // PR already exists — record it and fix base branch if needed. - if s.Branches[i].PullRequest == nil { - s.Branches[i].PullRequest = &stack.PullRequestRef{ - Number: pr.Number, - ID: pr.ID, - URL: pr.URL, - } - } - - if pr.BaseRefName != baseBranch { - if s.ID != "" { - // PRs in an existing stack can't have their base updated - // via the API — the stack owns the base relationships. - cfg.Warningf("PR %s has base %q (expected %q) but cannot update while stacked", - cfg.PRLink(pr.Number, pr.URL), pr.BaseRefName, baseBranch) - } else { - if err := client.UpdatePRBase(pr.Number, baseBranch); err != nil { - cfg.Warningf("failed to update base branch for PR %s: %v", - cfg.PRLink(pr.Number, pr.URL), err) - } else { - cfg.Successf("Updated base branch for PR %s to %s", - cfg.PRLink(pr.Number, pr.URL), baseBranch) - } - } - } else { - cfg.Printf("PR %s for %s is up to date", cfg.PRLink(pr.Number, pr.URL), b.Branch) + // Find or create PR, and fix base if needed + baseBranch := s.ActiveBaseBranch(b.Branch) + if err := ensurePR(cfg, client, s, i, baseBranch, opts); err != nil { + if errors.Is(err, errInterrupt) { + printInterrupt(cfg) + return ErrSilent } + // Non-fatal — continue with remaining branches } } // Create or update the stack on GitHub if stacksAvailable { syncStack(cfg, client, s) + clearPendingModifyState(cfg, gitDir) } // Update base commit hashes and sync PR state @@ -236,6 +185,91 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { return nil } +// ensurePR finds or creates a PR for the branch at index i, and updates +// its base branch if needed. This is the single place where PR state is +// reconciled during submit. +func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions) error { + b := s.Branches[i] + + pr, err := client.FindPRForBranch(b.Branch) + if err != nil { + cfg.Warningf("failed to check PR for %s: %v", b.Branch, err) + return nil + } + + if pr == nil { + return createPR(cfg, client, s, i, baseBranch, opts) + } + + // PR exists — record it and fix base if needed. + if s.Branches[i].PullRequest == nil { + s.Branches[i].PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + } + } + + if pr.BaseRefName != baseBranch { + if s.ID != "" { + // Stack API owns base relationships — can't update directly. + cfg.Warningf("PR %s has base %q (expected %q) but cannot update while stacked", + cfg.PRLink(pr.Number, pr.URL), pr.BaseRefName, baseBranch) + } else { + if err := client.UpdatePRBase(pr.Number, baseBranch); err != nil { + cfg.Warningf("failed to update base branch for PR %s: %v", + cfg.PRLink(pr.Number, pr.URL), err) + } else { + cfg.Successf("Updated base branch for PR %s to %s", + cfg.PRLink(pr.Number, pr.URL), baseBranch) + } + } + } else { + cfg.Printf("PR %s for %s is up to date", cfg.PRLink(pr.Number, pr.URL), b.Branch) + } + + return nil +} + +// createPR creates a new PR for the branch at index i. +func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions) error { + b := s.Branches[i] + + title, commitBody := defaultPRTitleBody(baseBranch, b.Branch) + originalTitle := title + if !opts.auto && cfg.IsInteractive() { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) + if err != nil { + if isInterruptError(err) { + return errInterrupt + } + // Non-interrupt error: keep the auto-generated title. + } else if input != "" { + title = input + } + } + + prBody := commitBody + if title != originalTitle && commitBody != "" { + prBody = originalTitle + "\n\n" + commitBody + } + body := generatePRBody(prBody) + + newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft) + if createErr != nil { + cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr) + return nil + } + cfg.Successf("Created PR %s for %s", cfg.PRLink(newPR.Number, newPR.URL), b.Branch) + s.Branches[i].PullRequest = &stack.PullRequestRef{ + Number: newPR.Number, + ID: newPR.ID, + URL: newPR.URL, + } + return nil +} + // defaultPRTitleBody generates a PR title and body from the branch's commits. // If there is exactly one commit, use its subject as the title and its body // (if any) as the PR body. Otherwise, humanize the branch name for the title. @@ -275,6 +309,68 @@ func humanize(s string) string { }, s) } +// handlePendingModify handles the stack recreation after a modify operation. +// It deletes the old remote stack and clears s.ID so syncStack creates a new +// one. The state file is NOT cleared here — it is cleared after syncStack +// succeeds, ensuring retry safety. +func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.Stack, gitDir string) error { + state, err := modify.LoadState(gitDir) + if err != nil || state == nil { + return nil // No modify state — nothing to do + } + if state.Phase != modify.PhasePendingSubmit { + return nil // Not in pending_submit phase + } + + // Prompt for confirmation before overwriting the remote stack + if cfg.IsInteractive() { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + proceed, promptErr := p.Confirm("The local stack has been modified. Overwrite the existing stack on GitHub?", true) + if promptErr != nil { + if isInterruptError(promptErr) { + printInterrupt(cfg) + return errInterrupt + } + return promptErr + } + if !proceed { + cfg.Printf("Skipping stack recreation — run `%s` when ready", + cfg.ColorCyan("gh stack submit")) + return errInterrupt + } + } + + // Delete the old remote stack + if state.PriorRemoteStackID != "" { + if err := client.DeleteStack(state.PriorRemoteStackID); err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { + cfg.Printf("Previous stack already deleted on GitHub") + } else { + cfg.Warningf("Failed to delete existing stack: %v", err) + cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit")) + return err + } + } else { + cfg.Successf("Cleared existing stack on GitHub") + } + // Clear the old stack ID so syncStack creates a new one + s.ID = "" + } + + return nil +} + +// clearPendingModifyState clears the modify state file after a successful submit. +// Called after syncStack succeeds to ensure retry safety. +func clearPendingModifyState(cfg *config.Config, gitDir string) { + if !modify.StateExists(gitDir) { + return + } + modify.ClearState(gitDir) + cfg.Successf("Stack recreated on GitHub to match local state") +} + // syncStack creates or updates a stack on GitHub from the active PRs. // If the stack already exists (s.ID is set), it calls the PUT endpoint with // the full list of PRs to keep the remote stack in sync. If no stack exists diff --git a/cmd/submit_test.go b/cmd/submit_test.go index 07d67d4..2b0e40d 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "io" "net/url" @@ -11,6 +12,7 @@ import ( "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -121,10 +123,11 @@ func TestSubmit_CreatesPRsAndStack(t *testing.T) { assert.NoError(t, err) - // Branches should be pushed - require.Len(t, pushCalls, 1) + // Branches should be pushed (sequentially, one per branch) + require.Len(t, pushCalls, 2) assert.Equal(t, "origin", pushCalls[0].remote) - assert.Equal(t, []string{"b1", "b2"}, pushCalls[0].branches) + assert.Equal(t, []string{"b1"}, pushCalls[0].branches) + assert.Equal(t, []string{"b2"}, pushCalls[1].branches) // PRs should be created assert.Equal(t, []string{"b1", "b2"}, createdPRs) @@ -1073,3 +1076,297 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) { // after PR creation), so expect exactly 2 ListStacks calls. assert.Equal(t, 2, listStacksCallCount, "ListStacks should only be called by syncStackPRs, not by the preflight check") } + +// --- Modify + Submit integration tests --- + +func saveModifyState(t *testing.T, gitDir string, state *modify.StateFile) { + t.Helper() + require.NoError(t, modify.SaveState(gitDir, state)) +} + +func newPendingSubmitState(priorStackID string) *modify.StateFile { + return &modify.StateFile{ + SchemaVersion: 1, + Phase: "pending_submit", + PriorRemoteStackID: priorStackID, + Snapshot: modify.Snapshot{StackMetadata: json.RawMessage(`{}`)}, + } +} + +func TestHandlePendingModify_DeletesOldStack(t *testing.T) { + gitDir := t.TempDir() + + saveModifyState(t, gitDir, newPendingSubmitState("stack-123")) + + s := &stack.Stack{ID: "stack-123", Trunk: stack.BranchRef{Branch: "main"}} + + var deletedStackID string + client := &github.MockClient{ + DeleteStackFn: func(id string) error { + deletedStackID = id + return nil + }, + } + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := handlePendingModify(cfg, client, s, gitDir) + require.NoError(t, err) + assert.Equal(t, "stack-123", deletedStackID) + assert.Equal(t, "", s.ID) +} + +func TestHandlePendingModify_NoStateFile(t *testing.T) { + gitDir := t.TempDir() + // No state file on disk. + + s := &stack.Stack{ID: "stack-123", Trunk: stack.BranchRef{Branch: "main"}} + + deleteCalled := false + client := &github.MockClient{ + DeleteStackFn: func(id string) error { + deleteCalled = true + return nil + }, + } + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := handlePendingModify(cfg, client, s, gitDir) + assert.NoError(t, err) + assert.False(t, deleteCalled, "DeleteStack should not be called when no state file exists") + assert.Equal(t, "stack-123", s.ID, "stack ID should remain unchanged") +} + +func TestHandlePendingModify_WrongPhase(t *testing.T) { + gitDir := t.TempDir() + + state := &modify.StateFile{ + SchemaVersion: 1, + Phase: "conflict", + Snapshot: modify.Snapshot{StackMetadata: json.RawMessage(`{}`)}, + } + saveModifyState(t, gitDir, state) + + s := &stack.Stack{ID: "stack-99", Trunk: stack.BranchRef{Branch: "main"}} + + deleteCalled := false + client := &github.MockClient{ + DeleteStackFn: func(id string) error { + deleteCalled = true + return nil + }, + } + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := handlePendingModify(cfg, client, s, gitDir) + assert.NoError(t, err) + assert.False(t, deleteCalled, "DeleteStack should not be called for non-pending_submit phase") + assert.Equal(t, "stack-99", s.ID, "stack ID should remain unchanged") +} + +func TestHandlePendingModify_DeleteFails(t *testing.T) { + gitDir := t.TempDir() + + saveModifyState(t, gitDir, newPendingSubmitState("stack-456")) + + s := &stack.Stack{ID: "stack-456", Trunk: stack.BranchRef{Branch: "main"}} + + client := &github.MockClient{ + DeleteStackFn: func(id string) error { + return fmt.Errorf("server error") + }, + } + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := handlePendingModify(cfg, client, s, gitDir) + assert.Error(t, err) + assert.Equal(t, "stack-456", s.ID, "stack ID should NOT be cleared on delete failure") +} + +func TestHandlePendingModify_Delete404(t *testing.T) { + gitDir := t.TempDir() + + saveModifyState(t, gitDir, newPendingSubmitState("stack-gone")) + + s := &stack.Stack{ID: "stack-gone", Trunk: stack.BranchRef{Branch: "main"}} + + client := &github.MockClient{ + DeleteStackFn: func(id string) error { + return &api.HTTPError{ + StatusCode: 404, + Message: "Not Found", + RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/stack-gone"}, + } + }, + } + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := handlePendingModify(cfg, client, s, gitDir) + require.NoError(t, err, "404 should be treated as success (stack already deleted)") + assert.Equal(t, "", s.ID, "stack ID should be cleared after 404") +} + +func TestClearPendingModifyState_ClearsFile(t *testing.T) { + gitDir := t.TempDir() + + saveModifyState(t, gitDir, newPendingSubmitState("stack-789")) + require.True(t, modify.StateExists(gitDir), "precondition: state file should exist") + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + clearPendingModifyState(cfg, gitDir) + assert.False(t, modify.StateExists(gitDir), "state file should be removed") +} + +func TestClearPendingModifyState_NoFile(t *testing.T) { + gitDir := t.TempDir() + // No state file on disk. + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // Should not panic or error. + clearPendingModifyState(cfg, gitDir) + assert.False(t, modify.StateExists(gitDir)) +} + +func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) { + s := stack.Stack{ + ID: "old-stack-42", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, + {Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 12}}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + saveModifyState(t, tmpDir, newPendingSubmitState("old-stack-42")) + + // Track call ordering + var callOrder []string + var pushCalls []pushCall + + mock := newSubmitMock(tmpDir, "b1") + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + callOrder = append(callOrder, fmt.Sprintf("push:%s", branches[0])) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + var deletedStackID string + var createdStackPRs []int + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + DeleteStackFn: func(id string) error { + deletedStackID = id + callOrder = append(callOrder, "delete:"+id) + return nil + }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + switch branch { + case "b1": + return &github.PullRequest{ + Number: 10, ID: "PR_10", + URL: "https://github.com/owner/repo/pull/10", + BaseRefName: "main", HeadRefName: "b1", + State: "OPEN", + }, nil + case "b2": + return &github.PullRequest{ + Number: 11, ID: "PR_11", + URL: "https://github.com/owner/repo/pull/11", + BaseRefName: "b1", HeadRefName: "b2", + State: "OPEN", + }, nil + case "b3": + return &github.PullRequest{ + Number: 12, ID: "PR_12", + URL: "https://github.com/owner/repo/pull/12", + BaseRefName: "b2", HeadRefName: "b3", + State: "OPEN", + }, nil + } + return nil, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + createdStackPRs = prNumbers + callOrder = append(callOrder, "create_stack") + return 99, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + } + + cmd := SubmitCmd(cfg) + cmd.SetArgs([]string{"--auto"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + + // DeleteStack called with old stack ID + assert.Equal(t, "old-stack-42", deletedStackID) + + // Push called per-branch (3 separate calls, not 1 atomic call) + require.Len(t, pushCalls, 3, "should push each branch individually") + assert.Equal(t, []string{"b1"}, pushCalls[0].branches) + assert.Equal(t, []string{"b2"}, pushCalls[1].branches) + assert.Equal(t, []string{"b3"}, pushCalls[2].branches) + for _, pc := range pushCalls { + assert.False(t, pc.atomic, "sequential push should not use atomic mode") + } + + // CreateStack called with all 3 PRs + assert.Equal(t, []int{10, 11, 12}, createdStackPRs) + + // Verify ordering: delete before push, push before create_stack + assert.True(t, len(callOrder) >= 5, "expected at least 5 calls, got %d: %v", len(callOrder), callOrder) + deleteIdx := -1 + firstPushIdx := -1 + createIdx := -1 + for i, c := range callOrder { + if c == "delete:old-stack-42" && deleteIdx == -1 { + deleteIdx = i + } + if c == "push:b1" && firstPushIdx == -1 { + firstPushIdx = i + } + if c == "create_stack" && createIdx == -1 { + createIdx = i + } + } + assert.Greater(t, firstPushIdx, deleteIdx, "delete should happen before push") + assert.Greater(t, createIdx, firstPushIdx, "create_stack should happen after push") + + // State file should be cleared + assert.False(t, modify.StateExists(tmpDir), "modify state file should be cleared after success") +} diff --git a/cmd/sync.go b/cmd/sync.go index 2a5471c..34a4baf 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -7,6 +7,7 @@ import ( "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -50,6 +51,12 @@ func runSync(cfg *config.Config, opts *syncOptions) error { return ErrNotInStack } gitDir := result.GitDir + + if err := modify.CheckStateGuard(gitDir); err != nil { + cfg.Errorf("%s", err) + return ErrModifyRecovery + } + sf := result.StackFile s := result.Stack currentBranch := result.CurrentBranch diff --git a/cmd/unstack.go b/cmd/unstack.go index bbcbb00..dd0b9ca 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -5,6 +5,7 @@ import ( "github.com/cli/go-gh/v2/pkg/api" "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -42,6 +43,12 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { return ErrNotInStack } gitDir := result.GitDir + + if err := modify.CheckStateGuard(gitDir); err != nil { + cfg.Errorf("%s", err) + return ErrModifyRecovery + } + sf := result.StackFile s := result.Stack diff --git a/cmd/utils.go b/cmd/utils.go index dc107ea..09e95e5 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -28,7 +28,8 @@ var ( ErrDisambiguate = &ExitError{Code: 6} // multiple stacks/remotes, can't auto-select ErrRebaseActive = &ExitError{Code: 7} // rebase already in progress ErrLockFailed = &ExitError{Code: 8} // could not acquire stack file lock - ErrStacksUnavailable = &ExitError{Code: 9} // stacked PRs not available for this repository + ErrStacksUnavailable = &ExitError{Code: 9} // stacked PRs not available for this repository + ErrModifyRecovery = &ExitError{Code: 10} // modify session interrupted, recovery required ) // ExitError is returned by commands to indicate a specific exit code. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 7fdbc10..0177626 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -65,6 +65,7 @@ export default defineConfig({ { label: 'Working with Stacked PRs', slug: 'guides/stacked-prs' }, { label: 'Stacked PRs in the GitHub UI', slug: 'guides/ui' }, { label: 'Typical Workflows', slug: 'guides/workflows' }, + { label: 'Restructuring Stacks', slug: 'guides/modify' }, ], }, { diff --git a/docs/src/assets/screenshots/modify-stack-tui.png b/docs/src/assets/screenshots/modify-stack-tui.png new file mode 100644 index 0000000..2c631c5 Binary files /dev/null and b/docs/src/assets/screenshots/modify-stack-tui.png differ diff --git a/docs/src/content/docs/guides/modify.md b/docs/src/content/docs/guides/modify.md new file mode 100644 index 0000000..fd15375 --- /dev/null +++ b/docs/src/content/docs/guides/modify.md @@ -0,0 +1,101 @@ +--- +title: Restructuring Stacks +description: How to use `gh stack modify` to restructure a stack. +--- + +`gh stack modify` provides an interactive terminal UI for restructuring a stack locally. You can drop, fold, rename, and reorder branches and then apply all your changes at once. + +![The modify stack terminal UI](../../../assets/screenshots/modify-stack-tui.png) + +## When to use modify + +Use `modify` when you need to: +- **Remove** a branch from the stack +- **Combine** two branches into one +- **Rename** a branch +- **Reorder** branches + +## Prerequisites + +Before running `modify`, ensure: +- You have an active stack checked out locally +- Your working tree is clean (no uncommitted changes) +- No rebase is in progress +- No PR in the stack is queued for merge +- Commit history is linear (run `gh stack rebase` first if needed) + +## Opening the TUI + +```sh +gh stack modify +``` + +The TUI shows your stack as a vertical list of branches with PR information, commits, and files changed. Merged branches appear as locked rows that cannot be modified. Press `?` for a help overlay describing all operations. + +## Operations + +### Drop (`x`) + +Removes a branch and its commits from the stack. The local branch and any associated PR are preserved. Upstream branches are rebased to exclude the dropped branch's unique commits. + +### Fold down (`d`) + +Absorbs the selected branch's commits into the branch below it (toward trunk) via cherry-pick. The folded branch is removed from the stack. + +### Fold up (`u`) + +Absorbs the selected branch's commits into the branch above it (away from trunk). Since the branch above already contains the folded branch's commits in its history, this is handled by adjusting what is considered the first unique commit for the branch. The folded branch is removed from the stack. + +### Rename (`r`) + +Opens an inline prompt to enter a new name for the branch. The branch is renamed locally and in the stack metadata. On the next `submit`, the new branch name is pushed to GitHub. + +### Reorder (`Shift+↑`/`Shift+↓`) + +Moves the selected branch up (away from trunk) or down (toward trunk) in the stack. A cascading rebase adjusts all affected branches. Note: reordering and structural changes (drop/fold/rename) cannot be mixed in the same session. + +### Undo (`z`) + +Reverses the most recent staged action. You can undo multiple times to step back through your changes. + +## Applying changes + +Press `Ctrl+S` to apply all staged changes. Nothing is modified until you save. The apply phase renames branches, folds/drops branches, and runs a cascading rebase to create a linear commit history with the desired stack state. + +### Handling conflicts + +If a rebase conflict occurs during the apply phase, you have two options: + +1. **Resolve and continue**: Fix the conflicts in your editor, stage with `git add`, then run `gh stack modify --continue` (you may need to do this multiple times) +2. **Abort**: Run `gh stack modify --abort` to abort the operation and restore the stack to the pre-modify state + +If a second conflict occurs after continuing, the same options are available. + +## After modifying + +If a stack of PRs has been created on GitHub, run: + +```sh +gh stack submit +``` + +This pushes the updated branches and recreates the stack. The old stack is automatically replaced. + +## Aborting + +If you want to discard all changes and restore the stack to its pre-modify state, run: + +```sh +gh stack modify --abort +``` + +This also works if `modify` was interrupted (e.g., terminal crash). A pre-modify snapshot is cached locally for state recovery. + +## Limitations + +- Cannot modify merged branches (they are locked) +- Cannot add new branches (use `gh stack add` instead) +- Cannot split a branch into multiple branches +- Cannot move branches between different stacks +- Requires an interactive terminal +- Reordering and structural changes (drop/fold/rename) cannot be mixed in the same session diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 1bb3a45..1237874 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -168,24 +168,49 @@ All branches in a stack should be part of the same feature or project. If you ne ## Restructuring a Stack -When you need to remove a branch, reorder branches, or rename branches, tear down the stack and rebuild it: +When you need to change the composition of a stack — remove a branch, combine branches, change the order, or rename a branch — use `gh stack modify`: ```sh -# 1. Remove the stack on GitHub and locally -gh stack unstack - -# 2. Make structural changes -git branch -m old-branch-1 new-branch-1 # rename a branch -git branch -D branch-3 # delete a branch - -# 3. Re-create the stack with the new order -gh stack init --adopt new-branch-1 branch-2 branch-4 - -# 4. Push and sync the new stack +# Open the modify TUI +gh stack modify + +# In the TUI: +# x → drop a branch +# d → fold down (into branch below) +# u → fold up (into branch above) +# Shift+↑/↓ → reorder +# r → rename +# z → undo +# Ctrl+S → apply changes +# q → cancel + +# After modifying, push changes to remote and recreate the stack on GitHub gh stack submit ``` -The `unstack` command deletes the stack on GitHub first, then removes local tracking. Your branches and PRs are not affected — only the stack relationship is removed. After `init --adopt`, any existing open PRs are automatically re-associated with the new stack. +### Common restructuring scenarios + +**Remove a branch and its unique commits from the stack:** +1. `gh stack modify` +2. Navigate to the branch, press `x` to mark it for drop +3. Press `Ctrl+S` to apply +4. `gh stack submit` + +**Combine two branches into one:** +1. `gh stack modify` +2. Navigate to the branch you want to fold +3. Press `d` to fold its commits into the branch below, or `u` to fold into the branch above +4. Press `Ctrl+S` to apply +5. `gh stack submit` + +**Reorder branches:** +1. `gh stack modify` +2. Navigate to the branch to move +3. Press `Shift+↑` to move up or `Shift+↓` to move down +4. Press `Ctrl+S` to apply +5. `gh stack submit` + +For a comprehensive guide on all modify operations, see the [Restructuring Stacks](/gh-stack/guides/modify/) guide. ## Using AI Agents with Stacks diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index ba213f5..b4e4cc5 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -157,6 +157,68 @@ gh stack checkout feature-auth gh stack checkout ``` +### `gh stack modify` + +Interactively restructure the current stack. + +```sh +gh stack modify [flags] +``` + +Opens an interactive terminal UI for restructuring a stack. All changes are staged in the TUI and applied together when you press `Ctrl+S`. Branches from merged PRs cannot be modified. + +| Flag | Description | +|------|-------------| +| `--continue` | Continue after resolving conflicts | +| `--abort` | Abort the modify session and restore the stack to its pre-modify state | + +**Preconditions:** + +The command checks these conditions before opening the TUI: + +1. Must have an active stack checked out locally +2. Working tree must be clean (no uncommitted changes) +3. No rebase in progress +4. No PR in the stack is queued for merge +5. Commit history must be linear (no merge commits, no diverged branches) + +**Operations:** + +| Operation | Key | Effect | +|-----------|-----|--------| +| Drop | `x` | Remove branch and its commits from stack. Local branch and associated PR are preserved. | +| Fold down | `d` | Absorb commits into branch below (toward trunk). Folded branch removed from stack. | +| Fold up | `u` | Absorb commits into branch above (away from trunk). Folded branch removed from stack. | +| Move down | `Shift+↓` | Reorder branch down (toward trunk) in the stack | +| Move up | `Shift+↑` | Reorder branch up (away from trunk) in the stack | +| Rename | `r` | Rename the branch (opens inline prompt) | +| Undo | `z` | Undo the last staged action | + +**Apply phase:** + +When you press `Ctrl+S`, the staged changes are applied by renaming branches, folding/dropping branches, and running a cascading rebase to create a linear commit history with the desired stack state. + +If a rebase conflict occurs, you can: +- Resolve conflicts, stage files, and run `gh stack modify --continue` +- Or run `gh stack modify --abort` to abort the operation and restore the stack to the pre-modify state + +**After modifying:** + +If a stack of PRs has been created on GitHub, run `gh stack submit` to push the updated branches and recreate the stack. The old stack is automatically replaced. + +**Examples:** + +```sh +# Open the interactive modify TUI +gh stack modify + +# Continue after resolving a conflict +gh stack modify --continue + +# Abort and restore to the previous state +gh stack modify --abort +``` + --- ## Remote Operations diff --git a/go.mod b/go.mod index 7933d90..9b1ab65 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/ansi v0.10.2 // indirect diff --git a/go.sum b/go.sum index 1050c8e..f43d5f0 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= diff --git a/internal/git/git.go b/internal/git/git.go index b7b9941..e9ed717 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -351,3 +351,34 @@ func CommitInteractive() (string, error) { func ValidateRefName(name string) error { return ops.ValidateRefName(name) } + +// RenameBranch renames a local branch. +func RenameBranch(oldName, newName string) error { + return ops.RenameBranch(oldName, newName) +} + +// CherryPick applies the given commits to the current branch. +func CherryPick(commits []string) error { + return ops.CherryPick(commits) +} + +// CherryPickAbort clears any in-progress cherry-pick state. +// Errors are silently ignored (no-op if no cherry-pick is in progress). +func CherryPickAbort() { + _ = ops.CherryPickAbort() +} + +// CherryPickContinue continues an in-progress cherry-pick after conflicts are resolved. +func CherryPickContinue() error { + return ops.CherryPickContinue() +} + +// HasUncommittedChanges returns true if the working tree has uncommitted changes. +func HasUncommittedChanges() (bool, error) { + return ops.HasUncommittedChanges() +} + +// LogMerges returns merge commits in the range base..head. +func LogMerges(base, head string) ([]CommitInfo, error) { + return ops.LogMerges(base, head) +} diff --git a/internal/git/gitops.go b/internal/git/gitops.go index d6ba52d..e3c1870 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -56,6 +56,12 @@ type Ops interface { Commit(message string) (string, error) CommitInteractive() (string, error) ValidateRefName(name string) error + RenameBranch(oldName, newName string) error + CherryPick(commits []string) error + CherryPickAbort() error + CherryPickContinue() error + HasUncommittedChanges() (bool, error) + LogMerges(base, head string) ([]CommitInfo, error) } // defaultOps implements Ops by delegating to the real git client and helpers. @@ -494,3 +500,63 @@ func (d *defaultOps) ValidateRefName(name string) error { _, err := run("check-ref-format", "--branch", name) return err } + +func (d *defaultOps) RenameBranch(oldName, newName string) error { + return runSilent("branch", "-m", oldName, newName) +} + +func (d *defaultOps) CherryPick(commits []string) error { + args := append([]string{"cherry-pick"}, commits...) + return runSilent(args...) +} + +func (d *defaultOps) CherryPickAbort() error { + return runSilent("cherry-pick", "--quit") +} + +func (d *defaultOps) CherryPickContinue() error { + cmd := exec.Command("git", "cherry-pick", "--continue") + cmd.Env = append(os.Environ(), "GIT_EDITOR=true") + return cmd.Run() +} + +func (d *defaultOps) HasUncommittedChanges() (bool, error) { + out, err := run("status", "--porcelain") + if err != nil { + return false, err + } + return out != "", nil +} + +func (d *defaultOps) LogMerges(base, head string) ([]CommitInfo, error) { + format := "%H%x01%B%x01%at%x00" + rangeSpec := base + ".." + head + output, err := run("log", "--merges", rangeSpec, "--format="+format) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + + var commits []CommitInfo + for _, record := range strings.Split(output, "\x00") { + record = strings.TrimSpace(record) + if record == "" { + continue + } + parts := strings.SplitN(record, "\x01", 3) + if len(parts) < 3 { + continue + } + ts, _ := strconv.ParseInt(strings.TrimSpace(parts[2]), 10, 64) + subject, body := splitCommitMessage(parts[1]) + commits = append(commits, CommitInfo{ + SHA: parts[0], + Subject: subject, + Body: body, + Time: time.Unix(ts, 0), + }) + } + return commits, nil +} diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index a8ab662..12ddf5b 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -43,7 +43,11 @@ type MockOps struct { HasStagedChangesFn func() bool CommitFn func(string) (string, error) CommitInteractiveFn func() (string, error) - ValidateRefNameFn func(string) error + ValidateRefNameFn func(string) error + RenameBranchFn func(string, string) error + CherryPickFn func([]string) error + HasUncommittedChangesFn func() (bool, error) + LogMergesFn func(string, string) ([]CommitInfo, error) } var _ Ops = (*MockOps)(nil) @@ -336,3 +340,39 @@ func (m *MockOps) ValidateRefName(name string) error { } return nil } + +func (m *MockOps) RenameBranch(oldName, newName string) error { + if m.RenameBranchFn != nil { + return m.RenameBranchFn(oldName, newName) + } + return nil +} + +func (m *MockOps) CherryPick(commits []string) error { + if m.CherryPickFn != nil { + return m.CherryPickFn(commits) + } + return nil +} + +func (m *MockOps) CherryPickAbort() error { + return nil +} + +func (m *MockOps) CherryPickContinue() error { + return nil +} + +func (m *MockOps) HasUncommittedChanges() (bool, error) { + if m.HasUncommittedChangesFn != nil { + return m.HasUncommittedChangesFn() + } + return false, nil +} + +func (m *MockOps) LogMerges(base, head string) ([]CommitInfo, error) { + if m.LogMergesFn != nil { + return m.LogMergesFn(base, head) + } + return nil, nil +} diff --git a/internal/modify/apply.go b/internal/modify/apply.go new file mode 100644 index 0000000..7075e52 --- /dev/null +++ b/internal/modify/apply.go @@ -0,0 +1,778 @@ +package modify + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/modifyview" +) + +// BuildSnapshot captures the current state of the stack for unwind/recovery. +func BuildSnapshot(s *stack.Stack) (Snapshot, error) { + // Collect all branch names + names := make([]string, len(s.Branches)) + for i, b := range s.Branches { + names[i] = b.Branch + } + + // Resolve all SHAs + shaMap, err := git.RevParseMap(names) + if err != nil { + return Snapshot{}, fmt.Errorf("resolving branch SHAs: %w", err) + } + + // Build branch snapshots + branches := make([]BranchSnapshot, len(s.Branches)) + for i, b := range s.Branches { + branches[i] = BranchSnapshot{ + Name: b.Branch, + TipSHA: shaMap[b.Branch], + Position: i, + } + } + + // Serialize stack metadata + stackJSON, err := json.Marshal(s) + if err != nil { + return Snapshot{}, fmt.Errorf("serializing stack metadata: %w", err) + } + + return Snapshot{ + Branches: branches, + StackMetadata: stackJSON, + }, nil +} + +// BuildPlan converts the TUI's staged actions into a list of Actions +// suitable for storage in the state file. +func BuildPlan(nodes []modifyview.ModifyBranchNode) []Action { + var plan []Action + + for i, n := range nodes { + if n.PendingAction == nil && n.OriginalPosition == i && !n.Removed { + continue + } + + if n.Removed { + continue // Removed nodes are handled by their pending action + } + + if n.PendingAction != nil { + action := Action{ + Type: string(n.PendingAction.Type), + Branch: n.Ref.Branch, + } + if n.PendingAction.Type == modifyview.ActionRename { + action.NewName = n.PendingAction.NewName + } + plan = append(plan, action) + } + + if n.OriginalPosition != i && n.PendingAction == nil { + plan = append(plan, Action{ + Type: "move", + Branch: n.Ref.Branch, + NewPosition: i, + }) + } + } + + return plan +} + +// ApplyPlan executes the staged modifications on the stack. +// updateBaseSHAs is called after rebasing to refresh branch SHAs in the stack metadata. +// It returns an ApplyResult on success or a ConflictInfo if a rebase conflict occurs. +func ApplyPlan( + cfg *config.Config, + gitDir string, + s *stack.Stack, + sf *stack.StackFile, + nodes []modifyview.ModifyBranchNode, + currentBranch string, + updateBaseSHAs func(*stack.Stack), +) (*modifyview.ApplyResult, *modifyview.ConflictInfo, error) { + // Build the snapshot before any changes + snapshot, err := BuildSnapshot(s) + if err != nil { + return nil, nil, fmt.Errorf("building snapshot: %w", err) + } + + // Acquire the stack lock before making any changes + lock, err := stack.Lock(gitDir) + if err != nil { + return nil, nil, fmt.Errorf("acquiring stack lock: %w", err) + } + defer lock.Unlock() + + plan := BuildPlan(nodes) + + // Find the index of this stack in the stack file for reliable identification + stackIndex := -1 + for i := range sf.Stacks { + if &sf.Stacks[i] == s { + stackIndex = i + break + } + } + + // Write state file with phase "applying" + stateFile := &StateFile{ + SchemaVersion: 1, + StackName: s.Trunk.Branch, + StackIndex: stackIndex, + StartedAt: time.Now().UTC(), + Phase: PhaseApplying, + PriorRemoteStackID: s.ID, + Snapshot: snapshot, + Plan: plan, + } + if err := SaveState(gitDir, stateFile); err != nil { + return nil, nil, fmt.Errorf("saving modify state: %w", err) + } + + result := &modifyview.ApplyResult{Success: true} + + // Collect original refs for rebase --onto, including trunk + branchNames := make([]string, 0, len(s.Branches)+1) + branchNames = append(branchNames, s.Trunk.Branch) + for _, b := range s.Branches { + if !b.IsMerged() && git.BranchExists(b.Branch) { + branchNames = append(branchNames, b.Branch) + } + } + originalRefs, err := git.RevParseMap(branchNames) + if err != nil { + // Unwind on failure + unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan) + if unwindErr != nil { + return nil, nil, fmt.Errorf("failed to resolve refs (%v) and unwind failed (%v)", err, unwindErr) + } + return nil, nil, fmt.Errorf("failed to resolve branch SHAs: %w", err) + } + + // Build a map of each branch's original parent tip SHA for accurate --onto rebase + originalParentTips := make(map[string]string) + for i, b := range s.Branches { + if b.IsMerged() { + continue + } + var parentName string + if i == 0 { + parentName = s.Trunk.Branch + } else { + parentName = s.ActiveBaseBranch(b.Branch) + } + if sha, ok := originalRefs[parentName]; ok { + originalParentTips[b.Branch] = sha + } + } + + // Step 1: Renames + for i, n := range nodes { + if n.PendingAction != nil && n.PendingAction.Type == modifyview.ActionRename { + oldName := n.Ref.Branch + newName := n.PendingAction.NewName + if err := git.RenameBranch(oldName, newName); err != nil { + unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan) + if unwindErr != nil { + return nil, nil, fmt.Errorf("rename failed (%v) and unwind failed (%v)", err, unwindErr) + } + return nil, nil, fmt.Errorf("renaming %s to %s: %w", oldName, newName, err) + } + + // Update in-memory state + idx := s.IndexOf(oldName) + if idx >= 0 { + // Update originalRefs key + if sha, ok := originalRefs[oldName]; ok { + originalRefs[newName] = sha + delete(originalRefs, oldName) + } + // Update originalParentTips key + if sha, ok := originalParentTips[oldName]; ok { + originalParentTips[newName] = sha + delete(originalParentTips, oldName) + } + s.Branches[idx].Branch = newName + } + // Update the node's ref for later steps + nodes[i].Ref.Branch = newName + + result.RenamedBranches = append(result.RenamedBranches, modifyview.RenamedBranch{ + OldName: oldName, + NewName: newName, + }) + cfg.Successf("Renamed %s → %s", oldName, newName) + } + } + + // Step 2: Folds — absorb one branch's commits into an adjacent branch. + // + // Fold-down: cherry-pick the folded branch's commits onto the target below. + // The target is below in the stack (closer to trunk), so it doesn't + // contain the folded branch's commits. Cherry-pick adds them. + // + // Fold-up: the target (above) already contains the folded branch's commits + // in its ancestry (it's stacked on top). Instead of cherry-picking, we + // adjust originalParentTips so the cascading rebase replays both the + // folded branch's commits AND the target's own commits when rebasing + // the target onto the folded branch's base. + for _, n := range nodes { + if n.PendingAction == nil { + continue + } + if n.PendingAction.Type != modifyview.ActionFoldDown && n.PendingAction.Type != modifyview.ActionFoldUp { + continue + } + + foldBranch := n.Ref.Branch + + // Determine target branch + var targetBranch string + foldIdx := s.IndexOf(foldBranch) + if foldIdx < 0 { + continue + } + + if n.PendingAction.Type == modifyview.ActionFoldDown { + // Target is the branch below (toward trunk) + if foldIdx == 0 { + continue + } + targetBranch = s.Branches[foldIdx-1].Branch + } else { + // Target is the branch above (away from trunk) + if foldIdx >= len(s.Branches)-1 { + continue + } + targetBranch = s.Branches[foldIdx+1].Branch + } + + baseBranch := s.ActiveBaseBranch(foldBranch) + + if n.PendingAction.Type == modifyview.ActionFoldDown { + // Fold-down: cherry-pick the folded branch's commits onto the target. + commits, err := git.LogRange(baseBranch, foldBranch) + if err != nil || len(commits) == 0 { + cfg.Printf("No commits to fold from %s", foldBranch) + } else { + if err := git.CheckoutBranch(targetBranch); err != nil { + unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan) + if unwindErr != nil { + return nil, nil, fmt.Errorf("checkout failed (%v) and unwind failed (%v)", err, unwindErr) + } + return nil, nil, fmt.Errorf("checking out %s for fold: %w", targetBranch, err) + } + + shas := make([]string, len(commits)) + for i, c := range commits { + shas[len(commits)-1-i] = c.SHA + } + + git.CherryPickAbort() + + if err := git.CherryPick(shas); err != nil { + conflict := &modifyview.ConflictInfo{Branch: foldBranch} + if files, ferr := git.ConflictedFiles(); ferr == nil { + conflict.ConflictedFiles = files + } + + // Compute remaining branches for cascading rebase after cherry-pick resumes. + // Since folds happen before cascading rebase (Step 5), all non-merged, non-folded + // branches need rebasing. + remaining := make([]string, 0) + for _, br := range s.Branches { + if !br.IsMerged() && br.Branch != foldBranch { + remaining = append(remaining, br.Branch) + } + } + + // Save conflict state so --continue can resume the cherry-pick + stateFile.Phase = PhaseConflict + stateFile.ConflictBranch = foldBranch + stateFile.ConflictType = "cherry_pick" + stateFile.FoldBranch = foldBranch + stateFile.FoldTarget = targetBranch + stateFile.RemainingBranches = remaining + stateFile.OriginalBranch = currentBranch + stateFile.OriginalRefs = originalParentTips + if saveErr := SaveState(gitDir, stateFile); saveErr != nil { + cfg.Warningf("failed to save conflict state: %v", saveErr) + } + + // Save stack metadata so far + if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil { + cfg.Warningf("failed to save stack metadata: %v", saveErr) + } + + return nil, conflict, fmt.Errorf("cherry-pick conflict folding %s into %s", foldBranch, targetBranch) + } + + cfg.Successf("Folded %s into %s (%d commits)", foldBranch, targetBranch, len(commits)) + } + } else { + // Fold-up: the target (above) already has the folded branch's + // commits in its history. We adjust originalParentTips so the + // cascading rebase uses the folded branch's BASE as the cutoff, + // replaying both the folded branch's commits and the target's + // own commits onto the new parent. + originalParentTips[targetBranch] = originalParentTips[foldBranch] + cfg.Successf("Folded %s into %s", foldBranch, targetBranch) + } + + // Remove folded branch from stack metadata + foldIdx = s.IndexOf(foldBranch) // re-resolve in case earlier folds shifted indices + if foldIdx >= 0 && foldIdx < len(s.Branches) { + s.Branches = append(s.Branches[:foldIdx], s.Branches[foldIdx+1:]...) + } + } + + // Step 3: Drops — remove from stack metadata + // Process in reverse order to preserve indices + for i := len(nodes) - 1; i >= 0; i-- { + n := nodes[i] + if n.PendingAction == nil || n.PendingAction.Type != modifyview.ActionDrop { + continue + } + + dropBranch := n.Ref.Branch + dropIdx := s.IndexOf(dropBranch) + if dropIdx < 0 { + continue + } + + if n.Ref.PullRequest != nil && n.Ref.PullRequest.Number > 0 { + result.DroppedPRs = append(result.DroppedPRs, modifyview.DroppedPR{ + Branch: dropBranch, + PRNumber: n.Ref.PullRequest.Number, + }) + } + + s.Branches = append(s.Branches[:dropIdx], s.Branches[dropIdx+1:]...) + cfg.Successf("Dropped %s from stack", dropBranch) + } + + // Step 4: Reorder — build the desired branch order from the remaining nodes + desiredOrder := make([]string, 0) + for _, n := range nodes { + if n.Removed { + continue + } + if n.PendingAction != nil && (n.PendingAction.Type == modifyview.ActionDrop || + n.PendingAction.Type == modifyview.ActionFoldDown || + n.PendingAction.Type == modifyview.ActionFoldUp) { + continue + } + if n.Ref.IsMerged() { + continue // Merged branches keep their position + } + desiredOrder = append(desiredOrder, n.Ref.Branch) + } + + // Check if reorder is needed by comparing with current stack order + currentOrder := make([]string, 0) + for _, b := range s.Branches { + if !b.IsMerged() { + currentOrder = append(currentOrder, b.Branch) + } + } + + needsReorder := false + if len(desiredOrder) == len(currentOrder) { + for i := range desiredOrder { + if desiredOrder[i] != currentOrder[i] { + needsReorder = true + break + } + } + } else { + needsReorder = true + } + + // Rebuild s.Branches in the desired order, preserving merged branches + // at their original positions. + if needsReorder { + // Build a queue of active branches in the desired order + desiredIdx := 0 + branchMap := make(map[string]stack.BranchRef) + for _, b := range s.Branches { + branchMap[b.Branch] = b + } + + newBranches := make([]stack.BranchRef, 0, len(s.Branches)) + for _, b := range s.Branches { + if b.IsMerged() { + // Merged branches stay at their original position + newBranches = append(newBranches, b) + } else { + // Substitute the next active branch from the desired order + if desiredIdx < len(desiredOrder) { + if sub, ok := branchMap[desiredOrder[desiredIdx]]; ok { + newBranches = append(newBranches, sub) + } + desiredIdx++ + } + } + } + + s.Branches = newBranches + } + + // Step 5: Cascading rebase — rebase each active branch onto its new parent. + // Use the original parent tip SHA as the oldBase for --onto, so that only + // the branch's own commits are replayed onto the new parent. + for i, b := range s.Branches { + if b.IsMerged() { + continue + } + + var newBase string + if i == 0 { + newBase = s.Trunk.Branch + } else { + newBase = s.ActiveBaseBranch(b.Branch) + } + + // Use the branch's original parent tip as the oldBase for --onto. + // This ensures we replay only this branch's unique commits. + oldBase, hasOldBase := originalParentTips[b.Branch] + if !hasOldBase { + // No original parent recorded — try merge-base as fallback + if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil { + oldBase = mb + } else { + continue + } + } + + // Check if rebase is actually needed + isAnc, ancErr := git.IsAncestor(newBase, b.Branch) + if ancErr == nil && isAnc { + if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil && mb == oldBase { + continue // No rebase needed + } + } + + if err := git.RebaseOnto(newBase, oldBase, b.Branch); err != nil { + conflict := &modifyview.ConflictInfo{ + Branch: b.Branch, + } + if files, ferr := git.ConflictedFiles(); ferr == nil { + conflict.ConflictedFiles = files + } + + // Save conflict state so --continue can resume + remaining := make([]string, 0) + for j := i + 1; j < len(s.Branches); j++ { + if !s.Branches[j].IsMerged() { + remaining = append(remaining, s.Branches[j].Branch) + } + } + stateFile.Phase = PhaseConflict + stateFile.ConflictBranch = b.Branch + stateFile.ConflictType = "rebase" + stateFile.RemainingBranches = remaining + stateFile.OriginalBranch = currentBranch + stateFile.OriginalRefs = originalParentTips + if saveErr := SaveState(gitDir, stateFile); saveErr != nil { + cfg.Warningf("failed to save conflict state: %v", saveErr) + } + + // Save stack metadata so far (renames, folds, drops already applied) + if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil { + cfg.Warningf("failed to save stack metadata: %v", saveErr) + } + + return nil, conflict, fmt.Errorf("rebase conflict on %s", b.Branch) + } + + cfg.Successf("Rebased %s onto %s", b.Branch, newBase) + result.MovedBranches++ + } + + // Restore original branch + _ = git.CheckoutBranch(currentBranch) + + // Update base SHAs + updateBaseSHAs(s) + + // Update state file phase + if s.ID != "" { + stateFile.Phase = PhasePendingSubmit + if err := SaveState(gitDir, stateFile); err != nil { + cfg.Warningf("failed to update modify state: %s", err) + } + } else { + // No remote stack — clear the state file + ClearState(gitDir) + } + + // Save stack metadata — this must succeed since git refs have been rewritten + if err := stack.SaveWithLock(gitDir, sf, lock); err != nil { + return nil, nil, fmt.Errorf("saving stack metadata: %w", err) + } + + return result, nil, nil +} + +// ContinueApply resumes a modify operation after the user resolves a rebase conflict. +// It finishes the in-progress git rebase, then continues the cascading rebase for +// remaining branches stored in the state file. +func ContinueApply( + cfg *config.Config, + gitDir string, + updateBaseSHAs func(*stack.Stack), +) error { + state, err := LoadState(gitDir) + if err != nil { + return fmt.Errorf("loading modify state: %w", err) + } + if state == nil { + return fmt.Errorf("no modify state file found") + } + if state.Phase != PhaseConflict { + return fmt.Errorf("no modify conflict in progress (phase: %s)", state.Phase) + } + + sf, err := stack.Load(gitDir) + if err != nil { + return fmt.Errorf("loading stack: %w", err) + } + + // Acquire lock for the duration of the operation + lock, err := stack.Lock(gitDir) + if err != nil { + return fmt.Errorf("acquiring stack lock: %w", err) + } + defer lock.Unlock() + + // Find the stack using the saved index for reliable identification. + var s *stack.Stack + if state.StackIndex >= 0 && state.StackIndex < len(sf.Stacks) { + s = &sf.Stacks[state.StackIndex] + } + if s == nil { + return fmt.Errorf("stack at index %d not found (stack file may have changed)", state.StackIndex) + } + + // Finish the in-progress git operation (rebase or cherry-pick) + if state.ConflictType == "cherry_pick" { + if err := git.CherryPickContinue(); err != nil { + return fmt.Errorf("cherry-pick continue failed — resolve remaining conflicts and try again: %w", err) + } + cfg.Successf("Folded %s into %s", state.FoldBranch, state.FoldTarget) + + // Remove the folded branch from stack metadata + foldIdx := s.IndexOf(state.FoldBranch) + if foldIdx >= 0 && foldIdx < len(s.Branches) { + s.Branches = append(s.Branches[:foldIdx], s.Branches[foldIdx+1:]...) + } + } else { + // Rebase conflict + if git.IsRebaseInProgress() { + if err := git.RebaseContinue(); err != nil { + return fmt.Errorf("rebase continue failed — resolve remaining conflicts and try again: %w", err) + } + } + cfg.Successf("Rebased %s", state.ConflictBranch) + } + + // Continue cascading rebase for remaining branches + for _, branchName := range state.RemainingBranches { + idx := s.IndexOf(branchName) + if idx < 0 { + cfg.Warningf("branch %s no longer in stack, skipping", branchName) + continue + } + b := s.Branches[idx] + if b.IsMerged() { + continue + } + + var newBase string + if idx == 0 { + newBase = s.Trunk.Branch + } else { + newBase = s.ActiveBaseBranch(b.Branch) + } + + // Use original parent tip or merge-base as oldBase + oldBase := "" + if state.OriginalRefs != nil { + oldBase = state.OriginalRefs[b.Branch] + } + if oldBase == "" { + if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil { + oldBase = mb + } else { + continue + } + } + + // Check if rebase is needed + isAnc, ancErr := git.IsAncestor(newBase, b.Branch) + if ancErr == nil && isAnc { + if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil && mb == oldBase { + continue + } + } + + if err := git.RebaseOnto(newBase, oldBase, b.Branch); err != nil { + // Another conflict — update state and bail + remaining := make([]string, 0) + foundCurrent := false + for _, rn := range state.RemainingBranches { + if rn == branchName { + foundCurrent = true + continue + } + if foundCurrent { + remaining = append(remaining, rn) + } + } + state.ConflictBranch = branchName + state.RemainingBranches = remaining + _ = SaveState(gitDir, state) + + cfg.Warningf("Conflict rebasing %s", branchName) + if files, ferr := git.ConflictedFiles(); ferr == nil { + for _, f := range files { + cfg.Printf(" %s", f) + } + } + cfg.Printf("") + cfg.Printf("Resolve the conflicts, stage with `%s`, then run `%s`", + cfg.ColorCyan("git add "), + cfg.ColorCyan("gh stack modify --continue")) + cfg.Printf("Or restore the stack with `%s`", + cfg.ColorCyan("gh stack modify --abort")) + return fmt.Errorf("rebase conflict on %s", branchName) + } + + cfg.Successf("Rebased %s onto %s", branchName, newBase) + } + + // All rebases done — restore original branch + if state.OriginalBranch != "" { + _ = git.CheckoutBranch(state.OriginalBranch) + } + + // Update base SHAs + updateBaseSHAs(s) + + // Transition to pending_submit or clear + if s.ID != "" { + state.Phase = PhasePendingSubmit + state.ConflictBranch = "" + state.RemainingBranches = nil + state.OriginalRefs = nil + if err := SaveState(gitDir, state); err != nil { + cfg.Warningf("failed to update modify state: %s", err) + } + } else { + ClearState(gitDir) + } + + // Save stack metadata + if err := stack.SaveWithLock(gitDir, sf, lock); err != nil { + cfg.Warningf("failed to save stack: %v", err) + } + + cfg.Successf("Stack modified successfully") + if state.PriorRemoteStackID != "" { + cfg.Printf("") + cfg.Printf("Run `%s` to push your changes and update the stack of PRs on GitHub", + cfg.ColorCyan("gh stack submit")) + } + return nil +} + +// Unwind restores the stack to its pre-modify state using the snapshot. +// stackIndex is the index of the stack in sf.Stacks at modify start time. +func Unwind(cfg *config.Config, gitDir string, snapshot Snapshot, stackIndex int, sf *stack.StackFile, plan []Action) error { + // Abort any in-progress rebase + if git.IsRebaseInProgress() { + _ = git.RebaseAbort() + } + + // Restore branch tips + snapshotNames := make(map[string]bool, len(snapshot.Branches)) + for _, bs := range snapshot.Branches { + snapshotNames[bs.Name] = true + if !git.BranchExists(bs.Name) { + // Branch was renamed — try to find it by SHA and recreate + if err := git.CreateBranch(bs.Name, bs.TipSHA); err != nil { + cfg.Warningf("failed to restore branch %s: %v", bs.Name, err) + continue + } + } else { + if err := git.CheckoutBranch(bs.Name); err != nil { + cfg.Warningf("failed to checkout %s for unwind: %v", bs.Name, err) + continue + } + if err := git.ResetHard(bs.TipSHA); err != nil { + cfg.Warningf("failed to reset %s to %s: %v", bs.Name, bs.TipSHA[:7], err) + continue + } + } + } + + // Clean up branches created by renames during the partial apply + for _, action := range plan { + if action.Type == "rename" && action.NewName != "" { + if !snapshotNames[action.NewName] && git.BranchExists(action.NewName) { + _ = git.DeleteBranch(action.NewName, true) + } + } + } + + // Restore stack metadata from snapshot + var restoredStack stack.Stack + if err := json.Unmarshal(snapshot.StackMetadata, &restoredStack); err != nil { + return fmt.Errorf("restoring stack metadata: %w", err) + } + + // Replace the stack at the saved index + if stackIndex >= 0 && stackIndex < len(sf.Stacks) { + sf.Stacks[stackIndex] = restoredStack + } + + // Save restored stack + if err := stack.Save(gitDir, sf); err != nil { + cfg.Warningf("failed to save restored stack: %v", err) + } + + // Clear state file + ClearState(gitDir) + + // Checkout the first snapshot branch + if len(snapshot.Branches) > 0 { + _ = git.CheckoutBranch(snapshot.Branches[0].Name) + } + + cfg.Successf("Stack restored to pre-modify state") + return nil +} + +// UnwindFromStateFile restores the stack from a modify state file (for --abort). +func UnwindFromStateFile(cfg *config.Config, gitDir string) error { + state, err := LoadState(gitDir) + if err != nil { + return fmt.Errorf("loading modify state: %w", err) + } + if state == nil { + return fmt.Errorf("no modify state file found") + } + + sf, err := stack.Load(gitDir) + if err != nil { + return fmt.Errorf("loading stack: %w", err) + } + + return Unwind(cfg, gitDir, state.Snapshot, state.StackIndex, sf, state.Plan) +} diff --git a/internal/modify/apply_test.go b/internal/modify/apply_test.go new file mode 100644 index 0000000..37699ce --- /dev/null +++ b/internal/modify/apply_test.go @@ -0,0 +1,1346 @@ +package modify + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/modifyview" + "github.com/github/gh-stack/internal/tui/stackview" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rebaseCall records arguments passed to RebaseOnto. +type rebaseCall struct { + newBase string + oldBase string + branch string +} + +// writeTestStackFile writes a stack file to disk and returns the loaded StackFile +// (with correct checksum for later Save calls). +func writeTestStackFile(t *testing.T, dir string, s stack.Stack) *stack.StackFile { + t.Helper() + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{s}, + } + data, err := json.MarshalIndent(sf, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644)) + // Reload so the StackFile has the correct loadChecksum for Save. + loaded, err := stack.Load(dir) + require.NoError(t, err) + return loaded +} + +// newApplyMock creates a MockOps pre-configured for apply tests. +func newApplyMock(gitDir string, branchSHAs map[string]string) *git.MockOps { + return &git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return true }, + RevParseFn: func(ref string) (string, error) { + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "sha-" + ref, nil + }, + IsAncestorFn: func(a, d string) (bool, error) { return false, nil }, + MergeBaseFn: func(a, b string) (string, error) { return "merge-base", nil }, + CheckoutBranchFn: func(string) error { return nil }, + RebaseOntoFn: func(string, string, string) error { return nil }, + IsRebaseInProgressFn: func() bool { return false }, + RenameBranchFn: func(string, string) error { return nil }, + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{{SHA: "commit-1"}, {SHA: "commit-2"}}, nil + }, + CherryPickFn: func([]string) error { return nil }, + ConflictedFilesFn: func() ([]string, error) { return nil, nil }, + ResetHardFn: func(string) error { return nil }, + CreateBranchFn: func(string, string) error { return nil }, + RebaseAbortFn: func() error { return nil }, + } +} + +// makeNodes creates ModifyBranchNodes from a stack for testing. +func makeNodes(s *stack.Stack) []modifyview.ModifyBranchNode { + nodes := make([]modifyview.ModifyBranchNode, len(s.Branches)) + for i, b := range s.Branches { + nodes[i] = modifyview.ModifyBranchNode{ + BranchNode: stackview.BranchNode{ + Ref: b, + }, + OriginalPosition: i, + } + } + return nodes +} + +func noopUpdateBaseSHAs(s *stack.Stack) {} + +// ─── BuildSnapshot ─────────────────────────────────────────────────────────── + +func TestBuildSnapshot(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + branchSHAs := map[string]string{ + "A": "sha-aaa", + "B": "sha-bbb", + } + mock := &git.MockOps{ + RevParseFn: func(ref string) (string, error) { + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "sha-" + ref, nil + }, + } + restore := git.SetOps(mock) + defer restore() + + snap, err := BuildSnapshot(&s) + require.NoError(t, err) + require.Len(t, snap.Branches, 2) + + assert.Equal(t, "A", snap.Branches[0].Name) + assert.Equal(t, "sha-aaa", snap.Branches[0].TipSHA) + assert.Equal(t, 0, snap.Branches[0].Position) + + assert.Equal(t, "B", snap.Branches[1].Name) + assert.Equal(t, "sha-bbb", snap.Branches[1].TipSHA) + assert.Equal(t, 1, snap.Branches[1].Position) + + // Verify stack metadata round-trips through JSON + var restored stack.Stack + require.NoError(t, json.Unmarshal(snap.StackMetadata, &restored)) + assert.Equal(t, "main", restored.Trunk.Branch) + assert.Equal(t, "A", restored.Branches[0].Branch) + assert.Equal(t, "B", restored.Branches[1].Branch) +} + +// ─── BuildPlan ─────────────────────────────────────────────────────────────── + +func TestBuildPlan_VariousActions(t *testing.T) { + t.Run("no changes produces empty plan", func(t *testing.T) { + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}}, + OriginalPosition: 0, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "B"}}, + OriginalPosition: 1, + }, + } + plan := BuildPlan(nodes) + assert.Empty(t, plan) + }) + + t.Run("rename produces rename action", func(t *testing.T) { + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}}, + OriginalPosition: 0, + PendingAction: &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-A"}, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "B"}}, + OriginalPosition: 1, + }, + } + plan := BuildPlan(nodes) + require.Len(t, plan, 1) + assert.Equal(t, "rename", plan[0].Type) + assert.Equal(t, "A", plan[0].Branch) + assert.Equal(t, "new-A", plan[0].NewName) + }) + + t.Run("move produces move action", func(t *testing.T) { + // Original order: A(0), B(1), C(2). Desired: A(0), C(1), B(2) + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}}, + OriginalPosition: 0, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "C"}}, + OriginalPosition: 2, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "B"}}, + OriginalPosition: 1, + }, + } + plan := BuildPlan(nodes) + // C moved from 2→1, B moved from 1→2 + require.Len(t, plan, 2) + assert.Equal(t, "move", plan[0].Type) + assert.Equal(t, "C", plan[0].Branch) + assert.Equal(t, 1, plan[0].NewPosition) + assert.Equal(t, "move", plan[1].Type) + assert.Equal(t, "B", plan[1].Branch) + assert.Equal(t, 2, plan[1].NewPosition) + }) + + t.Run("removed nodes with drop action not in plan directly", func(t *testing.T) { + // BuildPlan skips Removed nodes — the drop is recorded by the non-removed + // logic. But nodes with PendingAction and NOT Removed do get recorded. + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}}, + OriginalPosition: 0, + Removed: true, + PendingAction: &modifyview.PendingAction{Type: modifyview.ActionDrop}, + }, + } + plan := BuildPlan(nodes) + // Removed == true, so it's skipped in BuildPlan + assert.Empty(t, plan) + }) +} + +// ─── ApplyPlan: Drop ───────────────────────────────────────────────────────── + +func TestApplyPlan_Drop(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B", PullRequest: &stack.PullRequestRef{Number: 42}}, + {Branch: "C"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + "C": "sha-C", + } + + var rebaseCalls []rebaseCall + mock := newApplyMock(gitDir, branchSHAs) + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // Build nodes: Drop B + nodes := makeNodes(&sf.Stacks[0]) + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[1].Removed = true + + result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Nil(t, conflict) + require.NotNil(t, result) + + // B should be removed from stack + assert.Equal(t, 2, len(sf.Stacks[0].Branches)) + assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch) + assert.Equal(t, "C", sf.Stacks[0].Branches[1].Branch) + + // B's PR should be in DroppedPRs + require.Len(t, result.DroppedPRs, 1) + assert.Equal(t, "B", result.DroppedPRs[0].Branch) + assert.Equal(t, 42, result.DroppedPRs[0].PRNumber) + + // C should be rebased onto A (B's parent), with B's old tip as oldBase + var cRebase *rebaseCall + for _, rc := range rebaseCalls { + if rc.branch == "C" { + cRebase = &rc + break + } + } + require.NotNil(t, cRebase, "C should be rebased") + assert.Equal(t, "A", cRebase.newBase) +} + +// ─── ApplyPlan: FoldDown ───────────────────────────────────────────────────── + +func TestApplyPlan_FoldDown(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + } + + var cherryPickCalls [][]string + var checkoutCalls []string + + mock := newApplyMock(gitDir, branchSHAs) + mock.CheckoutBranchFn = func(name string) error { + checkoutCalls = append(checkoutCalls, name) + return nil + } + mock.CherryPickFn = func(shas []string) error { + cherryPickCalls = append(cherryPickCalls, shas) + return nil + } + mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) { + if base == "A" && head == "B" { + return []git.CommitInfo{ + {SHA: "commit-b2"}, + {SHA: "commit-b1"}, + }, nil + } + return nil, nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + nodes := makeNodes(&sf.Stacks[0]) + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown} + nodes[1].Removed = true + + result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Nil(t, conflict) + require.NotNil(t, result) + + // CheckoutBranch should be called with "A" (the target below) + assert.Contains(t, checkoutCalls, "A") + + // CherryPick should be called with B's commit SHAs (reversed for chronological order) + require.Len(t, cherryPickCalls, 1) + assert.Equal(t, []string{"commit-b1", "commit-b2"}, cherryPickCalls[0]) + + // B should be removed from stack + assert.Equal(t, 1, len(sf.Stacks[0].Branches)) + assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch) +} + +// ─── ApplyPlan: FoldUp ─────────────────────────────────────────────────────── + +func TestApplyPlan_FoldUp(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + {Branch: "C"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + "C": "sha-C", + } + + var cherryPickCalls [][]string + var rebaseCalls []rebaseCall + + mock := newApplyMock(gitDir, branchSHAs) + mock.CherryPickFn = func(shas []string) error { + cherryPickCalls = append(cherryPickCalls, shas) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + nodes := makeNodes(&sf.Stacks[0]) + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldUp} + nodes[1].Removed = true + + result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Nil(t, conflict) + require.NotNil(t, result) + + // Fold-up should NOT call CherryPick + assert.Empty(t, cherryPickCalls, "fold-up should not cherry-pick") + + // B should be removed from stack + assert.Equal(t, 2, len(sf.Stacks[0].Branches)) + assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch) + assert.Equal(t, "C", sf.Stacks[0].Branches[1].Branch) + + // C's rebase should use B's base (A's tip) as oldBase, not B's tip. + // The fold-up adjusts originalParentTips[C] = originalParentTips[B] = sha-A + var cRebase *rebaseCall + for _, rc := range rebaseCalls { + if rc.branch == "C" { + cRebase = &rc + break + } + } + require.NotNil(t, cRebase, "C should be rebased") + assert.Equal(t, "A", cRebase.newBase, "C should rebase onto A (B's parent)") + assert.Equal(t, "sha-A", cRebase.oldBase, "C should use A's tip (B's original parent tip) as oldBase") +} + +// ─── ApplyPlan: Rename ─────────────────────────────────────────────────────── + +func TestApplyPlan_Rename(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + } + + var renameCalls []struct{ oldName, newName string } + + mock := newApplyMock(gitDir, branchSHAs) + mock.RenameBranchFn = func(old, new string) error { + renameCalls = append(renameCalls, struct{ oldName, newName string }{old, new}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + nodes := makeNodes(&sf.Stacks[0]) + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-A"} + + result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Nil(t, conflict) + require.NotNil(t, result) + + // RenameBranch called with correct args + require.Len(t, renameCalls, 1) + assert.Equal(t, "A", renameCalls[0].oldName) + assert.Equal(t, "new-A", renameCalls[0].newName) + + // In-memory branch name updated + assert.Equal(t, "new-A", sf.Stacks[0].Branches[0].Branch) + + // Result tracks rename + require.Len(t, result.RenamedBranches, 1) + assert.Equal(t, "A", result.RenamedBranches[0].OldName) + assert.Equal(t, "new-A", result.RenamedBranches[0].NewName) +} + +// ─── ApplyPlan: Reorder ────────────────────────────────────────────────────── + +func TestApplyPlan_Reorder(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + {Branch: "C"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + "C": "sha-C", + } + + var rebaseCalls []rebaseCall + + mock := newApplyMock(gitDir, branchSHAs) + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // Desired order: A, C, B (move C between A and B) + nodes := []modifyview.ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: sf.Stacks[0].Branches[0]}, // A + OriginalPosition: 0, + }, + { + BranchNode: stackview.BranchNode{Ref: sf.Stacks[0].Branches[2]}, // C + OriginalPosition: 2, + }, + { + BranchNode: stackview.BranchNode{Ref: sf.Stacks[0].Branches[1]}, // B + OriginalPosition: 1, + }, + } + + result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Nil(t, conflict) + require.NotNil(t, result) + + // Verify stack order is now A, C, B + require.Len(t, sf.Stacks[0].Branches, 3) + assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch) + assert.Equal(t, "C", sf.Stacks[0].Branches[1].Branch) + assert.Equal(t, "B", sf.Stacks[0].Branches[2].Branch) + + // Both C and B should be rebased onto their new parents + rebaseMap := make(map[string]rebaseCall) + for _, rc := range rebaseCalls { + rebaseMap[rc.branch] = rc + } + + if cCall, ok := rebaseMap["C"]; ok { + assert.Equal(t, "A", cCall.newBase, "C should be rebased onto A") + } + if bCall, ok := rebaseMap["B"]; ok { + assert.Equal(t, "C", bCall.newBase, "B should be rebased onto C") + } +} + +// ─── ApplyPlan: Mixed Drop and Fold ───────────────────────────────────────── + +func TestApplyPlan_MixedDropAndFold(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + {Branch: "C"}, + {Branch: "D"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + "C": "sha-C", + "D": "sha-D", + } + + var cherryPickCalls [][]string + var checkoutCalls []string + var rebaseCalls []rebaseCall + + mock := newApplyMock(gitDir, branchSHAs) + mock.CheckoutBranchFn = func(name string) error { + checkoutCalls = append(checkoutCalls, name) + return nil + } + mock.CherryPickFn = func(shas []string) error { + cherryPickCalls = append(cherryPickCalls, shas) + return nil + } + mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) { + if head == "C" { + return []git.CommitInfo{{SHA: "c-commit-1"}}, nil + } + return nil, nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // Drop B, fold C down into A + nodes := makeNodes(&sf.Stacks[0]) + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[1].Removed = true + nodes[2].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown} + nodes[2].Removed = true + + result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Nil(t, conflict) + require.NotNil(t, result) + + // B and C should be removed, leaving A and D + branchNames := make([]string, len(sf.Stacks[0].Branches)) + for i, b := range sf.Stacks[0].Branches { + branchNames[i] = b.Branch + } + assert.Equal(t, []string{"A", "D"}, branchNames) + + // C's commits should have been cherry-picked onto A + require.Len(t, cherryPickCalls, 1) + + // D should be rebased onto A + var dRebase *rebaseCall + for _, rc := range rebaseCalls { + if rc.branch == "D" { + dRebase = &rc + break + } + } + require.NotNil(t, dRebase, "D should be rebased") + assert.Equal(t, "A", dRebase.newBase, "D should be rebased onto A") +} + +// ─── ApplyPlan: Conflict During Rebase ─────────────────────────────────────── + +func TestApplyPlan_ConflictDuringRebase(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + } + + mock := newApplyMock(gitDir, branchSHAs) + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + if branch == "B" { + return assert.AnError + } + return nil + } + mock.ConflictedFilesFn = func() ([]string, error) { + return []string{"file.go"}, nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // Drop A so B must rebase onto main + nodes := makeNodes(&sf.Stacks[0]) + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[0].Removed = true + + _, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + assert.Error(t, err) + require.NotNil(t, conflict) + assert.Equal(t, "B", conflict.Branch) + assert.Contains(t, conflict.ConflictedFiles, "file.go") + + // Verify state file written with phase "conflict" + state, loadErr := LoadState(gitDir) + require.NoError(t, loadErr) + require.NotNil(t, state) + assert.Equal(t, "conflict", state.Phase) + assert.Equal(t, "B", state.ConflictBranch) +} + +// ─── ApplyPlan: Conflict During CherryPick ─────────────────────────────────── + +func TestApplyPlan_ConflictDuringCherryPick(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + } + + mock := newApplyMock(gitDir, branchSHAs) + mock.CherryPickFn = func(shas []string) error { + return assert.AnError + } + mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{{SHA: "commit-1"}}, nil + } + mock.ConflictedFilesFn = func() ([]string, error) { + return []string{"conflict.go"}, nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // Fold B down into A + nodes := makeNodes(&sf.Stacks[0]) + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionFoldDown} + nodes[1].Removed = true + + _, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + assert.Error(t, err) + require.NotNil(t, conflict) + assert.Equal(t, "B", conflict.Branch) + + // Cherry-pick conflicts now save state for --continue recovery + state, loadErr := LoadState(gitDir) + require.NoError(t, loadErr) + require.NotNil(t, state) + assert.Equal(t, PhaseConflict, state.Phase) + assert.Equal(t, "cherry_pick", state.ConflictType) + assert.Equal(t, "B", state.FoldBranch) + assert.Equal(t, "A", state.FoldTarget) + assert.Equal(t, "A", state.OriginalBranch) + assert.Contains(t, state.RemainingBranches, "A") +} + +// ─── ContinueApply: Multi-Stack Finds Correct Stack ───────────────────────── + +func TestContinueApply_MultiStackFindsCorrectStack(t *testing.T) { + // When multiple stacks share the same trunk, ContinueApply should use + // StackIndex to find the right stack, not just trunk name matching. + gitDir := t.TempDir() + + // Stack 0: main <- X (a different stack) + // Stack 1: main <- A <- B <- C (the one being modified) + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "X"}}, + }, + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + {Branch: "C"}, + }, + }, + }, + } + data, err := json.MarshalIndent(sf, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(gitDir, "gh-stack"), data, 0644)) + + // Create a state file pointing at Stack 1 (index 1) + state := &StateFile{ + SchemaVersion: 1, + StackName: "main", + StackIndex: 1, // The correct stack is at index 1 + Phase: PhaseConflict, + ConflictBranch: "A", + ConflictType: "rebase", + RemainingBranches: []string{"B", "C"}, + OriginalRefs: map[string]string{"B": "sha-A", "C": "sha-B"}, + } + require.NoError(t, SaveState(gitDir, state)) + + mock := newApplyMock(gitDir, map[string]string{ + "main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C", + }) + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseContinueFn = func() error { return nil } + + var rebasedBranches []string + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebasedBranches = append(rebasedBranches, branch) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err = ContinueApply(cfg, gitDir, noopUpdateBaseSHAs) + require.NoError(t, err) + + // B and C should have been found and processed (not "no longer in stack") + assert.Contains(t, rebasedBranches, "B", "B should be rebased") + assert.Contains(t, rebasedBranches, "C", "C should be rebased") +} + +// ─── Unwind ────────────────────────────────────────────────────────────────── + +func TestUnwind(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + // Build a snapshot of the original state + branchSHAs := map[string]string{ + "A": "sha-A-original", + "B": "sha-B-original", + } + + snapshotMock := &git.MockOps{ + RevParseFn: func(ref string) (string, error) { + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "sha-" + ref, nil + }, + } + restore := git.SetOps(snapshotMock) + snapshot, err := BuildSnapshot(&s) + require.NoError(t, err) + restore() + + // Save a state file + stateFile := &StateFile{ + SchemaVersion: 1, + StackName: "main", + StackIndex: 0, + Phase: "applying", + Snapshot: snapshot, + } + require.NoError(t, SaveState(gitDir, stateFile)) + + // Simulate partial apply: modify the stack + sf.Stacks[0].Branches = []stack.BranchRef{{Branch: "A"}} // B was removed + + var resetCalls []struct{ branch, sha string } + var checkoutCalls []string + currentBranch := "A" + + mock := &git.MockOps{ + IsRebaseInProgressFn: func() bool { return false }, + BranchExistsFn: func(name string) bool { return true }, + CheckoutBranchFn: func(name string) error { + checkoutCalls = append(checkoutCalls, name) + currentBranch = name + return nil + }, + ResetHardFn: func(ref string) error { + resetCalls = append(resetCalls, struct{ branch, sha string }{currentBranch, ref}) + return nil + }, + CreateBranchFn: func(name, base string) error { return nil }, + } + + restore = git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err = Unwind(cfg, gitDir, snapshot, 0, sf, nil) + require.NoError(t, err) + + // ResetHard should be called for each branch with snapshot SHAs + resetMap := make(map[string]string) + for _, r := range resetCalls { + resetMap[r.branch] = r.sha + } + assert.Equal(t, "sha-A-original", resetMap["A"]) + assert.Equal(t, "sha-B-original", resetMap["B"]) + + // Stack should be restored to original (2 branches) + assert.Equal(t, 2, len(sf.Stacks[0].Branches)) + assert.Equal(t, "A", sf.Stacks[0].Branches[0].Branch) + assert.Equal(t, "B", sf.Stacks[0].Branches[1].Branch) + + // State file should be cleared + assert.False(t, StateExists(gitDir)) +} + +// ─── ApplyPlan: No-op (empty plan) ────────────────────────────────────────── + +func TestApplyPlan_NoChanges(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + } + + mock := newApplyMock(gitDir, branchSHAs) + // Make IsAncestor return true and MergeBase match oldBase to skip rebases + mock.IsAncestorFn = func(a, d string) (bool, error) { return true, nil } + mock.MergeBaseFn = func(a, b string) (string, error) { + // Return the parent tip SHA so the "no rebase needed" check passes + if a == "main" && b == "A" { + return branchSHAs["main"], nil + } + if a == "A" && b == "B" { + return branchSHAs["A"], nil + } + return "merge-base", nil + } + + var rebaseCalls int + mock.RebaseOntoFn = func(string, string, string) error { + rebaseCalls++ + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + nodes := makeNodes(&sf.Stacks[0]) + + result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Nil(t, conflict) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Equal(t, 0, rebaseCalls, "no rebase should be needed when nothing changed") +} + +// ─── ApplyPlan: Drop with no PR ───────────────────────────────────────────── + +func TestApplyPlan_DropNoPR(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + } + + mock := newApplyMock(gitDir, branchSHAs) + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + nodes := makeNodes(&sf.Stacks[0]) + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[0].Removed = true + + result, conflict, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs) + require.NoError(t, err) + assert.Nil(t, conflict) + require.NotNil(t, result) + + // No PR means no DroppedPRs entry + assert.Empty(t, result.DroppedPRs) + + // A should be removed + assert.Equal(t, 1, len(sf.Stacks[0].Branches)) + assert.Equal(t, "B", sf.Stacks[0].Branches[0].Branch) +} + +// ─── ContinueApply ────────────────────────────────────────────────────────── + +func TestContinueApply(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + {Branch: "C"}, + }, + } + + gitDir := t.TempDir() + _ = writeTestStackFile(t, gitDir, s) + + // Write a conflict state file + stateFile := &StateFile{ + SchemaVersion: 1, + StackName: "main", + StackIndex: 0, + Phase: "conflict", + ConflictBranch: "B", + RemainingBranches: []string{"C"}, + OriginalBranch: "A", + OriginalRefs: map[string]string{ + "A": "sha-A", + "B": "sha-B", + "C": "sha-C", + }, + } + require.NoError(t, SaveState(gitDir, stateFile)) + + var rebaseContinueCalled bool + var rebaseCalls []rebaseCall + var checkoutCalls []string + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "B", nil }, + BranchExistsFn: func(string) bool { return true }, + IsRebaseInProgressFn: func() bool { return true }, + RebaseContinueFn: func() error { + rebaseContinueCalled = true + return nil + }, + RebaseOntoFn: func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + }, + CheckoutBranchFn: func(name string) error { + checkoutCalls = append(checkoutCalls, name) + return nil + }, + IsAncestorFn: func(a, d string) (bool, error) { return false, nil }, + MergeBaseFn: func(a, b string) (string, error) { return "merge-base", nil }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs) + require.NoError(t, err) + + assert.True(t, rebaseContinueCalled, "RebaseContinue should be called") + + // C should be rebased + require.Len(t, rebaseCalls, 1) + assert.Equal(t, "C", rebaseCalls[0].branch) + assert.Equal(t, "B", rebaseCalls[0].newBase) + assert.Equal(t, "sha-C", rebaseCalls[0].oldBase) + + // Should checkout original branch + assert.Contains(t, checkoutCalls, "A") + + // State file should be cleared (no remote stack ID) + assert.False(t, StateExists(gitDir)) +} + +func TestContinueApply_NoStateFile(t *testing.T) { + gitDir := t.TempDir() + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no modify state file found") +} + +func TestContinueApply_WrongPhase(t *testing.T) { + gitDir := t.TempDir() + + stateFile := &StateFile{ + SchemaVersion: 1, + Phase: "applying", + } + require.NoError(t, SaveState(gitDir, stateFile)) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no modify conflict in progress") +} + +// ─── Unwind with active rebase ────────────────────────────────────────────── + +func TestUnwind_AbortsActiveRebase(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + snapshotMock := &git.MockOps{ + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + } + restore := git.SetOps(snapshotMock) + snapshot, err := BuildSnapshot(&s) + require.NoError(t, err) + restore() + + require.NoError(t, SaveState(gitDir, &StateFile{ + SchemaVersion: 1, Phase: "conflict", Snapshot: snapshot, + })) + + var rebaseAbortCalled bool + mock := &git.MockOps{ + IsRebaseInProgressFn: func() bool { return true }, + RebaseAbortFn: func() error { + rebaseAbortCalled = true + return nil + }, + BranchExistsFn: func(string) bool { return true }, + CheckoutBranchFn: func(string) error { return nil }, + ResetHardFn: func(string) error { return nil }, + CreateBranchFn: func(string, string) error { return nil }, + } + + restore = git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err = Unwind(cfg, gitDir, snapshot, 0, sf, nil) + require.NoError(t, err) + assert.True(t, rebaseAbortCalled, "RebaseAbort should be called when rebase is in progress") + assert.False(t, StateExists(gitDir)) +} + +// ─── Unwind restores renamed branch ───────────────────────────────────────── + +func TestUnwind_RestoresRenamedBranch(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + snapshotMock := &git.MockOps{ + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + } + restore := git.SetOps(snapshotMock) + snapshot, err := BuildSnapshot(&s) + require.NoError(t, err) + restore() + + // Simulate: A was renamed to new-A, so A no longer exists + var createdBranches []struct{ name, sha string } + mock := &git.MockOps{ + IsRebaseInProgressFn: func() bool { return false }, + BranchExistsFn: func(name string) bool { + return name != "A" // A was renamed away + }, + CreateBranchFn: func(name, sha string) error { + createdBranches = append(createdBranches, struct{ name, sha string }{name, sha}) + return nil + }, + CheckoutBranchFn: func(string) error { return nil }, + ResetHardFn: func(string) error { return nil }, + } + + restore = git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err = Unwind(cfg, gitDir, snapshot, 0, sf, nil) + require.NoError(t, err) + + // A should be recreated via CreateBranch + require.Len(t, createdBranches, 1) + assert.Equal(t, "A", createdBranches[0].name) + assert.Equal(t, "sha-A", createdBranches[0].sha) +} + +// ─── ApplyPlan: State file transitions for remote stack ───────────────────── + +func TestApplyPlan_PendingSubmitForRemoteStack(t *testing.T) { + s := stack.Stack{ + ID: "remote-stack-123", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + "B": "sha-B", + } + + mock := newApplyMock(gitDir, branchSHAs) + mock.IsAncestorFn = func(a, d string) (bool, error) { return true, nil } + mock.MergeBaseFn = func(a, b string) (string, error) { + if a == "main" && b == "A" { + return branchSHAs["main"], nil + } + if a == "A" && b == "B" { + return branchSHAs["A"], nil + } + return "merge-base", nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + nodes := makeNodes(&sf.Stacks[0]) + + _, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.NoError(t, err) + + // Remote stack should transition to "pending_submit" + state, loadErr := LoadState(gitDir) + require.NoError(t, loadErr) + require.NotNil(t, state) + assert.Equal(t, "pending_submit", state.Phase) +} + +func TestApplyPlan_ClearsStateForLocalStack(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + branchSHAs := map[string]string{ + "main": "sha-main", + "A": "sha-A", + } + + mock := newApplyMock(gitDir, branchSHAs) + mock.IsAncestorFn = func(a, d string) (bool, error) { return true, nil } + mock.MergeBaseFn = func(a, b string) (string, error) { + return branchSHAs["main"], nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + nodes := makeNodes(&sf.Stacks[0]) + + _, _, err := ApplyPlan(cfg, gitDir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.NoError(t, err) + + // Local stack (no ID) should clear the state file + assert.False(t, StateExists(gitDir)) +} diff --git a/internal/modify/preconditions.go b/internal/modify/preconditions.go new file mode 100644 index 0000000..40eabe7 --- /dev/null +++ b/internal/modify/preconditions.go @@ -0,0 +1,71 @@ +package modify + +import ( + "fmt" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" +) + +// CheckNoMergeQueuePRs checks that no unmerged PR in the stack is currently queued. +func CheckNoMergeQueuePRs(cfg *config.Config, s *stack.Stack) error { + for _, b := range s.Branches { + if b.IsQueued() && !b.IsMerged() { + prLink := "" + if b.PullRequest != nil { + prLink = cfg.PRLink(b.PullRequest.Number, b.PullRequest.URL) + } + cfg.Errorf("branch %s has a PR (%s) in the merge queue", b.Branch, prLink) + cfg.Printf("Wait for it to land or remove it from the queue before modifying the stack") + return fmt.Errorf("merge queue conflict on %s", b.Branch) + } + } + return nil +} + +// CheckStackLinearity verifies that the stack has unambiguous commit-to-branch mapping. +// For each adjacent pair (parent, child), checks: +// 1. parent tip is an ancestor of child tip +// 2. no merge commits exist in the range parent..child +func CheckStackLinearity(cfg *config.Config, s *stack.Stack) error { + for i, b := range s.Branches { + if b.IsMerged() { + continue + } + + var parentBranch string + if i == 0 { + parentBranch = s.Trunk.Branch + } else { + parentBranch = s.ActiveBaseBranch(b.Branch) + } + + isAnc, err := git.IsAncestor(parentBranch, b.Branch) + if err != nil { + cfg.Errorf("failed to check linearity for %s: %s", b.Branch, err) + return fmt.Errorf("linearity check failed for %s", b.Branch) + } + if !isAnc { + cfg.Errorf("%s has diverged from %s", b.Branch, parentBranch) + cfg.Printf("Run `%s` to normalize the stack, or `%s` to restructure manually", + cfg.ColorCyan("gh stack rebase"), + cfg.ColorCyan("gh stack unstack")) + return fmt.Errorf("%s has diverged from %s", b.Branch, parentBranch) + } + + merges, err := git.LogMerges(parentBranch, b.Branch) + if err != nil { + continue + } + if len(merges) > 0 { + cfg.Errorf("%s contains a merge commit — modify requires linear history", b.Branch) + cfg.Printf("Run `%s` to replay without the merge, or `%s` to restructure manually", + cfg.ColorCyan("gh stack rebase"), + cfg.ColorCyan("gh stack unstack")) + return fmt.Errorf("%s contains merge commits", b.Branch) + } + } + + return nil +} diff --git a/internal/modify/state.go b/internal/modify/state.go new file mode 100644 index 0000000..d4e243a --- /dev/null +++ b/internal/modify/state.go @@ -0,0 +1,138 @@ +package modify + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +const stateFileName = "gh-stack-modify-state" + +const ( + PhaseApplying = "applying" + PhaseConflict = "conflict" + PhasePendingSubmit = "pending_submit" +) + +// StateFile holds the state of an in-progress or pending-submit modify operation. +// It is stored at .git/gh-stack-modify-state. +type StateFile struct { + SchemaVersion int `json:"schema_version"` + StackName string `json:"stack_name"` + StackIndex int `json:"stack_index"` // index in StackFile.Stacks at modify start + StartedAt time.Time `json:"started_at"` + Phase string `json:"phase"` // "applying", "conflict", or "pending_submit" + PriorRemoteStackID string `json:"prior_remote_stack_id,omitempty"` + Snapshot Snapshot `json:"snapshot"` + Plan []Action `json:"plan"` + + // Conflict state — populated when phase is "conflict" + ConflictBranch string `json:"conflict_branch,omitempty"` + ConflictType string `json:"conflict_type,omitempty"` // "rebase" or "cherry_pick" + RemainingBranches []string `json:"remaining_branches,omitempty"` + OriginalBranch string `json:"original_branch,omitempty"` + OriginalRefs map[string]string `json:"original_refs,omitempty"` + + // Cherry-pick conflict context — which fold was in progress + FoldBranch string `json:"fold_branch,omitempty"` // branch being folded + FoldTarget string `json:"fold_target,omitempty"` // branch receiving the cherry-pick +} + +// Snapshot captures the pre-modify state for unwind/recovery. +type Snapshot struct { + Branches []BranchSnapshot `json:"branches"` + StackMetadata json.RawMessage `json:"stack_metadata"` +} + +// BranchSnapshot stores the state of a single branch before modification. +type BranchSnapshot struct { + Name string `json:"name"` + TipSHA string `json:"tip_sha"` + Position int `json:"position"` +} + +// Action represents a single staged action from the TUI. +type Action struct { + Type string `json:"type"` // "drop", "fold_down", "fold_up", "move", "rename" + Branch string `json:"branch"` + NewPosition int `json:"new_position,omitempty"` // for "move" + NewName string `json:"new_name,omitempty"` // for "rename" +} + +// StatePath returns the full path to the modify state file. +func StatePath(gitDir string) string { + return filepath.Join(gitDir, stateFileName) +} + +// LoadState reads the modify state file from the git directory. +// Returns nil, nil if the file does not exist. +func LoadState(gitDir string) (*StateFile, error) { + data, err := os.ReadFile(StatePath(gitDir)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("reading modify state: %w", err) + } + + var state StateFile + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("parsing modify state: %w", err) + } + return &state, nil +} + +// SaveState writes the modify state file atomically (write to temp, then rename). +func SaveState(gitDir string, state *StateFile) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("marshaling modify state: %w", err) + } + target := StatePath(gitDir) + tmp := target + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return fmt.Errorf("writing modify state: %w", err) + } + // Remove existing target before rename for Windows compatibility + // (os.Rename fails on Windows if the target already exists). + _ = os.Remove(target) + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("committing modify state: %w", err) + } + return nil +} + +// ClearState removes the modify state file. +func ClearState(gitDir string) { + _ = os.Remove(StatePath(gitDir)) +} + +// StateExists returns true if a modify state file exists. +func StateExists(gitDir string) bool { + _, err := os.Stat(StatePath(gitDir)) + return err == nil +} + +// CheckStateGuard checks if a modify state file exists with phase "applying" +// and returns an error if so. This is used as a guard at the top of commands that +// should not run while a modify is in progress. +func CheckStateGuard(gitDir string) error { + state, err := LoadState(gitDir) + if err != nil { + return nil // ignore read errors + } + if state == nil { + return nil + } + if state.Phase == PhaseApplying { + return fmt.Errorf("a modify session was interrupted — run `gh stack modify --abort` to restore your stack") + } + if state.Phase == PhaseConflict { + return fmt.Errorf("a modify has unresolved conflicts — run `gh stack modify --continue` or `gh stack modify --abort`") + } + return nil +} diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 5dd89ea..75ff71c 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -316,6 +316,16 @@ func Save(gitDir string, sf *StackFile) error { return writeStackFile(gitDir, sf) } +// SaveWithLock writes the stack file while the caller already holds the lock. +// The caller is responsible for acquiring and releasing the lock. +// Panics if lock is nil to catch programming errors. +func SaveWithLock(gitDir string, sf *StackFile, lock *FileLock) error { + if lock == nil { + panic("SaveWithLock called with nil lock") + } + return writeStackFile(gitDir, sf) +} + // SaveNonBlocking attempts to save without blocking. If another process holds // the lock or the file was modified since Load, the save is silently skipped. // Use this for best-effort metadata persistence (e.g. syncing PR state in view). diff --git a/internal/tui/modifyview/help.go b/internal/tui/modifyview/help.go new file mode 100644 index 0000000..ad25db3 --- /dev/null +++ b/internal/tui/modifyview/help.go @@ -0,0 +1,99 @@ +package modifyview + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// renderHelpOverlay renders a centered help overlay with a guide to modify operations. +func renderHelpOverlay(width, height int) string { + var b strings.Builder + + title := helpTitleStyle.Render("Modify Stack") + b.WriteString(title) + b.WriteString("\n") + b.WriteString(helpDescStyle.Render("Restructure your stack by dropping, folding, renaming, or reordering branches.")) + b.WriteString("\n") + + sections := []struct { + heading string + body string + }{ + { + "Drop (x)", + "Remove a branch and its commits from the stack.\nThe local branch is preserved; the PR stays open on GitHub.", + }, + { + "Fold up / down (u / d)", + "Merge a branch's commits into an adjacent branch.\nFold up absorbs into the branch above; fold down into the branch below.", + }, + { + "Rename (r)", + "Rename a branch locally. The new name is pushed on submit.", + }, + { + "Reorder (Shift+↑/↓)", + "Move a branch up or down in the stack.\nA cascading rebase adjusts all affected branches.", + }, + } + + for _, s := range sections { + b.WriteString("\n") + b.WriteString(helpKeyStyle.Render(s.heading)) + b.WriteString("\n") + for _, line := range strings.Split(s.body, "\n") { + b.WriteString(helpDescStyle.Render(line)) + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(helpKeyStyle.Render("Applying changes")) + b.WriteString("\n") + b.WriteString(helpDescStyle.Render("Press " + helpKeyStyle.Render("Ctrl+S") + " to apply all staged changes. Nothing is modified until you save.")) + b.WriteString("\n") + b.WriteString(helpDescStyle.Render("If you have open PRs, run ") + helpKeyStyle.Render("gh stack submit") + helpDescStyle.Render(" afterwards to push the updated")) + b.WriteString("\n") + b.WriteString(helpDescStyle.Render("branches and recreate the stack of PRs on GitHub.")) + + b.WriteString("\n\n") + b.WriteString(statusBarStyle.Render("Press ? or Esc to close")) + + content := b.String() + + // Apply the overlay style and center it + styled := helpOverlayStyle.Render(content) + + // Center vertically and horizontally + styledLines := strings.Split(styled, "\n") + styledHeight := len(styledLines) + styledWidth := 0 + for _, line := range styledLines { + w := lipgloss.Width(line) + if w > styledWidth { + styledWidth = w + } + } + + topPad := (height - styledHeight) / 2 + if topPad < 0 { + topPad = 0 + } + leftPad := (width - styledWidth) / 2 + if leftPad < 0 { + leftPad = 0 + } + + var result strings.Builder + for i := 0; i < topPad; i++ { + result.WriteString("\n") + } + for _, line := range styledLines { + result.WriteString(strings.Repeat(" ", leftPad)) + result.WriteString(line) + result.WriteString("\n") + } + + return result.String() +} diff --git a/internal/tui/modifyview/model.go b/internal/tui/modifyview/model.go new file mode 100644 index 0000000..75cf852 --- /dev/null +++ b/internal/tui/modifyview/model.go @@ -0,0 +1,1091 @@ +package modifyview + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/shared" +) + +// modifyKeyMap defines key bindings for the modify view. +type modifyKeyMap struct { + Up key.Binding + Down key.Binding + MoveUp key.Binding + MoveDown key.Binding + Drop key.Binding + FoldDown key.Binding + FoldUp key.Binding + Rename key.Binding + Undo key.Binding + ToggleCommits key.Binding + ToggleFiles key.Binding + Apply key.Binding + Help key.Binding + Quit key.Binding +} + +func (k modifyKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Drop, k.FoldDown, k.Rename, k.ToggleCommits, k.ToggleFiles, k.Apply, k.Help, k.Quit} +} + +func (k modifyKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} + +var modifyKeys = modifyKeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + MoveUp: key.NewBinding( + key.WithKeys("K", "shift+up"), + key.WithHelp("shift+↑", "move up"), + ), + MoveDown: key.NewBinding( + key.WithKeys("J", "shift+down"), + key.WithHelp("shift+↓", "move down"), + ), + Drop: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "drop"), + ), + FoldDown: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "fold down"), + ), + FoldUp: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "fold up"), + ), + Rename: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "rename"), + ), + Undo: key.NewBinding( + key.WithKeys("z"), + key.WithHelp("z", "undo"), + ), + ToggleCommits: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "commits"), + ), + ToggleFiles: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "files"), + ), + Apply: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "apply"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} + +// Model is the Bubble Tea model for the interactive modify view. +type Model struct { + nodes []ModifyBranchNode + trunk stack.BranchRef + version string + cursor int + width int + height int + scrollOffset int + + // Undo stack + actionStack []StagedAction + + // Rename mode + renameMode bool + renameInput textinput.Model + renameOriginal string // original branch name shown as label + + // Help overlay + showHelp bool + + // Status/transient message + statusMessage string + statusIsError bool + + // Apply result + applied bool + cancelled bool + applyRequested bool + result *ApplyResult + conflict *ConflictInfo + + // Conflict choice + conflictChoice string // "editor" or "unwind" +} + +// New creates a new modify view model. +func New(nodes []ModifyBranchNode, trunk stack.BranchRef, version string) Model { + ti := textinput.New() + ti.CharLimit = 100 + + // Default cursor to the current active branch, or first non-merged branch + cursor := 0 + found := false + for i, n := range nodes { + if n.IsCurrent { + cursor = i + found = true + break + } + } + if !found { + for i, n := range nodes { + if !n.Ref.IsMerged() { + cursor = i + break + } + } + } + + return Model{ + nodes: nodes, + trunk: trunk, + version: version, + cursor: cursor, + renameInput: ti, + } +} + +// --- Getters for the command layer --- + +func (m Model) Applied() bool { return m.applied } +func (m Model) Cancelled() bool { return m.cancelled } +func (m Model) ApplyRequested() bool { return m.applyRequested } +func (m Model) Result() *ApplyResult { return m.result } +func (m Model) Conflict() *ConflictInfo { return m.conflict } +func (m Model) ConflictChoice() string { return m.conflictChoice } +func (m Model) StagedActions() []StagedAction { return m.actionStack } + +// Nodes returns the current node state for the apply engine. +func (m Model) Nodes() []ModifyBranchNode { return m.nodes } + +// SetResult is called by the command layer after apply completes. +func (m *Model) SetResult(r *ApplyResult) { m.result = r; m.applied = true } + +// SetConflict is called by the command layer when a rebase conflict occurs. +func (m *Model) SetConflict(c *ConflictInfo) { m.conflict = c } + +// --- Bubble Tea interface --- + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + // Clear transient status on any key press + m.statusMessage = "" + m.statusIsError = false + + if m.showHelp { + return m.updateHelp(msg) + } + if m.renameMode { + return m.updateRename(msg) + } + return m.updateNormal(msg) + + case tea.MouseMsg: + switch msg.Action { + case tea.MouseActionPress: + if msg.Button == tea.MouseButtonLeft { + return m.handleMouseClick(msg.X, msg.Y) + } + if msg.Button == tea.MouseButtonWheelUp { + if m.scrollOffset > 0 { + m.scrollOffset-- + } + return m, nil + } + if msg.Button == tea.MouseButtonWheelDown { + m.scrollOffset++ + m.clampScroll() + return m, nil + } + } + } + + return m, nil +} + +// updateHelp handles keys while the help overlay is visible. +func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, modifyKeys.Help) || msg.Type == tea.KeyEscape { + m.showHelp = false + } + return m, nil +} + +// updateRename handles keys while in rename mode. +func (m Model) updateRename(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + newName := strings.TrimSpace(m.renameInput.Value()) + if newName == "" { + m.renameMode = false + return m, nil + } + node := &m.nodes[m.cursor] + oldName := node.Ref.Branch + + if newName == oldName { + m.renameMode = false + return m, nil + } + + // Validate: git ref name rules + if err := git.ValidateRefName(newName); err != nil { + m.statusMessage = fmt.Sprintf("Invalid branch name: %s", err) + m.statusIsError = true + return m, nil + } + + // Validate: not already used by another local branch + if git.BranchExists(newName) { + m.statusMessage = fmt.Sprintf("Branch %q already exists locally", newName) + m.statusIsError = true + return m, nil + } + + // Validate: not already used in this stack (by another node) + for j, other := range m.nodes { + if j == m.cursor { + continue + } + checkName := other.Ref.Branch + if other.PendingAction != nil && other.PendingAction.Type == ActionRename { + checkName = other.PendingAction.NewName + } + if checkName == newName { + m.statusMessage = fmt.Sprintf("Branch %q already used in this stack", newName) + m.statusIsError = true + return m, nil + } + } + + // Record undo action + m.actionStack = append(m.actionStack, StagedAction{ + Type: ActionRename, + BranchName: oldName, + OriginalName: oldName, + NewName: newName, + }) + + node.PendingAction = &PendingAction{ + Type: ActionRename, + NewName: newName, + } + m.renameMode = false + return m, nil + + case tea.KeyEscape: + m.renameMode = false + return m, nil + + default: + var cmd tea.Cmd + m.renameInput, cmd = m.renameInput.Update(msg) + return m, cmd + } +} + +// updateNormal handles keys during normal (non-modal) interaction. +func (m Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, modifyKeys.Quit): + m.cancelled = true + return m, tea.Quit + + case key.Matches(msg, modifyKeys.Up): + m.moveCursor(-1) + return m, nil + + case key.Matches(msg, modifyKeys.Down): + m.moveCursor(1) + return m, nil + + case key.Matches(msg, modifyKeys.MoveUp): + if m.currentMode() == modeStructure { + m.statusMessage = "Cannot reorder while drops, folds, or renames are staged — undo them first" + m.statusIsError = true + return m, nil + } + m.moveNode(-1) + return m, nil + + case key.Matches(msg, modifyKeys.MoveDown): + if m.currentMode() == modeStructure { + m.statusMessage = "Cannot reorder while drops, folds, or renames are staged — undo them first" + m.statusIsError = true + return m, nil + } + m.moveNode(1) + return m, nil + + case key.Matches(msg, modifyKeys.Drop): + if m.currentMode() == modeReorder { + m.statusMessage = "Cannot drop while branches are reordered — undo moves first" + m.statusIsError = true + return m, nil + } + m.toggleDrop() + return m, nil + + case key.Matches(msg, modifyKeys.FoldDown): + if m.currentMode() == modeReorder { + m.statusMessage = "Cannot fold while branches are reordered — undo moves first" + m.statusIsError = true + return m, nil + } + m.fold(ActionFoldDown) + return m, nil + + case key.Matches(msg, modifyKeys.FoldUp): + if m.currentMode() == modeReorder { + m.statusMessage = "Cannot fold while branches are reordered — undo moves first" + m.statusIsError = true + return m, nil + } + m.fold(ActionFoldUp) + return m, nil + + case key.Matches(msg, modifyKeys.Rename): + if m.currentMode() == modeReorder { + m.statusMessage = "Cannot rename while branches are reordered — undo moves first" + m.statusIsError = true + return m, nil + } + m.startRename() + return m, nil + + case key.Matches(msg, modifyKeys.Undo): + m.undoLast() + return m, nil + + case key.Matches(msg, modifyKeys.ToggleCommits): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + m.nodes[m.cursor].CommitsExpanded = !m.nodes[m.cursor].CommitsExpanded + m.clampScroll() + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, modifyKeys.ToggleFiles): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + m.nodes[m.cursor].FilesExpanded = !m.nodes[m.cursor].FilesExpanded + m.clampScroll() + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, modifyKeys.Apply): + return m.tryApply() + + case key.Matches(msg, modifyKeys.Help): + m.showHelp = true + return m, nil + } + + return m, nil +} + +// --- Action handlers --- + +// actionMode represents which exclusive mode the user is in. +type actionMode int + +const ( + modeNone actionMode = iota // no actions yet + modeReorder // user has moved/reordered branches + modeStructure // user has dropped, folded, or renamed branches +) + +// currentMode returns the exclusive action mode based on pending actions. +func (m *Model) currentMode() actionMode { + hasReorder := false + hasStructure := false + + for i, n := range m.nodes { + if n.PendingAction != nil { + switch n.PendingAction.Type { + case ActionDrop, ActionFoldDown, ActionFoldUp, ActionRename: + hasStructure = true + } + } + // Position change without explicit action = reorder + if !n.Ref.IsMerged() && n.OriginalPosition != i && n.PendingAction == nil { + hasReorder = true + } + } + + if hasReorder { + return modeReorder + } + if hasStructure { + return modeStructure + } + return modeNone +} + +// moveCursor moves the cursor by delta to the next node. +func (m *Model) moveCursor(delta int) { + next := m.cursor + delta + if next >= 0 && next < len(m.nodes) { + m.cursor = next + m.ensureVisible() + } +} + +// moveNode swaps the current node with an adjacent non-removed, non-merged node. +func (m *Model) moveNode(delta int) { + if m.cursor < 0 || m.cursor >= len(m.nodes) { + return + } + cur := &m.nodes[m.cursor] + if cur.Ref.IsMerged() { + m.statusMessage = "Cannot move a merged branch" + m.statusIsError = true + return + } + if cur.Removed { + return + } + + // Find the target position + target := m.cursor + delta + for target >= 0 && target < len(m.nodes) { + if !m.nodes[target].Removed && !m.nodes[target].Ref.IsMerged() { + break + } + target += delta + } + if target < 0 || target >= len(m.nodes) { + return + } + if m.nodes[target].Ref.IsMerged() { + m.statusMessage = "Cannot move past a merged branch" + m.statusIsError = true + return + } + + // Record undo + m.actionStack = append(m.actionStack, StagedAction{ + Type: ActionMove, + BranchName: cur.Ref.Branch, + OriginalPosition: m.cursor, + NewPosition: target, + }) + + // Swap + m.nodes[m.cursor], m.nodes[target] = m.nodes[target], m.nodes[m.cursor] + m.cursor = target + m.ensureVisible() +} + +// toggleDrop toggles the drop action on the current node. +func (m *Model) toggleDrop() { + if m.cursor < 0 || m.cursor >= len(m.nodes) { + return + } + node := &m.nodes[m.cursor] + if node.Ref.IsMerged() { + m.statusMessage = "Cannot drop a merged branch" + m.statusIsError = true + return + } + + if node.PendingAction != nil && node.PendingAction.Type == ActionDrop { + // Undo drop + m.actionStack = append(m.actionStack, StagedAction{ + Type: ActionDrop, + BranchName: node.Ref.Branch, + }) + node.PendingAction = nil + node.Removed = false + } else { + // Check if any other branch has a fold targeting this branch. + // A fold-up targets the branch above (lower index), fold-down + // targets the branch below (higher index). + for i, other := range m.nodes { + if other.PendingAction == nil || i == m.cursor { + continue + } + if other.PendingAction.Type == ActionFoldUp { + // fold-up target = nearest non-removed, non-merged node above (lower index) + for j := i - 1; j >= 0; j-- { + if !m.nodes[j].Removed && !m.nodes[j].Ref.IsMerged() { + if j == m.cursor { + m.statusMessage = fmt.Sprintf("Cannot drop: %s is folding into this branch", other.Ref.Branch) + m.statusIsError = true + return + } + break + } + } + } + if other.PendingAction.Type == ActionFoldDown { + // fold-down target = nearest non-removed, non-merged node below (higher index) + for j := i + 1; j < len(m.nodes); j++ { + if !m.nodes[j].Removed && !m.nodes[j].Ref.IsMerged() { + if j == m.cursor { + m.statusMessage = fmt.Sprintf("Cannot drop: %s is folding into this branch", other.Ref.Branch) + m.statusIsError = true + return + } + break + } + } + } + } + + // Check if this would remove the last active branch + active := 0 + for j, other := range m.nodes { + if j == m.cursor { + continue // skip the branch we're about to drop + } + if !other.Removed && !other.Ref.IsMerged() { + active++ + } + } + if active < 1 { + m.statusMessage = "Cannot drop the last branch in the stack" + m.statusIsError = true + return + } + + // Apply drop + m.actionStack = append(m.actionStack, StagedAction{ + Type: ActionDrop, + BranchName: node.Ref.Branch, + }) + node.PendingAction = &PendingAction{Type: ActionDrop} + node.Removed = true + } +} + +// fold stages a fold action on the current node. +func (m *Model) fold(action ActionType) { + if m.cursor < 0 || m.cursor >= len(m.nodes) { + return + } + node := &m.nodes[m.cursor] + if node.Ref.IsMerged() { + m.statusMessage = "Cannot fold a merged branch" + m.statusIsError = true + return + } + + // If already has this same fold type, un-fold (toggle off) + if node.PendingAction != nil && node.PendingAction.Type == action { + m.actionStack = append(m.actionStack, StagedAction{ + Type: action, + BranchName: node.Ref.Branch, + }) + node.PendingAction = nil + node.Removed = false + return + } + + // If already has a different fold type, replace it + if node.PendingAction != nil && (node.PendingAction.Type == ActionFoldDown || node.PendingAction.Type == ActionFoldUp) { + // Clear the old fold first, then fall through to apply the new one + node.PendingAction = nil + node.Removed = false + } + + // Can't fold a branch that's already dropped + if node.Removed { + return + } + + // Find the target branch + var targetIdx int + var found bool + + if action == ActionFoldDown { + // Fold down: target is the next non-removed, non-merged node toward trunk (higher index) + for i := m.cursor + 1; i < len(m.nodes); i++ { + if !m.nodes[i].Removed && !m.nodes[i].Ref.IsMerged() { + targetIdx = i + found = true + break + } + } + if !found { + m.statusMessage = "No branch below to fold into" + m.statusIsError = true + return + } + } else { + // Fold up: target is the previous non-removed, non-merged node away from trunk (lower index) + for i := m.cursor - 1; i >= 0; i-- { + if !m.nodes[i].Removed && !m.nodes[i].Ref.IsMerged() { + targetIdx = i + found = true + break + } + } + if !found { + m.statusMessage = "No branch above to fold into" + m.statusIsError = true + return + } + } + + // Check if this would remove the last active branch + active := 0 + for j, other := range m.nodes { + if j == m.cursor { + continue + } + if !other.Removed && !other.Ref.IsMerged() { + active++ + } + } + if active < 1 { + m.statusMessage = "Cannot fold the last branch in the stack" + m.statusIsError = true + return + } + + m.actionStack = append(m.actionStack, StagedAction{ + Type: action, + BranchName: node.Ref.Branch, + FoldTarget: m.nodes[targetIdx].Ref.Branch, + }) + node.PendingAction = &PendingAction{Type: action} + node.Removed = true +} + +// startRename enters rename mode for the current node. +func (m *Model) startRename() { + if m.cursor < 0 || m.cursor >= len(m.nodes) { + return + } + node := &m.nodes[m.cursor] + if node.Ref.IsMerged() { + m.statusMessage = "Cannot rename a merged branch" + m.statusIsError = true + return + } + if node.Removed { + return + } + + m.renameMode = true + m.renameOriginal = node.Ref.Branch + m.renameInput.SetValue(node.Ref.Branch) + m.renameInput.Prompt = "" + m.renameInput.Focus() + m.renameInput.CursorEnd() +} + +// undoLast reverses the most recent action from the stack. +func (m *Model) undoLast() { + if len(m.actionStack) == 0 { + m.statusMessage = "Nothing to undo" + m.statusIsError = false + return + } + + action := m.actionStack[len(m.actionStack)-1] + m.actionStack = m.actionStack[:len(m.actionStack)-1] + + switch action.Type { + case ActionDrop: + // Find the branch and toggle its state + for i := range m.nodes { + if m.nodes[i].Ref.Branch == action.BranchName || (m.nodes[i].PendingAction != nil && m.nodes[i].PendingAction.Type == ActionDrop && m.nodes[i].Ref.Branch == action.BranchName) { + if m.nodes[i].PendingAction != nil && m.nodes[i].PendingAction.Type == ActionDrop { + m.nodes[i].PendingAction = nil + m.nodes[i].Removed = false + } else { + m.nodes[i].PendingAction = &PendingAction{Type: ActionDrop} + m.nodes[i].Removed = true + } + break + } + } + + case ActionFoldDown, ActionFoldUp: + for i := range m.nodes { + if m.nodes[i].Ref.Branch == action.BranchName { + if m.nodes[i].PendingAction != nil && m.nodes[i].PendingAction.Type == action.Type { + m.nodes[i].PendingAction = nil + m.nodes[i].Removed = false + } else { + m.nodes[i].PendingAction = &PendingAction{Type: action.Type} + m.nodes[i].Removed = true + } + break + } + } + + case ActionMove: + // Swap back to original positions + from := -1 + to := action.OriginalPosition + for i := range m.nodes { + if m.nodes[i].Ref.Branch == action.BranchName { + from = i + break + } + } + if from >= 0 && from != to && to >= 0 && to < len(m.nodes) { + m.nodes[from], m.nodes[to] = m.nodes[to], m.nodes[from] + m.cursor = to + } + + case ActionRename: + for i := range m.nodes { + if m.nodes[i].Ref.Branch == action.BranchName || (m.nodes[i].PendingAction != nil && m.nodes[i].PendingAction.Type == ActionRename && m.nodes[i].PendingAction.NewName == action.NewName) { + m.nodes[i].PendingAction = nil + break + } + } + } +} + +// tryApply validates and initiates apply. +func (m Model) tryApply() (tea.Model, tea.Cmd) { + hasPending := false + for i, n := range m.nodes { + if n.PendingAction != nil { + hasPending = true + break + } + if !n.Removed && n.OriginalPosition != i { + hasPending = true + break + } + } + + if !hasPending { + m.statusMessage = "No pending changes to apply" + m.statusIsError = false + return m, nil + } + + // Ensure at least one non-removed, non-merged branch remains + active := 0 + for _, n := range m.nodes { + if !n.Removed && !n.Ref.IsMerged() { + active++ + } + } + if active < 1 { + m.statusMessage = "Cannot remove all branches from the stack" + m.statusIsError = true + return m, nil + } + + m.applyRequested = true + return m, tea.Quit +} + +// --- Scrolling --- + +func (m *Model) ensureVisible() { + if m.height == 0 { + return + } + startLine := 0 + for i := 0; i < m.cursor; i++ { + startLine += m.nodeLineCount(i) + } + endLine := startLine + m.nodeLineCount(m.cursor) + + viewHeight := m.contentViewHeight() + m.scrollOffset = shared.EnsureVisible(startLine, endLine, m.scrollOffset, viewHeight) +} + +func (m Model) nodeLineCount(idx int) int { + return shared.NodeLineCount(toNodeData(m.nodes[idx], idx)) +} + +func (m Model) contentViewHeight() int { + reserved := 3 // post-scroll newline + context line + status bar + if shared.ShouldShowHeader(m.width, m.height) { + reserved += shared.HeaderHeight + } + h := m.height - reserved + if h < 1 { + h = 1 + } + return h +} + +func (m *Model) clampScroll() { + total := 0 + for i := range m.nodes { + total += m.nodeLineCount(i) + } + total++ // trunk line + m.scrollOffset = shared.ClampScroll(total, m.contentViewHeight(), m.scrollOffset) +} + +// --- Mouse handling --- + +// handleMouseClick processes a mouse click at the given screen position. +func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { + nodes := make([]shared.BranchNodeData, len(m.nodes)) + for i, n := range m.nodes { + nodes[i] = toNodeData(n, i) + } + + result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, shared.ShouldShowHeader(m.width, m.height), false) + if result.NodeIndex < 0 { + return m, nil + } + + m.cursor = result.NodeIndex + + if result.OpenURL != "" { + shared.OpenBrowserInBackground(result.OpenURL) + } + if result.ToggleFiles { + m.nodes[result.NodeIndex].FilesExpanded = !m.nodes[result.NodeIndex].FilesExpanded + m.clampScroll() + } + if result.ToggleCommits { + m.nodes[result.NodeIndex].CommitsExpanded = !m.nodes[result.NodeIndex].CommitsExpanded + m.clampScroll() + } + + return m, nil +} + +// --- View --- + +// toNodeData converts a ModifyBranchNode to shared.BranchNodeData, +// applying drop/fold/move visual overrides. currentIdx is the node's +// current position in the list, used to detect moves. +func toNodeData(n ModifyBranchNode, currentIdx int) shared.BranchNodeData { + data := shared.BranchNodeData{ + Ref: n.Ref, + IsCurrent: n.IsCurrent, + IsLinear: n.IsLinear, + BaseBranch: n.BaseBranch, + Commits: n.Commits, + FilesChanged: n.FilesChanged, + PR: n.PR, + Additions: n.Additions, + Deletions: n.Deletions, + CommitsExpanded: n.CommitsExpanded, + FilesExpanded: n.FilesExpanded, + } + + if n.PendingAction != nil { + switch n.PendingAction.Type { + case ActionDrop: + s := dropBranchStyle + c := dropConnectorStyle + data.BranchNameStyleOverride = &s + data.ConnectorStyleOverride = &c + data.ForceDashedConnector = true + case ActionFoldDown, ActionFoldUp: + s := foldBranchStyle + c := foldConnectorStyle + data.BranchNameStyleOverride = &s + data.ConnectorStyleOverride = &c + data.ForceDashedConnector = true + } + } + + // Moved branch: purple solid connector (no dash, no strikethrough) + if n.PendingAction == nil && !n.Ref.IsMerged() && n.OriginalPosition != currentIdx { + c := movedConnectorStyle + data.ConnectorStyleOverride = &c + } + + return data +} + +// nodeAnnotation builds an optional annotation from the node's pending action +// or its position change. currentIdx is the node's current position in the list. +func nodeAnnotation(n ModifyBranchNode, currentIdx int) *shared.NodeAnnotation { + if n.Ref.IsMerged() { + return &shared.NodeAnnotation{Text: "🔒", Style: shared.DimStyle} + } + if n.PendingAction != nil { + switch n.PendingAction.Type { + case ActionDrop: + return &shared.NodeAnnotation{Text: "✗ drop", Style: dropBadge} + case ActionFoldDown: + return &shared.NodeAnnotation{Text: "↓ fold down", Style: foldBadge} + case ActionFoldUp: + return &shared.NodeAnnotation{Text: "↑ fold up", Style: foldBadge} + case ActionRename: + return &shared.NodeAnnotation{Text: "→ " + n.PendingAction.NewName, Style: renameBadge} + case ActionMove: + return &shared.NodeAnnotation{Text: "↕ moved", Style: moveBadge} + } + } + // Show move annotation when position changed (even without explicit PendingAction) + if !n.Ref.IsMerged() && n.OriginalPosition != currentIdx { + delta := n.OriginalPosition - currentIdx // positive = moved up (toward top) + direction := "up" + layers := delta + if delta < 0 { + direction = "down" + layers = -delta + } + label := "layers" + if layers == 1 { + label = "layer" + } + text := fmt.Sprintf("↕ moved %d %s %s", layers, label, direction) + return &shared.NodeAnnotation{Text: text, Style: moveBadge} + } + return nil +} + +func (m Model) View() string { + if m.width == 0 { + return "" + } + + if m.showHelp { + return renderHelpOverlay(m.width, m.height) + } + + var out strings.Builder + + // Header + showHeader := shared.ShouldShowHeader(m.width, m.height) + if showHeader { + shared.RenderHeader(&out, m.buildHeaderConfig(), m.width, m.height) + } + + // Build the scrollable branch list content + var b strings.Builder + for i := 0; i < len(m.nodes); i++ { + nodeData := toNodeData(m.nodes[i], i) + isFocused := i == m.cursor + annotation := nodeAnnotation(m.nodes[i], i) + shared.RenderNode(&b, nodeData, isFocused, m.width, annotation) + } + shared.RenderTrunk(&b, m.trunk.Branch) + + // Count fixed bottom lines (always visible, not scrollable). + // The bottom section always has 2 lines: one for contextual info + // (rename prompt or error, blank when neither) and one for the status bar. + bottomLines := 2 // error/status line + status bar (post-scroll newline is inline) + + // Scrolling — reserve space for header and fixed bottom + reservedLines := bottomLines + if showHeader { + reservedLines += shared.HeaderHeight + } + viewHeight := m.height - reservedLines + if viewHeight < 1 { + viewHeight = 1 + } + + out.WriteString(shared.ApplyScrollToContent(b.String(), m.scrollOffset, viewHeight)) + out.WriteString("\n") + + // Second-to-bottom: error/status message line (always present, blank when empty) + if m.statusMessage != "" { + if m.statusIsError { + out.WriteString(transientErrorStyle.Render("✗ " + m.statusMessage)) + } else { + out.WriteString(transientInfoStyle.Render(m.statusMessage)) + } + } + + // Bottom line: rename prompt (when active) or status bar + out.WriteString("\n") + if m.renameMode { + out.WriteString(renameBadge.Render(fmt.Sprintf("Rename: %s → ", m.renameOriginal))) + out.WriteString(m.renameInput.View()) + } else { + out.WriteString(renderStatusLine(m.nodes, m.width)) + } + + return out.String() +} + +// buildHeaderConfig creates the header configuration for modify mode. +func (m Model) buildHeaderConfig() shared.HeaderConfig { + mergedCount := 0 + for _, n := range m.nodes { + if n.Ref.IsMerged() { + mergedCount++ + } + } + + branchCount := len(m.nodes) + branchInfo := fmt.Sprintf("%d branches", branchCount) + if branchCount == 1 { + branchInfo = "1 branch" + } + if mergedCount > 0 { + branchInfo += fmt.Sprintf(" (%d merged, locked)", mergedCount) + } + + pendingSummary := pendingChangeSummary(m.nodes) + + infoLines := []shared.HeaderInfoLine{ + {Icon: "◆", Label: "Base: " + m.trunk.Branch}, + {Icon: "○", Label: branchInfo}, + } + if pendingSummary != "" { + yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "■", Label: pendingSummary, IconStyle: &yellowStyle}) + } else { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "□", Label: "No pending changes"}) + } + + mode := m.currentMode() + reorderDisabled := mode == modeStructure + structureDisabled := mode == modeReorder + + return shared.HeaderConfig{ + ShowArt: true, + Title: "Modify Stack", + Subtitle: "v" + m.version, + InfoLines: infoLines, + ShortcutColumns: 2, + Shortcuts: []shared.ShortcutEntry{ + // Left column // Right column + {Key: "↑↓", Desc: "select branch"}, {Key: "x", Desc: "drop", Disabled: structureDisabled}, + {Key: "f", Desc: "view files"}, {Key: "r", Desc: "rename", Disabled: structureDisabled}, + {Key: "c", Desc: "view commits"}, {Key: "u", Desc: "fold up", Disabled: structureDisabled}, + {Key: "?", Desc: "help"}, {Key: "d", Desc: "fold down", Disabled: structureDisabled}, + {Key: "q/esc", Desc: "quit"}, {Key: "shift+↑↓", Desc: "reorder", Disabled: reorderDisabled}, + {Key: "^S", Desc: "apply changes"}, {Key: "z", Desc: "undo"}, + }, + } +} + +// Ensure Model satisfies the tea.Model interface. +var _ tea.Model = Model{} diff --git a/internal/tui/modifyview/model_test.go b/internal/tui/modifyview/model_test.go new file mode 100644 index 0000000..348515e --- /dev/null +++ b/internal/tui/modifyview/model_test.go @@ -0,0 +1,758 @@ +package modifyview + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/stackview" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeNode creates a test ModifyBranchNode with sensible defaults. +func makeNode(branch string, isCurrent bool, pos int) ModifyBranchNode { + return ModifyBranchNode{ + BranchNode: stackview.BranchNode{ + Ref: stack.BranchRef{Branch: branch}, + IsCurrent: isCurrent, + IsLinear: true, + }, + OriginalPosition: pos, + } +} + +// makeMergedNode creates a merged test node (IsMerged() returns true). +func makeMergedNode(branch string, pos int) ModifyBranchNode { + return ModifyBranchNode{ + BranchNode: stackview.BranchNode{ + Ref: stack.BranchRef{ + Branch: branch, + PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}, + }, + IsLinear: true, + }, + OriginalPosition: pos, + } +} + +var testTrunk = stack.BranchRef{Branch: "main"} + +// sendKey sends a key message to the model and returns the updated Model. +func sendKey(t *testing.T, m Model, msg tea.KeyMsg) Model { + t.Helper() + updated, _ := m.Update(msg) + return updated.(Model) +} + +func runeKey(r rune) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} +} + +// --- New() constructor tests --- + +func TestNew_CursorDefaultsToCurrentBranch(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("b0", false, 0), + makeNode("b1", false, 1), + makeNode("b2", true, 2), + makeNode("b3", false, 3), + } + m := New(nodes, testTrunk, "1.0.0") + assert.Equal(t, 2, m.cursor, "cursor should be on the IsCurrent node") +} + +func TestNew_CursorFallsBackToFirstNonMerged(t *testing.T) { + nodes := []ModifyBranchNode{ + makeMergedNode("merged0", 0), + makeNode("active1", false, 1), + makeNode("active2", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + assert.Equal(t, 1, m.cursor, "cursor should skip merged node and land on first non-merged") +} + +// --- Drop toggle tests --- + +func TestDropToggle(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", false, 0), + makeNode("b", true, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + require.Equal(t, 1, m.cursor) + + // Press 'x' → drop + m = sendKey(t, m, runeKey('x')) + require.NotNil(t, m.nodes[1].PendingAction) + assert.Equal(t, ActionDrop, m.nodes[1].PendingAction.Type) + assert.True(t, m.nodes[1].Removed) + + // Press 'x' again → undo drop + m = sendKey(t, m, runeKey('x')) + assert.Nil(t, m.nodes[1].PendingAction) + assert.False(t, m.nodes[1].Removed) +} + +// --- Fold toggle tests --- + +func TestFoldToggle(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", false, 0), + makeNode("b", true, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + require.Equal(t, 1, m.cursor) // cursor on b + + // 'd' → fold down + m = sendKey(t, m, runeKey('d')) + require.NotNil(t, m.nodes[1].PendingAction) + assert.Equal(t, ActionFoldDown, m.nodes[1].PendingAction.Type) + assert.True(t, m.nodes[1].Removed) + + // 'd' again → toggle off + m = sendKey(t, m, runeKey('d')) + assert.Nil(t, m.nodes[1].PendingAction) + assert.False(t, m.nodes[1].Removed) + + // 'u' → fold up + m = sendKey(t, m, runeKey('u')) + require.NotNil(t, m.nodes[1].PendingAction) + assert.Equal(t, ActionFoldUp, m.nodes[1].PendingAction.Type) + assert.True(t, m.nodes[1].Removed) + + // 'u' again → toggle off + m = sendKey(t, m, runeKey('u')) + assert.Nil(t, m.nodes[1].PendingAction) + assert.False(t, m.nodes[1].Removed) +} + +// --- Fold replace tests --- + +func TestFoldReplace(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", false, 0), + makeNode("b", true, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + + // 'd' → fold down + m = sendKey(t, m, runeKey('d')) + require.NotNil(t, m.nodes[1].PendingAction) + assert.Equal(t, ActionFoldDown, m.nodes[1].PendingAction.Type) + + // 'u' → should replace fold-down with fold-up + m = sendKey(t, m, runeKey('u')) + require.NotNil(t, m.nodes[1].PendingAction) + assert.Equal(t, ActionFoldUp, m.nodes[1].PendingAction.Type) + assert.True(t, m.nodes[1].Removed) +} + +// --- Last branch guard tests --- + +func TestCannotDropLastBranch(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeNode("b", false, 1), + } + m := New(nodes, testTrunk, "1.0.0") + + // Drop first node + m = sendKey(t, m, runeKey('x')) + require.NotNil(t, m.nodes[0].PendingAction) + assert.Equal(t, ActionDrop, m.nodes[0].PendingAction.Type) + + // Move cursor to second node + m = sendKey(t, m, runeKey('j')) + require.Equal(t, 1, m.cursor) + + // Try to drop second node → should be refused + m = sendKey(t, m, runeKey('x')) + assert.Nil(t, m.nodes[1].PendingAction, "second node should NOT be dropped") + assert.False(t, m.nodes[1].Removed) + assert.True(t, m.statusIsError) + assert.Contains(t, m.statusMessage, "last branch") +} + +func TestCannotFoldLastBranch(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeNode("b", false, 1), + } + m := New(nodes, testTrunk, "1.0.0") + + // Drop first node + m = sendKey(t, m, runeKey('x')) + require.NotNil(t, m.nodes[0].PendingAction) + + // Move cursor to second node + m = sendKey(t, m, runeKey('j')) + require.Equal(t, 1, m.cursor) + + // Try to fold second node down → should fail (no branch below) + m = sendKey(t, m, runeKey('d')) + assert.Nil(t, m.nodes[1].PendingAction, "second node should NOT be folded") + assert.True(t, m.statusIsError) + + // Try to fold second node up → should fail (only target above is removed) + m = sendKey(t, m, runeKey('u')) + assert.Nil(t, m.nodes[1].PendingAction, "second node should NOT be folded") + assert.True(t, m.statusIsError) +} + +// --- Mutual fold test --- + +func TestMutualFoldBlocked(t *testing.T) { + // With 3 nodes A(0), B(1), C(2): fold B down into C, then try + // to fold C up. Since B is removed the target search for fold-up + // skips B and finds A. The fold into A would leave only 1 active + // branch (A) so it IS allowed (active >= 1). Verify the behavior. + nodes := []ModifyBranchNode{ + makeNode("a", false, 0), + makeNode("b", true, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + + // Fold B down into C + m = sendKey(t, m, runeKey('d')) + require.NotNil(t, m.nodes[1].PendingAction) + assert.Equal(t, ActionFoldDown, m.nodes[1].PendingAction.Type) + assert.True(t, m.nodes[1].Removed) + + // Move cursor to C + m = sendKey(t, m, runeKey('j')) + require.Equal(t, 2, m.cursor) + + // Try fold C up — B is removed so target becomes A. + // With only 2 nodes removed (B, C), A is the only active → active=1 (passes guard). + m = sendKey(t, m, runeKey('u')) + require.NotNil(t, m.nodes[2].PendingAction, "C should fold up into A since B is skipped") + assert.Equal(t, ActionFoldUp, m.nodes[2].PendingAction.Type) + assert.True(t, m.nodes[2].Removed) + + // Verify only A remains active + active := 0 + for _, n := range m.nodes { + if !n.Removed { + active++ + } + } + assert.Equal(t, 1, active, "only A should remain active") +} + +// --- Mode exclusivity tests --- + +func TestModeExclusivity_ReorderBlocksStructure(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeNode("b", false, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + require.Equal(t, 0, m.cursor) + + // Move top node down (shift+down = 'J') → reorder mode + m = sendKey(t, m, runeKey('J')) + assert.Equal(t, 1, m.cursor, "cursor should move with the swapped node") + assert.Equal(t, modeReorder, m.currentMode()) + + // Try 'x' (drop) → should show error + m = sendKey(t, m, runeKey('x')) + assert.True(t, m.statusIsError) + assert.Contains(t, m.statusMessage, "undo") + + // Try 'd' (fold) → should show error + m = sendKey(t, m, runeKey('d')) + assert.True(t, m.statusIsError) + assert.Contains(t, m.statusMessage, "undo") +} + +func TestModeExclusivity_StructureBlocksReorder(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", false, 0), + makeNode("b", true, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + require.Equal(t, 1, m.cursor) + + // Drop middle node → structure mode + m = sendKey(t, m, runeKey('x')) + require.NotNil(t, m.nodes[1].PendingAction) + assert.Equal(t, modeStructure, m.currentMode()) + + // Move cursor to a non-removed node + m = sendKey(t, m, runeKey('j')) + require.Equal(t, 2, m.cursor) + + // Try shift+down (move) → should show error + m = sendKey(t, m, runeKey('J')) + assert.True(t, m.statusIsError) + assert.Contains(t, m.statusMessage, "undo") + + // Try shift+up (move) → should show error + m = sendKey(t, m, runeKey('K')) + assert.True(t, m.statusIsError) + assert.Contains(t, m.statusMessage, "undo") +} + +// --- Apply validation tests --- + +func TestApplyRefusedWhenNoPendingChanges(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeNode("b", false, 1), + } + m := New(nodes, testTrunk, "1.0.0") + + // ctrl+s with no changes + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlS}) + assert.False(t, m.applyRequested) + assert.Equal(t, "No pending changes to apply", m.statusMessage) + assert.False(t, m.statusIsError) +} + +func TestApplySucceedsWithPendingChanges(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeNode("b", false, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + + // Drop a node + m = sendKey(t, m, runeKey('x')) + require.NotNil(t, m.nodes[0].PendingAction) + + // ctrl+s → apply should be requested + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlS}) + assert.True(t, m.applyRequested) +} + +// --- Quit / cancel tests --- + +func TestQuitSetsCancelled(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + } + m := New(nodes, testTrunk, "1.0.0") + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyEscape}) + assert.True(t, m.Cancelled()) +} + +// --- Cursor navigation tests --- + +func TestCursorNavigation(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeNode("b", false, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + require.Equal(t, 0, m.cursor) + + // Move down + m = sendKey(t, m, runeKey('j')) + assert.Equal(t, 1, m.cursor) + + // Move down again + m = sendKey(t, m, runeKey('j')) + assert.Equal(t, 2, m.cursor) + + // Move down at bottom → stays at bottom + m = sendKey(t, m, runeKey('j')) + assert.Equal(t, 2, m.cursor) + + // Move up + m = sendKey(t, m, runeKey('k')) + assert.Equal(t, 1, m.cursor) + + // Move up to top + m = sendKey(t, m, runeKey('k')) + assert.Equal(t, 0, m.cursor) + + // Move up at top → stays at top + m = sendKey(t, m, runeKey('k')) + assert.Equal(t, 0, m.cursor) +} + +// --- Undo tests --- + +func TestUndoDrop(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeNode("b", false, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + + // Drop node + m = sendKey(t, m, runeKey('x')) + require.True(t, m.nodes[0].Removed) + + // Undo + m = sendKey(t, m, runeKey('z')) + assert.Nil(t, m.nodes[0].PendingAction) + assert.False(t, m.nodes[0].Removed) +} + +func TestUndoMove(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeNode("b", false, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + + // Move a down (shift+down) + m = sendKey(t, m, runeKey('J')) + assert.Equal(t, "b", m.nodes[0].Ref.Branch, "b should now be at index 0") + assert.Equal(t, "a", m.nodes[1].Ref.Branch, "a should now be at index 1") + assert.Equal(t, 1, m.cursor) + + // Undo → should swap back + m = sendKey(t, m, runeKey('z')) + assert.Equal(t, "a", m.nodes[0].Ref.Branch, "a should be back at index 0") + assert.Equal(t, "b", m.nodes[1].Ref.Branch, "b should be back at index 1") + assert.Equal(t, 0, m.cursor, "cursor should return to original position") +} + +func TestUndoNothingShowsMessage(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + } + m := New(nodes, testTrunk, "1.0.0") + + // Undo with empty stack + m = sendKey(t, m, runeKey('z')) + assert.Equal(t, "Nothing to undo", m.statusMessage) + assert.False(t, m.statusIsError) +} + +// --- Getters tests --- + +func TestGetters(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeNode("b", false, 1), + } + m := New(nodes, testTrunk, "1.0.0") + + assert.False(t, m.Applied()) + assert.False(t, m.Cancelled()) + assert.False(t, m.ApplyRequested()) + assert.Len(t, m.Nodes(), 2) + assert.Equal(t, "a", m.Nodes()[0].Ref.Branch) + assert.Equal(t, "b", m.Nodes()[1].Ref.Branch) +} + +// --- Merged branch guard tests --- + +func TestCannotDropMergedBranch(t *testing.T) { + nodes := []ModifyBranchNode{ + makeMergedNode("merged", 0), + makeNode("active", true, 1), + } + m := New(nodes, testTrunk, "1.0.0") + + // Cursor on merged node + m.cursor = 0 + m = sendKey(t, m, runeKey('x')) + assert.Nil(t, m.nodes[0].PendingAction) + assert.True(t, m.statusIsError) + assert.Contains(t, m.statusMessage, "merged") +} + +func TestCannotFoldMergedBranch(t *testing.T) { + nodes := []ModifyBranchNode{ + makeMergedNode("merged", 0), + makeNode("active", true, 1), + } + m := New(nodes, testTrunk, "1.0.0") + + m.cursor = 0 + m = sendKey(t, m, runeKey('d')) + assert.Nil(t, m.nodes[0].PendingAction) + assert.True(t, m.statusIsError) + assert.Contains(t, m.statusMessage, "merged") +} + +// --- Drop target protected by fold --- + +func TestCannotDropFoldTarget(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", false, 0), + makeNode("b", true, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + + // Fold b down into c + m = sendKey(t, m, runeKey('d')) + require.NotNil(t, m.nodes[1].PendingAction) + assert.Equal(t, ActionFoldDown, m.nodes[1].PendingAction.Type) + + // Move cursor to c (fold target) + m = sendKey(t, m, runeKey('j')) + require.Equal(t, 2, m.cursor) + + // Try to drop c → should be refused because b is folding into c + m = sendKey(t, m, runeKey('x')) + assert.Nil(t, m.nodes[2].PendingAction, "fold target should not be droppable") + assert.True(t, m.statusIsError) + assert.Contains(t, m.statusMessage, "folding into") +} + +// --- Help toggle --- + +func TestHelpToggle(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + } + m := New(nodes, testTrunk, "1.0.0") + + // Open help + m = sendKey(t, m, runeKey('?')) + assert.True(t, m.showHelp) + + // Close help with '?' + m = sendKey(t, m, runeKey('?')) + assert.False(t, m.showHelp) + + // Open and close with Escape + m = sendKey(t, m, runeKey('?')) + assert.True(t, m.showHelp) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyEscape}) + assert.False(t, m.showHelp) +} + +// --- Status message cleared on keypress --- + +func TestStatusMessageClearedOnKeypress(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + } + m := New(nodes, testTrunk, "1.0.0") + + // Generate an error + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlS}) + require.NotEmpty(t, m.statusMessage) + + // Any key press clears the message + m = sendKey(t, m, runeKey('j')) + assert.Empty(t, m.statusMessage) + assert.False(t, m.statusIsError) +} + +func TestPendingChangeSummary(t *testing.T) { + t.Run("no changes returns empty", func(t *testing.T) { + nodes := []ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, + }, + } + + result := pendingChangeSummary(nodes) + assert.Equal(t, "", result) + }) + + t.Run("one drop", func(t *testing.T) { + nodes := []ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + PendingAction: &PendingAction{Type: ActionDrop}, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, + }, + } + + result := pendingChangeSummary(nodes) + assert.Equal(t, "Pending: 1 drop", result) + }) + + t.Run("multiple drops uses plural", func(t *testing.T) { + nodes := []ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + PendingAction: &PendingAction{Type: ActionDrop}, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, + PendingAction: &PendingAction{Type: ActionDrop}, + }, + } + + result := pendingChangeSummary(nodes) + assert.Equal(t, "Pending: 2 drops", result) + }) + + t.Run("mixed actions", func(t *testing.T) { + nodes := []ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + PendingAction: &PendingAction{Type: ActionDrop}, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, + PendingAction: &PendingAction{Type: ActionFoldDown}, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b3"}}, + OriginalPosition: 2, + PendingAction: &PendingAction{Type: ActionFoldUp}, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b4"}}, + OriginalPosition: 3, + PendingAction: &PendingAction{Type: ActionRename, NewName: "b4-new"}, + }, + } + + result := pendingChangeSummary(nodes) + assert.Equal(t, "Pending: 1 drop, 2 folds, 1 rename", result) + }) + + t.Run("position change counts as move", func(t *testing.T) { + // b2 moved to position 0, b1 moved to position 1 + nodes := []ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, // moved from 1 to 0 + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, // moved from 0 to 1 + }, + } + + result := pendingChangeSummary(nodes) + assert.Equal(t, "Pending: 2 moves", result) + }) + + t.Run("removed nodes with position change not counted as move", func(t *testing.T) { + // A removed node with a different position should not add to moves + nodes := []ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 1, + Removed: true, + PendingAction: &PendingAction{Type: ActionDrop}, + }, + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b2"}}, + OriginalPosition: 1, + }, + } + + result := pendingChangeSummary(nodes) + assert.Equal(t, "Pending: 1 drop", result) + }) + + t.Run("one rename singular", func(t *testing.T) { + nodes := []ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + PendingAction: &PendingAction{Type: ActionRename, NewName: "feature"}, + }, + } + + result := pendingChangeSummary(nodes) + assert.Equal(t, "Pending: 1 rename", result) + }) + + t.Run("one fold singular", func(t *testing.T) { + nodes := []ModifyBranchNode{ + { + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "b1"}}, + OriginalPosition: 0, + PendingAction: &PendingAction{Type: ActionFoldDown}, + }, + } + + result := pendingChangeSummary(nodes) + assert.Equal(t, "Pending: 1 fold", result) + }) +} + +func TestPluralize(t *testing.T) { + assert.Equal(t, "drop", pluralize(1, "drop", "drops")) + assert.Equal(t, "drops", pluralize(2, "drop", "drops")) + assert.Equal(t, "drops", pluralize(0, "drop", "drops")) +} + +func TestUndoRename(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", false, 0), + makeNode("b", true, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + + // Simulate a rename on node at index 1 (bypass git validation) + m.nodes[1].PendingAction = &PendingAction{Type: ActionRename, NewName: "b-renamed"} + m.actionStack = append(m.actionStack, StagedAction{ + Type: ActionRename, + BranchName: "b", + OriginalName: "b", + NewName: "b-renamed", + }) + + require.NotNil(t, m.nodes[1].PendingAction) + assert.Equal(t, ActionRename, m.nodes[1].PendingAction.Type) + assert.Equal(t, "b-renamed", m.nodes[1].PendingAction.NewName) + + // Undo + m = sendKey(t, m, runeKey('z')) + assert.Nil(t, m.nodes[1].PendingAction, "PendingAction should be cleared after undo") +} + +func TestUndoRename_DoesNotAffectOtherRenames(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", false, 0), + makeNode("b", true, 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + + // Simulate rename on node 0 (first rename) + m.nodes[0].PendingAction = &PendingAction{Type: ActionRename, NewName: "a-renamed"} + m.actionStack = append(m.actionStack, StagedAction{ + Type: ActionRename, + BranchName: "a", + OriginalName: "a", + NewName: "a-renamed", + }) + + // Simulate rename on node 2 (second rename) + m.nodes[2].PendingAction = &PendingAction{Type: ActionRename, NewName: "c-renamed"} + m.actionStack = append(m.actionStack, StagedAction{ + Type: ActionRename, + BranchName: "c", + OriginalName: "c", + NewName: "c-renamed", + }) + + // Undo the second rename + m = sendKey(t, m, runeKey('z')) + assert.Nil(t, m.nodes[2].PendingAction, "second rename should be undone") + require.NotNil(t, m.nodes[0].PendingAction, "first rename should still be intact") + assert.Equal(t, "a-renamed", m.nodes[0].PendingAction.NewName, "first rename new name should be unchanged") +} diff --git a/internal/tui/modifyview/status.go b/internal/tui/modifyview/status.go new file mode 100644 index 0000000..cb5d489 --- /dev/null +++ b/internal/tui/modifyview/status.go @@ -0,0 +1,93 @@ +package modifyview + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// pendingChangeSummary returns a summary string of all pending changes. +// E.g. "Pending: 1 drop, 1 fold, 2 moves, 1 rename" +func pendingChangeSummary(nodes []ModifyBranchNode) string { + var drops, foldDowns, foldUps, moves, renames int + + for _, n := range nodes { + if n.PendingAction == nil { + continue + } + switch n.PendingAction.Type { + case ActionDrop: + drops++ + case ActionFoldDown: + foldDowns++ + case ActionFoldUp: + foldUps++ + case ActionMove: + moves++ + case ActionRename: + renames++ + } + } + + // Also count nodes that have moved from their original position + for i, n := range nodes { + if !n.Removed && n.PendingAction == nil && n.OriginalPosition != i { + moves++ + } + } + + if drops == 0 && foldDowns == 0 && foldUps == 0 && moves == 0 && renames == 0 { + return "" + } + + var parts []string + if drops > 0 { + parts = append(parts, fmt.Sprintf("%d %s", drops, pluralize(drops, "drop", "drops"))) + } + folds := foldDowns + foldUps + if folds > 0 { + parts = append(parts, fmt.Sprintf("%d %s", folds, pluralize(folds, "fold", "folds"))) + } + if moves > 0 { + parts = append(parts, fmt.Sprintf("%d %s", moves, pluralize(moves, "move", "moves"))) + } + if renames > 0 { + parts = append(parts, fmt.Sprintf("%d %s", renames, pluralize(renames, "rename", "renames"))) + } + + return "Pending: " + strings.Join(parts, ", ") +} + +// pluralize returns singular or plural form based on count. +func pluralize(count int, singular, plural string) string { + if count == 1 { + return singular + } + return plural +} + +// renderStatusLine renders the bottom status bar with pending changes and key hints. +func renderStatusLine(nodes []ModifyBranchNode, width int) string { + summary := pendingChangeSummary(nodes) + + hints := statusKeyStyle.Render("ctrl+s") + statusDescStyle.Render(" apply ") + + statusKeyStyle.Render("q") + statusDescStyle.Render(" cancel ") + + statusKeyStyle.Render("?") + statusDescStyle.Render(" help") + + if summary == "" { + summary = statusBarStyle.Render("No pending changes") + } else { + summary = statusCountStyle.Render(summary) + } + + // Lay out: summary on left, hints on right + summaryWidth := lipgloss.Width(summary) + hintsWidth := lipgloss.Width(hints) + gap := width - summaryWidth - hintsWidth + if gap < 2 { + gap = 2 + } + + return summary + strings.Repeat(" ", gap) + hints +} diff --git a/internal/tui/modifyview/styles.go b/internal/tui/modifyview/styles.go new file mode 100644 index 0000000..2c7b9d9 --- /dev/null +++ b/internal/tui/modifyview/styles.go @@ -0,0 +1,39 @@ +package modifyview + +import "github.com/charmbracelet/lipgloss" + +var ( + // Action annotation styles (modify-specific) + dropBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + foldBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow + renameBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan + moveBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta/purple + + // Branch name overrides for drop/fold + dropBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Strikethrough(true) // red strikethrough + foldBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Strikethrough(true) // yellow strikethrough + + // Connector color overrides for drop/fold/move + dropConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + foldConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow + movedConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta/purple + + // Status line styles + statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + statusCountStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + statusKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + statusDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + // Help overlay styles + helpOverlayStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). + Padding(1, 2) + helpKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) + helpDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + helpTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true) + + // Transient message styles + transientErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + transientInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray +) diff --git a/internal/tui/modifyview/types.go b/internal/tui/modifyview/types.go new file mode 100644 index 0000000..e3f995d --- /dev/null +++ b/internal/tui/modifyview/types.go @@ -0,0 +1,69 @@ +package modifyview + +import ( + "github.com/github/gh-stack/internal/tui/stackview" +) + +// ActionType represents the type of modification action. +type ActionType string + +const ( + ActionDrop ActionType = "drop" + ActionFoldDown ActionType = "fold_down" + ActionFoldUp ActionType = "fold_up" + ActionMove ActionType = "move" + ActionRename ActionType = "rename" +) + +// PendingAction represents a staged modification on a branch. +type PendingAction struct { + Type ActionType + NewName string // for rename +} + +// StagedAction records a single staged action in the undo stack. +// It stores enough information to reverse the action. +type StagedAction struct { + Type ActionType + BranchName string // the branch affected + OriginalPosition int // for move: the position before the move + NewPosition int // for move: the position after the move + OriginalName string // for rename: the name before rename + NewName string // for rename: the name after rename + FoldTarget string // for fold: the branch that received the commits +} + +// ModifyBranchNode wraps a BranchNode with modification state. +type ModifyBranchNode struct { + stackview.BranchNode + PendingAction *PendingAction + OriginalPosition int + Removed bool // true if this branch has been dropped or folded +} + +// ApplyResult holds the outcome of applying modifications. +type ApplyResult struct { + Success bool + DroppedPRs []DroppedPR + RenamedBranches []RenamedBranch + MovedBranches int + Message string +} + +// DroppedPR records a PR that was dropped from the stack. +type DroppedPR struct { + Branch string + PRNumber int +} + +// RenamedBranch records a branch rename. +type RenamedBranch struct { + OldName string + NewName string +} + +// ConflictInfo holds information about a rebase conflict during apply. +type ConflictInfo struct { + Branch string + ConflictedFiles []string +} diff --git a/internal/tui/shared/header.go b/internal/tui/shared/header.go new file mode 100644 index 0000000..d686e3f --- /dev/null +++ b/internal/tui/shared/header.go @@ -0,0 +1,319 @@ +package shared + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// HeaderHeight is the total number of lines the header occupies. +const HeaderHeight = 12 + +// MinHeightForHeader is the minimum terminal height to show the header. +const MinHeightForHeader = 25 + +// MinWidthForShortcuts is the minimum width to show keyboard shortcuts. +const MinWidthForShortcuts = 65 + +// MinWidthForHeader is the minimum width to show the header at all. +const MinWidthForHeader = 50 + +// MinWidthForArt is the minimum width to show ASCII art in the header. +const MinWidthForArt = 88 + +// ShortcutEntry represents a keyboard shortcut for the header. +type ShortcutEntry struct { + Key string + Desc string + Disabled bool // when true, rendered in gray (dimmed) +} + +// HeaderInfoLine represents an info line in the header (icon + label). +type HeaderInfoLine struct { + Icon string + Label string + IconStyle *lipgloss.Style // optional override; nil uses default HeaderInfoStyle (cyan) +} + +// ArtLines is the braille ASCII art for the View header. +var ArtLines = [10]string{ + "⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀", + "⠀⢀⣼⣿⣿⠛⠛⠿⠿⠿⠿⠿⠿⠛⠛⣿⣿⣷⡀⠀", + "⠀⣾⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⡀", + "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", + "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", + "⠘⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢀⣤⣿⣿⣿⣿⠇", + "⠀⠹⣿⣦⡈⠻⢿⠟⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⠏⠀", + "⠀⠀⠈⠻⣷⣤⣀⡀⠀⠀⠀⠀⢸⣿⣿⣿⡿⠃⠀⠀", + "⠀⠀⠀⠀⠈⠙⠻⠇⠀⠀⠀⠀⠸⠟⠛⠁⠀⠀⠀⠀", +} + +// ArtDisplayWidth is the visual column width of each art line. +const ArtDisplayWidth = 20 + +// HeaderConfig controls what the header displays. +type HeaderConfig struct { + ShowArt bool // whether to display GitHub logo + Title string // heading next to logo art + Subtitle string // version string, or empty + InfoLines []HeaderInfoLine // info rows (stack info) + Shortcuts []ShortcutEntry // keyboard shortcuts + ShortcutColumns int // number of columns for shortcuts (default 1; set 2 for side-by-side) +} + +// ShouldShowHeader returns whether the header should be displayed. +func ShouldShowHeader(width, height int) bool { + return height >= MinHeightForHeader && width >= MinWidthForHeader +} + +// ShouldShowShortcuts returns whether shortcuts should be displayed. +func ShouldShowShortcuts(width int) bool { + return width >= MinWidthForShortcuts +} + +// RenderHeader renders the full-width header box. +// Progressive disclosure as width narrows: first hides the art, then the +// info text, keeping keyboard shortcuts always visible. +func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) { + if width < 2 { + return + } + innerWidth := width - 2 + + // Always build shortcut lines + type shortcutLine struct { + text string + visWidth int + } + var shortcuts []shortcutLine + maxShortcutWidth := 0 + rightColWidth := 0 + + cols := cfg.ShortcutColumns + if cols < 1 { + cols = 1 + } + + if len(cfg.Shortcuts) > 0 { + if cols >= 2 { + // Two-column layout with aligned keys and descriptions. + // First pass: compute max visual key width per column. + maxKeyLeft := 0 + maxKeyRight := 0 + for i := 0; i < len(cfg.Shortcuts); i += 2 { + kw := lipgloss.Width(cfg.Shortcuts[i].Key) + if kw > maxKeyLeft { + maxKeyLeft = kw + } + if i+1 < len(cfg.Shortcuts) { + kw = lipgloss.Width(cfg.Shortcuts[i+1].Key) + if kw > maxKeyRight { + maxKeyRight = kw + } + } + } + + // Second pass: compute max visual width of the left column + // so the right column starts at a consistent position. + maxLeftWidth := 0 + for i := 0; i < len(cfg.Shortcuts); i += 2 { + left := renderShortcutEntryPadded(cfg.Shortcuts[i], maxKeyLeft) + lw := lipgloss.Width(left) + if lw > maxLeftWidth { + maxLeftWidth = lw + } + } + + colGap := " " + colGapWidth := lipgloss.Width(colGap) + for i := 0; i < len(cfg.Shortcuts); i += 2 { + left := renderShortcutEntryPadded(cfg.Shortcuts[i], maxKeyLeft) + // Pad left entry to maxLeftWidth for consistent right column start + leftPad := maxLeftWidth - lipgloss.Width(left) + if leftPad < 0 { + leftPad = 0 + } + line := left + strings.Repeat(" ", leftPad) + if i+1 < len(cfg.Shortcuts) { + right := renderShortcutEntryPadded(cfg.Shortcuts[i+1], maxKeyRight) + line = line + colGap + right + } + visW := lipgloss.Width(line) + // Account for column gap width in case right column is missing + if i+1 >= len(cfg.Shortcuts) { + visW = maxLeftWidth + colGapWidth + maxKeyRight + 10 // approximate + } + shortcuts = append(shortcuts, shortcutLine{text: line, visWidth: visW}) + if visW > maxShortcutWidth { + maxShortcutWidth = visW + } + } + } else { + // Single-column layout with aligned keys. + maxKeyW := 0 + for _, sc := range cfg.Shortcuts { + kw := lipgloss.Width(sc.Key) + if kw > maxKeyW { + maxKeyW = kw + } + } + for _, sc := range cfg.Shortcuts { + rendered := renderShortcutEntryPadded(sc, maxKeyW) + visW := lipgloss.Width(rendered) + shortcuts = append(shortcuts, shortcutLine{text: rendered, visWidth: visW}) + if visW > maxShortcutWidth { + maxShortcutWidth = visW + } + } + } + rightColWidth = maxShortcutWidth + 2 + } + + // Determine what fits: shortcuts always shown, art and info are progressive. + // Hide art first (below 88 cols), then info text, as width narrows. + showArt := cfg.ShowArt + showInfo := true + + // Hide art when viewport is too narrow for art + info + shortcuts + if showArt && width < MinWidthForArt { + showArt = false + } + + // If info + shortcuts don't fit, hide info + infoMinWidth := 20 // rough minimum for title/info text + if innerWidth < rightColWidth+infoMinWidth+4 { + showInfo = false + } + + // Map info lines to row indices + infoByRow := make(map[int]string) + if showInfo { + infoByRow[2] = HeaderTitleStyle.Render(cfg.Title) + if cfg.Subtitle != "" { + infoByRow[3] = HeaderInfoLabelStyle.Render(cfg.Subtitle) + } + for i, info := range cfg.InfoLines { + row := 5 + i + if row > 9 { + break + } + iconStyle := HeaderInfoStyle + if info.IconStyle != nil { + iconStyle = *info.IconStyle + } + infoByRow[row] = iconStyle.Render(info.Icon) + HeaderInfoLabelStyle.Render(" "+info.Label) + } + } + + // Left content base width + leftContentBase := 1 // margin + if showArt { + leftContentBase += ArtDisplayWidth + } + + // Vertically center shortcuts + scStartRow := 0 + if len(shortcuts) > 0 { + scStartRow = (10 - len(shortcuts)) / 2 + } + + gap := " " + + // Top border + b.WriteString(HeaderBorderStyle.Render("┌" + strings.Repeat("─", innerWidth) + "┐")) + b.WriteString("\n") + + // Content rows + for i := 0; i < 10; i++ { + // Left column: art (optional) + info + artText := "" + if showArt { + artText = ArtLines[i] + } + + infoText := "" + infoVisualLen := 0 + if info, ok := infoByRow[i]; ok { + infoText = gap + info + infoVisualLen = 2 + lipgloss.Width(info) + } + + leftUsed := leftContentBase + infoVisualLen + + if len(shortcuts) > 0 { + shortcutCol := innerWidth - rightColWidth + midPad := shortcutCol - leftUsed + if midPad < 0 { + midPad = 0 + } + + scIdx := i - scStartRow + shortcutRendered := "" + scVisWidth := 0 + if scIdx >= 0 && scIdx < len(shortcuts) { + shortcutRendered = shortcuts[scIdx].text + scVisWidth = shortcuts[scIdx].visWidth + } + scTrailingPad := rightColWidth - scVisWidth + if scTrailingPad < 0 { + scTrailingPad = 0 + } + + b.WriteString(HeaderBorderStyle.Render("│")) + b.WriteString(" ") + if showArt { + b.WriteString(artText) + } + b.WriteString(infoText) + b.WriteString(strings.Repeat(" ", midPad)) + b.WriteString(shortcutRendered) + b.WriteString(strings.Repeat(" ", scTrailingPad)) + b.WriteString(HeaderBorderStyle.Render("│")) + } else { + trailingPad := innerWidth - leftUsed + if trailingPad < 0 { + trailingPad = 0 + } + + b.WriteString(HeaderBorderStyle.Render("│")) + b.WriteString(" ") + if showArt { + b.WriteString(artText) + } + b.WriteString(infoText) + b.WriteString(strings.Repeat(" ", trailingPad)) + b.WriteString(HeaderBorderStyle.Render("│")) + } + b.WriteString("\n") + } + + // Bottom border + b.WriteString(HeaderBorderStyle.Render("└" + strings.Repeat("─", innerWidth) + "┘")) + b.WriteString("\n") +} + +// disabledShortcutStyle renders both key and desc in dim gray. +var disabledShortcutStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + +// renderShortcutEntry renders a single shortcut, dimmed if disabled. +func renderShortcutEntry(sc ShortcutEntry) string { + if sc.Disabled { + return disabledShortcutStyle.Render(sc.Key + " " + sc.Desc) + } + return HeaderShortcutKey.Render(sc.Key) + HeaderShortcutDesc.Render(" "+sc.Desc) +} + +// renderShortcutEntryPadded renders a shortcut with the key right-padded +// to keyWidth visual columns so descriptions align across rows. +func renderShortcutEntryPadded(sc ShortcutEntry, keyWidth int) string { + keyVisWidth := lipgloss.Width(sc.Key) + pad := "" + if keyVisWidth < keyWidth { + pad = strings.Repeat(" ", keyWidth-keyVisWidth) + } + if sc.Disabled { + return disabledShortcutStyle.Render(sc.Key) + pad + disabledShortcutStyle.Render(" "+sc.Desc) + } + return HeaderShortcutKey.Render(sc.Key) + pad + HeaderShortcutDesc.Render(" "+sc.Desc) +} diff --git a/internal/tui/shared/render.go b/internal/tui/shared/render.go new file mode 100644 index 0000000..65bacf2 --- /dev/null +++ b/internal/tui/shared/render.go @@ -0,0 +1,529 @@ +package shared + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/git" + ghapi "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" +) + +// BranchNodeData is the interface for branch data that can be rendered. +// Both stackview.BranchNode and modifyview.ModifyBranchNode satisfy this. +type BranchNodeData struct { + Ref stack.BranchRef + IsCurrent bool + IsLinear bool + BaseBranch string + Commits []git.CommitInfo + FilesChanged []git.FileDiffStat + PR *ghapi.PRDetails + Additions int + Deletions int + CommitsExpanded bool + FilesExpanded bool + + // ShowCurrentLabel controls whether "(current)" is appended and cyan + // styling is used for the current branch. View sets this true; Modify + // leaves it false so all branches look uniform. + ShowCurrentLabel bool + + // BranchNameStyleOverride, when non-nil, overrides the default branch + // name style. Used by Modify to render dropped branches in red + // strikethrough and folded branches in yellow strikethrough. + BranchNameStyleOverride *lipgloss.Style + + // ForceDashedConnector, when true, forces the connector line to use + // the dashed style (┊) regardless of linearity. Used by Modify for + // branches marked for drop or fold. + ForceDashedConnector bool + + // ConnectorStyleOverride, when non-nil, overrides the default connector + // color for dashed lines. Used to make drop connectors red and fold + // connectors yellow. + ConnectorStyleOverride *lipgloss.Style +} + +// NodeAnnotation is an optional annotation to display after the branch info. +type NodeAnnotation struct { + Text string + Style lipgloss.Style +} + +// ResolveConnectorStyle determines the connector character and style for a node. +func ResolveConnectorStyle(node BranchNodeData, isFocused bool) (string, lipgloss.Style) { + connector := "│" + connStyle := ConnectorStyle + isMerged := node.Ref.IsMerged() + isQueued := node.Ref.IsQueued() + if node.ForceDashedConnector || (!node.IsLinear && !isMerged && !isQueued) { + connector = "┊" + if node.ConnectorStyleOverride == nil { + connStyle = ConnectorDashedStyle + } + } + // Apply explicit connector color override (drop=red, fold=yellow, moved=magenta) + if node.ConnectorStyleOverride != nil { + connStyle = *node.ConnectorStyleOverride + } + if isFocused && node.ConnectorStyleOverride == nil { + if node.IsCurrent && node.ShowCurrentLabel { + connStyle = ConnectorCurrentStyle + } else if isMerged { + connStyle = ConnectorMergedStyle + } else if isQueued { + connStyle = ConnectorQueuedStyle + } else { + connStyle = ConnectorFocusedStyle + } + } + return connector, connStyle +} + +// StatusIcon returns the appropriate status icon for a branch. +func StatusIcon(node BranchNodeData) string { + if node.Ref.IsMerged() { + return MergedIcon + } + if node.Ref.IsQueued() { + return QueuedIcon + } + if !node.IsLinear { + return WarningIcon + } + if node.PR != nil && node.PR.Number != 0 { + return OpenIcon + } + return "" +} + +// RenderNode renders a single branch node with all its sub-sections. +// annotation is optional — pass nil for plain view, or a NodeAnnotation to add a badge. +func RenderNode(b *strings.Builder, node BranchNodeData, isFocused bool, width int, annotation *NodeAnnotation) { + connector, connStyle := ResolveConnectorStyle(node, isFocused) + + if node.PR != nil { + RenderPRHeader(b, node, isFocused, connStyle, annotation) + RenderBranchLine(b, node, connector, connStyle, nil) // annotation already on PR line + } else { + RenderBranchHeader(b, node, isFocused, connStyle, annotation) + } + + if len(node.FilesChanged) > 0 { + RenderFiles(b, node, connector, connStyle, width) + } + + if len(node.Commits) > 0 { + RenderCommits(b, node, connector, connStyle, width) + } + + // Connector/spacer + b.WriteString(connStyle.Render(connector)) + b.WriteString("\n") +} + +// RenderPRHeader renders the top line with PR info: bullet + status icon + PR# + state + optional annotation. +func RenderPRHeader(b *strings.Builder, node BranchNodeData, isFocused bool, connStyle lipgloss.Style, annotation *NodeAnnotation) { + bullet := "├" + if isFocused { + bullet = "▶" + } + b.WriteString(connStyle.Render(bullet + " ")) + + icon := StatusIcon(node) + if icon != "" { + b.WriteString(icon + " ") + } + + pr := node.PR + prLabel := fmt.Sprintf("#%d", pr.Number) + stateLabel := "" + style := PROpenStyle + switch { + case pr.Merged: + stateLabel = " MERGED" + style = PRMergedStyle + case pr.IsQueued: + stateLabel = " QUEUED" + style = PRQueuedStyle + case pr.State == "CLOSED": + stateLabel = " CLOSED" + style = PRClosedStyle + case pr.IsDraft: + stateLabel = " DRAFT" + style = PRDraftStyle + default: + stateLabel = " OPEN" + } + b.WriteString(PRLinkStyle.Render(prLabel) + style.Render(stateLabel)) + + if annotation != nil { + b.WriteString(" ") + b.WriteString(annotation.Style.Render(annotation.Text)) + } + + b.WriteString("\n") +} + +// RenderBranchLine renders branch name + diff stats below a PR header. +func RenderBranchLine(b *strings.Builder, node BranchNodeData, connector string, connStyle lipgloss.Style, annotation *NodeAnnotation) { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + b.WriteString(renderBranchName(node)) + + RenderDiffStats(b, node) + + if annotation != nil { + b.WriteString(" ") + b.WriteString(annotation.Style.Render(annotation.Text)) + } + + b.WriteString("\n") +} + +// RenderBranchHeader renders header when no PR exists: bullet + icon + branch + stats + annotation. +func RenderBranchHeader(b *strings.Builder, node BranchNodeData, isFocused bool, connStyle lipgloss.Style, annotation *NodeAnnotation) { + bullet := "├" + if isFocused { + bullet = "▶" + } + b.WriteString(connStyle.Render(bullet + " ")) + + icon := StatusIcon(node) + if icon != "" { + b.WriteString(icon + " ") + } + + b.WriteString(renderBranchName(node)) + + RenderDiffStats(b, node) + + if annotation != nil { + b.WriteString(" ") + b.WriteString(annotation.Style.Render(annotation.Text)) + } + + b.WriteString("\n") +} + +// RenderDiffStats appends +N -N diff stats. +func RenderDiffStats(b *strings.Builder, node BranchNodeData) { + if node.Additions > 0 || node.Deletions > 0 { + b.WriteString(" ") + b.WriteString(AdditionsStyle.Render(fmt.Sprintf("+%d", node.Additions))) + b.WriteString(" ") + b.WriteString(DeletionsStyle.Render(fmt.Sprintf("-%d", node.Deletions))) + } +} + +// renderBranchName returns the styled branch name string based on node settings. +func renderBranchName(node BranchNodeData) string { + name := node.Ref.Branch + if node.BranchNameStyleOverride != nil { + return node.BranchNameStyleOverride.Render(name) + } + if node.IsCurrent && node.ShowCurrentLabel { + return CurrentBranchStyle.Render(name + " (current)") + } + return NormalBranchStyle.Render(name) +} + +// RenderFiles renders the files toggle and optionally expanded file list. +func RenderFiles(b *strings.Builder, node BranchNodeData, connector string, connStyle lipgloss.Style, width int) { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + icon := CollapsedIcon + if node.FilesExpanded { + icon = ExpandedIcon + } + fileLabel := "files changed" + if len(node.FilesChanged) == 1 { + fileLabel = "file changed" + } + b.WriteString(CommitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.FilesChanged), fileLabel))) + b.WriteString("\n") + + if !node.FilesExpanded { + return + } + + for _, f := range node.FilesChanged { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + path := f.Path + maxLen := width - 30 + if maxLen < 20 { + maxLen = 20 + } + if len(path) > maxLen { + path = "…" + path[len(path)-maxLen+1:] + } + b.WriteString(NormalBranchStyle.Render(path)) + b.WriteString(" ") + b.WriteString(AdditionsStyle.Render(fmt.Sprintf("+%d", f.Additions))) + b.WriteString(" ") + b.WriteString(DeletionsStyle.Render(fmt.Sprintf("-%d", f.Deletions))) + b.WriteString("\n") + } +} + +// RenderCommits renders the commits toggle and optionally expanded commits. +func RenderCommits(b *strings.Builder, node BranchNodeData, connector string, connStyle lipgloss.Style, width int) { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + icon := CollapsedIcon + if node.CommitsExpanded { + icon = ExpandedIcon + } + commitLabel := "commits" + if len(node.Commits) == 1 { + commitLabel = "commit" + } + b.WriteString(CommitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.Commits), commitLabel))) + b.WriteString("\n") + + if !node.CommitsExpanded { + return + } + + for _, c := range node.Commits { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + sha := c.SHA + if len(sha) > 7 { + sha = sha[:7] + } + b.WriteString(CommitSHAStyle.Render(sha)) + b.WriteString(" ") + + subject := c.Subject + maxLen := width - 35 + if maxLen < 20 { + maxLen = 20 + } + if len(subject) > maxLen { + subject = subject[:maxLen-1] + "…" + } + b.WriteString(CommitSubjectStyle.Render(subject)) + b.WriteString(" ") + b.WriteString(CommitTimeStyle.Render(TimeAgo(c.Time))) + b.WriteString("\n") + } +} + +// NodeLineCount returns how many rendered lines a node occupies. +func NodeLineCount(node BranchNodeData) int { + lines := 1 // header line + if node.PR != nil { + lines++ // branch + diff stats line below PR header + } + if len(node.FilesChanged) > 0 { + lines++ // files toggle + if node.FilesExpanded { + lines += len(node.FilesChanged) + } + } + if len(node.Commits) > 0 { + lines++ // commits toggle + if node.CommitsExpanded { + lines += len(node.Commits) + } + } + lines++ // connector/spacer + return lines +} + +// RenderTrunk renders the trunk line. +func RenderTrunk(b *strings.Builder, trunkBranch string) { + b.WriteString(ConnectorStyle.Render("└ ")) + b.WriteString(TrunkStyle.Render(trunkBranch)) + b.WriteString("\n") +} + +// RenderMergedSeparator renders the merged section separator. +func RenderMergedSeparator(b *strings.Builder) { + b.WriteString(ConnectorStyle.Render("────") + DimStyle.Render(" merged ") + ConnectorStyle.Render("─────") + "\n") +} + +// RenderQueuedSeparator renders the queued section separator. +func RenderQueuedSeparator(b *strings.Builder) { + b.WriteString(ConnectorStyle.Render("────") + DimStyle.Render(" queued ") + ConnectorStyle.Render("─────") + "\n") +} + +// TimeAgo returns a human-readable time-ago string. +func TimeAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + secs := int(d.Seconds()) + if secs == 1 { + return "1 second ago" + } + return fmt.Sprintf("%d seconds ago", secs) + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case d < 30*24*time.Hour: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + default: + months := int(d.Hours() / 24 / 30) + if months <= 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + } +} + +// --- Mouse click helpers --- + +// ClickResult describes what happened when a node was clicked. +type ClickResult struct { + NodeIndex int // which node was clicked (-1 if none) + ToggleFiles bool // should toggle files expansion + ToggleCommits bool // should toggle commits expansion + OpenURL string // URL to open in browser (empty if none) +} + +// HandleClick maps a screen click to a node action. +// nodes is the list of BranchNodeData in display order. +// showHeader indicates whether the header is visible. +// scrollOffset is the current scroll position. +// hasSeparators controls whether merged/queued separator lines are accounted for. +func HandleClick(screenX, screenY int, nodes []BranchNodeData, width, height, scrollOffset int, showHeader, hasSeparators bool) ClickResult { + yOffset := 0 + if showHeader { + if screenY < HeaderHeight { + return ClickResult{NodeIndex: -1} + } + yOffset = HeaderHeight + } + + contentLine := (screenY - yOffset) + scrollOffset + + line := 0 + prevWasMerged := false + prevWasQueued := false + for i := 0; i < len(nodes); i++ { + if hasSeparators { + isMerged := nodes[i].Ref.IsMerged() + isQueued := nodes[i].Ref.IsQueued() + if isMerged && !prevWasMerged && i > 0 { + line++ + } else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 { + line++ + } + prevWasMerged = isMerged + prevWasQueued = isQueued + } + + nodeStart := line + nodeLines := NodeLineCount(nodes[i]) + + if contentLine >= nodeStart && contentLine < nodeStart+nodeLines { + result := ClickResult{NodeIndex: i} + + // Click on PR header line — check if clicking the PR number + if contentLine == nodeStart && nodes[i].PR != nil && nodes[i].PR.URL != "" { + prStartX, prEndX := PRLabelColumns(nodes[i]) + if screenX >= prStartX && screenX < prEndX { + result.OpenURL = nodes[i].PR.URL + } + } + + // Click on files toggle line + if len(nodes[i].FilesChanged) > 0 { + if contentLine == nodeStart+FilesToggleLineOffset(nodes[i]) { + result.ToggleFiles = true + } + } + + // Click on commits toggle line + if len(nodes[i].Commits) > 0 { + if contentLine == nodeStart+CommitToggleLineOffset(nodes[i]) { + result.ToggleCommits = true + } + } + + return result + } + line += nodeLines + } + + return ClickResult{NodeIndex: -1} +} + +// FilesToggleLineOffset returns the line offset from node start to the files toggle. +func FilesToggleLineOffset(node BranchNodeData) int { + offset := 1 // after header + if node.PR != nil { + offset++ + } + return offset +} + +// CommitToggleLineOffset returns the line offset from node start to the commits toggle. +func CommitToggleLineOffset(node BranchNodeData) int { + offset := 1 + if node.PR != nil { + offset++ + } + if len(node.FilesChanged) > 0 { + offset++ + if node.FilesExpanded { + offset += len(node.FilesChanged) + } + } + return offset +} + +// PRLabelColumns returns the start and end X columns of the PR number label. +func PRLabelColumns(node BranchNodeData) (int, int) { + col := 2 // bullet + space + icon := StatusIcon(node) + if icon != "" { + col += 2 + } + prLabel := fmt.Sprintf("#%d", node.PR.Number) + return col, col + len(prLabel) +} + +// OpenBrowserInBackground launches the system browser for the given URL. +func OpenBrowserInBackground(url string) { + cmd := BrowserCmd(url) + _ = cmd.Start() +} + +// BrowserCmd returns an exec.Cmd to open a URL in the default browser. +func BrowserCmd(url string) *exec.Cmd { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url) + case "windows": + return exec.Command("cmd", "/c", "start", url) + default: + return exec.Command("xdg-open", url) + } +} diff --git a/internal/tui/shared/scroll.go b/internal/tui/shared/scroll.go new file mode 100644 index 0000000..936035e --- /dev/null +++ b/internal/tui/shared/scroll.go @@ -0,0 +1,51 @@ +package shared + +import "strings" + +// ClampScroll ensures scrollOffset doesn't exceed content bounds. +func ClampScroll(totalLines, viewHeight, scrollOffset int) int { + maxScroll := totalLines - viewHeight + if maxScroll < 0 { + maxScroll = 0 + } + if scrollOffset > maxScroll { + scrollOffset = maxScroll + } + if scrollOffset < 0 { + scrollOffset = 0 + } + return scrollOffset +} + +// EnsureVisible adjusts scrollOffset so the cursor's line range is visible. +func EnsureVisible(startLine, endLine, scrollOffset, viewHeight int) int { + if viewHeight < 1 { + viewHeight = 1 + } + if startLine < scrollOffset { + scrollOffset = startLine + } + if endLine > scrollOffset+viewHeight { + scrollOffset = endLine - viewHeight + } + return scrollOffset +} + +// ApplyScrollToContent takes rendered content, splits into lines, applies +// scroll offset, and returns the visible portion as a string. +func ApplyScrollToContent(content string, scrollOffset, viewHeight int) string { + lines := strings.Split(content, "\n") + maxScroll := len(lines) - viewHeight + if maxScroll < 0 { + maxScroll = 0 + } + start := scrollOffset + if start > maxScroll { + start = maxScroll + } + end := start + viewHeight + if end > len(lines) { + end = len(lines) + } + return strings.Join(lines[start:end], "\n") +} diff --git a/internal/tui/shared/styles.go b/internal/tui/shared/styles.go new file mode 100644 index 0000000..f3a4430 --- /dev/null +++ b/internal/tui/shared/styles.go @@ -0,0 +1,57 @@ +package shared + +import "github.com/charmbracelet/lipgloss" + +var ( + // Branch name styles + CurrentBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) + NormalBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + MergedBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + TrunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) + + // Status indicators + MergedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Render("✓") + WarningIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("⚠") + OpenIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("○") + QueuedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("130")).Render("◎") + + // PR status styles + PRLinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Underline(true) + PROpenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + PRMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) + PRClosedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + PRDraftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + PRQueuedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")) + + // Diff stats + AdditionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + DeletionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + + // Commit lines + CommitSHAStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + CommitSubjectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + CommitTimeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + // Connector lines + ConnectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + ConnectorDashedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + ConnectorFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + ConnectorCurrentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + ConnectorMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) + ConnectorQueuedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")) + + // Dim text + DimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + + // Header styles + HeaderBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + HeaderTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + HeaderInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + HeaderInfoLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + HeaderShortcutKey = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + HeaderShortcutDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + // Expand/collapse icons + ExpandedIcon = "▾" + CollapsedIcon = "▸" +) diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index 8f845f9..7c7159c 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -2,16 +2,13 @@ package stackview import ( "fmt" - "os/exec" - "runtime" "strings" - "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/shared" ) // keyMap defines the key bindings for the stack view. @@ -64,36 +61,6 @@ var keys = keyMap{ ), } -// headerHeight is the total number of lines the header box occupies (top border + 10 art lines + bottom border). -const headerHeight = 12 - -// minHeightForHeader is the minimum terminal height to show the header. -const minHeightForHeader = 25 - -// minWidthForShortcuts is the minimum terminal width to show keyboard shortcuts in the header. -// Below this, the header is shown without the right-side shortcuts column. -const minWidthForShortcuts = 65 - -// minWidthForHeader is the minimum terminal width to show the header at all. -const minWidthForHeader = 50 - -// artLines contains the braille ASCII art displayed in the header. -var artLines = [10]string{ - "⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀", - "⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀", - "⠀⢀⣼⣿⣿⠛⠛⠿⠿⠿⠿⠿⠿⠛⠛⣿⣿⣷⡀⠀", - "⠀⣾⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⡀", - "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", - "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", - "⠘⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢀⣤⣿⣿⣿⣿⠇", - "⠀⠹⣿⣦⡈⠻⢿⠟⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⠏⠀", - "⠀⠀⠈⠻⣷⣤⣀⡀⠀⠀⠀⠀⢸⣿⣿⣿⡿⠃⠀⠀", - "⠀⠀⠀⠀⠈⠙⠻⠇⠀⠀⠀⠀⠸⠟⠛⠁⠀⠀⠀⠀", -} - -// artDisplayWidth is the visual column width of each art line. -const artDisplayWidth = 20 - // Model is the Bubbletea model for the interactive stack view. type Model struct { nodes []BranchNode @@ -190,7 +157,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor >= 0 && m.cursor < len(m.nodes) { node := m.nodes[m.cursor] if node.PR != nil && node.PR.URL != "" { - openBrowserInBackground(node.PR.URL) + shared.OpenBrowserInBackground(node.PR.URL) } } return m, nil @@ -229,147 +196,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// openBrowserInBackground launches the system browser for the given URL. -func openBrowserInBackground(url string) { - cmd := browserCmd(url) - _ = cmd.Start() +// toBranchNodeData converts a BranchNode to shared.BranchNodeData. +func toBranchNodeData(node BranchNode) shared.BranchNodeData { + return shared.BranchNodeData{ + Ref: node.Ref, + IsCurrent: node.IsCurrent, + IsLinear: node.IsLinear, + BaseBranch: node.BaseBranch, + Commits: node.Commits, + FilesChanged: node.FilesChanged, + PR: node.PR, + Additions: node.Additions, + Deletions: node.Deletions, + CommitsExpanded: node.CommitsExpanded, + FilesExpanded: node.FilesExpanded, + ShowCurrentLabel: true, + } } // handleMouseClick processes a mouse click at the given screen position. func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { - // If header is visible, clicks in the header area are ignored - yOffset := 0 - if m.showHeader() { - if screenY < headerHeight { - return m, nil - } - yOffset = headerHeight + nodes := make([]shared.BranchNodeData, len(m.nodes)) + for i, n := range m.nodes { + nodes[i] = toBranchNodeData(n) } - // Map screen Y to content line, accounting for scroll offset and header - contentLine := (screenY - yOffset) + m.scrollOffset - - // Walk through rendered lines to find which node was clicked. - // Account for the merged/queued separator lines that may appear between nodes. - line := 0 - prevWasMerged := false - prevWasQueued := false - for i := 0; i < len(m.nodes); i++ { - isMerged := m.nodes[i].Ref.IsMerged() - isQueued := m.nodes[i].Ref.IsQueued() - if isMerged && !prevWasMerged && i > 0 { - line++ // separator line - } else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 { - line++ // separator line - } - prevWasMerged = isMerged - prevWasQueued = isQueued - - nodeStart := line - nodeLines := m.nodeLineCount(i) - - if contentLine >= nodeStart && contentLine < nodeStart+nodeLines { - m.cursor = i - - // Click on PR header line — only open browser if clicking the PR number - if contentLine == nodeStart && m.nodes[i].PR != nil && m.nodes[i].PR.URL != "" { - prStartX, prEndX := m.prLabelColumns(i) - if screenX >= prStartX && screenX < prEndX { - openBrowserInBackground(m.nodes[i].PR.URL) - } - } - - // Click on files toggle line → toggle expansion - if len(m.nodes[i].FilesChanged) > 0 { - filesToggleLine := nodeStart + m.filesToggleLineOffset(i) - if contentLine == filesToggleLine { - m.nodes[i].FilesExpanded = !m.nodes[i].FilesExpanded - m.clampScroll() - } - } - - // Click on commits toggle line → toggle expansion - if len(m.nodes[i].Commits) > 0 { - commitToggleLine := nodeStart + m.commitToggleLineOffset(i) - if contentLine == commitToggleLine { - m.nodes[i].CommitsExpanded = !m.nodes[i].CommitsExpanded - m.clampScroll() - } - } - - return m, nil - } - line += nodeLines + result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, shared.ShouldShowHeader(m.width, m.height), true) + if result.NodeIndex < 0 { + return m, nil } - return m, nil -} + m.cursor = result.NodeIndex -// nodeLineCount returns how many rendered lines a node occupies. -func (m Model) nodeLineCount(idx int) int { - node := m.nodes[idx] - lines := 1 // header line (PR line or branch line) - - if node.PR != nil { - lines++ // branch + diff stats line (below PR header) + if result.OpenURL != "" { + shared.OpenBrowserInBackground(result.OpenURL) } - - if len(node.FilesChanged) > 0 { - lines++ // files toggle line - if node.FilesExpanded { - lines += len(node.FilesChanged) - } + if result.ToggleFiles { + m.nodes[result.NodeIndex].FilesExpanded = !m.nodes[result.NodeIndex].FilesExpanded + m.clampScroll() } - - if len(node.Commits) > 0 { - lines++ // commits toggle line - if node.CommitsExpanded { - lines += len(node.Commits) - } + if result.ToggleCommits { + m.nodes[result.NodeIndex].CommitsExpanded = !m.nodes[result.NodeIndex].CommitsExpanded + m.clampScroll() } - lines++ // connector/spacer line - return lines -} - -// commitToggleLineOffset returns the offset from node start to the commits toggle line. -func (m Model) commitToggleLineOffset(idx int) int { - node := m.nodes[idx] - offset := 1 // after header - if node.PR != nil { - offset++ // branch + diff line - } - if len(node.FilesChanged) > 0 { - offset++ // files toggle line - if node.FilesExpanded { - offset += len(node.FilesChanged) - } - } - return offset -} - -// filesToggleLineOffset returns the offset from node start to the files toggle line. -func (m Model) filesToggleLineOffset(idx int) int { - node := m.nodes[idx] - offset := 1 // after header - if node.PR != nil { - offset++ // branch + diff line - } - return offset + return m, nil } -// prLabelColumns returns the start and end X columns of the PR number label -// (e.g. "#123") on the PR header line, for click hit-testing. -func (m Model) prLabelColumns(idx int) (int, int) { - node := m.nodes[idx] - // Layout: "├ " (2) + optional status icon + " " (2) + "#N..." - col := 2 // bullet + space - icon := m.statusIcon(node) - if icon != "" { - col += 2 // icon (1 visible char) + space - } - prLabel := fmt.Sprintf("#%d", node.PR.Number) - return col, col + len(prLabel) +// nodeLineCount returns how many rendered lines a node occupies. +func (m Model) nodeLineCount(idx int) int { + return shared.NodeLineCount(toBranchNodeData(m.nodes[idx])) } // ensureVisible adjusts scroll offset so the cursor is visible. @@ -406,28 +282,8 @@ func (m *Model) ensureVisible() { } endLine := startLine + m.nodeLineCount(m.cursor) - // Available content height (reserve space for header or help bar) viewHeight := m.contentViewHeight() - if viewHeight < 1 { - viewHeight = 1 - } - - if startLine < m.scrollOffset { - m.scrollOffset = startLine - } - if endLine > m.scrollOffset+viewHeight { - m.scrollOffset = endLine - viewHeight - } -} - -// showHeader returns true if the terminal is large enough for the header. -func (m Model) showHeader() bool { - return m.height >= minHeightForHeader && m.width >= minWidthForHeader -} - -// showShortcuts returns true if the terminal is wide enough for the shortcuts column in the header. -func (m Model) showShortcuts() bool { - return m.width >= minWidthForShortcuts + m.scrollOffset = shared.EnsureVisible(startLine, endLine, m.scrollOffset, viewHeight) } // totalContentLines returns the total number of rendered content lines (excluding header). @@ -454,8 +310,8 @@ func (m Model) totalContentLines() int { // contentViewHeight returns the number of lines available for stack content. func (m Model) contentViewHeight() int { reserved := 0 - if m.showHeader() { - reserved = headerHeight + if shared.ShouldShowHeader(m.width, m.height) { + reserved = shared.HeaderHeight } h := m.height - reserved if h < 1 { @@ -466,16 +322,7 @@ func (m Model) contentViewHeight() int { // clampScroll ensures scrollOffset doesn't exceed content bounds. func (m *Model) clampScroll() { - maxScroll := m.totalContentLines() - m.contentViewHeight() - if maxScroll < 0 { - maxScroll = 0 - } - if m.scrollOffset > maxScroll { - m.scrollOffset = maxScroll - } - if m.scrollOffset < 0 { - m.scrollOffset = 0 - } + m.scrollOffset = shared.ClampScroll(m.totalContentLines(), m.contentViewHeight(), m.scrollOffset) } func (m Model) View() string { @@ -485,9 +332,9 @@ func (m Model) View() string { var out strings.Builder - showHeader := m.showHeader() + showHeader := shared.ShouldShowHeader(m.width, m.height) if showHeader { - m.renderHeader(&out) + shared.RenderHeader(&out, m.buildHeaderConfig(), m.width, m.height) } var b strings.Builder @@ -499,9 +346,9 @@ func (m Model) View() string { isMerged := m.nodes[i].Ref.IsMerged() isQueued := m.nodes[i].Ref.IsQueued() if isMerged && !prevWasMerged && i > 0 { - b.WriteString(connectorStyle.Render("────") + dimStyle.Render(" merged ") + connectorStyle.Render("─────") + "\n") + shared.RenderMergedSeparator(&b) } else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 { - b.WriteString(connectorStyle.Render("────") + dimStyle.Render(" queued ") + connectorStyle.Render("─────") + "\n") + shared.RenderQueuedSeparator(&b) } m.renderNode(&b, i) prevWasMerged = isMerged @@ -509,52 +356,27 @@ func (m Model) View() string { } // Trunk - b.WriteString(connectorStyle.Render("└ ")) - b.WriteString(trunkStyle.Render(m.trunk.Branch)) - b.WriteString("\n") + shared.RenderTrunk(&b, m.trunk.Branch) content := b.String() - contentLines := strings.Split(content, "\n") // Apply scrolling reservedLines := 0 if showHeader { - reservedLines = headerHeight + reservedLines = shared.HeaderHeight } viewHeight := m.height - reservedLines if viewHeight < 1 { viewHeight = 1 } - // Clamp scroll offset so we can't scroll past content - maxScroll := len(contentLines) - viewHeight - if maxScroll < 0 { - maxScroll = 0 - } - start := m.scrollOffset - if start > maxScroll { - start = maxScroll - } - end := start + viewHeight - if end > len(contentLines) { - end = len(contentLines) - } - - visibleContent := strings.Join(contentLines[start:end], "\n") - out.WriteString(visibleContent) + out.WriteString(shared.ApplyScrollToContent(content, m.scrollOffset, viewHeight)) return out.String() } -// renderHeader renders the full-width stylized header box with ASCII art, stack info, and keyboard shortcuts. -func (m Model) renderHeader(b *strings.Builder) { - w := m.width - if w < 2 { - return - } - innerWidth := w - 2 // subtract left and right border chars - - // Build info lines (placed to the right of art on specific rows) +// buildHeaderConfig produces the header configuration for the stack view. +func (m Model) buildHeaderConfig() shared.HeaderConfig { mergedCount := 0 queuedCount := 0 for _, n := range m.nodes { @@ -565,6 +387,7 @@ func (m Model) renderHeader(b *strings.Builder) { queuedCount++ } } + branchCount := len(m.nodes) branchInfo := fmt.Sprintf("%d branches", branchCount) if branchCount == 1 { @@ -577,7 +400,6 @@ func (m Model) renderHeader(b *strings.Builder) { branchInfo += fmt.Sprintf(" (%d queued)", queuedCount) } - // Branch progress icon: ○ none merged, ◐ some merged, ● all merged branchIcon := "○" if mergedCount > 0 && mergedCount < branchCount { branchIcon = "◐" @@ -585,416 +407,31 @@ func (m Model) renderHeader(b *strings.Builder) { branchIcon = "●" } - // Info text mapped to art row indices (0-based) - infoByRow := map[int]string{ - 2: headerTitleStyle.Render("GitHub Stacks"), - 3: headerInfoLabelStyle.Render("v" + m.version), - 5: headerInfoStyle.Render("✓") + headerInfoLabelStyle.Render(" Stack initialized"), - 6: headerInfoStyle.Render("◆") + headerInfoLabelStyle.Render(" Base: "+m.trunk.Branch), - 7: headerInfoStyle.Render(branchIcon) + headerInfoLabelStyle.Render(" "+branchInfo), - } - - showShortcuts := m.showShortcuts() - - // Build shortcut lines (rendered content + visual widths) - type shortcutLine struct { - text string - visWidth int - } - var shortcuts []shortcutLine - maxShortcutWidth := 0 - rightColWidth := 0 - - if showShortcuts { - shortcuts = []shortcutLine{ - {headerShortcutKey.Render("↑") + headerShortcutDesc.Render(" up ") + - headerShortcutKey.Render("↓") + headerShortcutDesc.Render(" down"), 0}, - {headerShortcutKey.Render("c") + headerShortcutDesc.Render(" commits"), 0}, - {headerShortcutKey.Render("f") + headerShortcutDesc.Render(" files"), 0}, - {headerShortcutKey.Render("o") + headerShortcutDesc.Render(" open PR"), 0}, - {headerShortcutKey.Render("↵") + headerShortcutDesc.Render(" checkout"), 0}, - {headerShortcutKey.Render("q") + headerShortcutDesc.Render(" quit"), 0}, - } - for i := range shortcuts { - shortcuts[i].visWidth = lipgloss.Width(shortcuts[i].text) - if shortcuts[i].visWidth > maxShortcutWidth { - maxShortcutWidth = shortcuts[i].visWidth - } - } - rightColWidth = maxShortcutWidth + 2 - } - - // Left content base: 1 (margin) + artDisplayWidth - leftContentBase := 1 + artDisplayWidth - - // Vertically center shortcuts within the 10 content rows - scStartRow := 0 - if len(shortcuts) > 0 { - scStartRow = (10 - len(shortcuts)) / 2 - } - - // Top border - b.WriteString(headerBorderStyle.Render("┌" + strings.Repeat("─", innerWidth) + "┐")) - b.WriteString("\n") - - // Content rows - gap := " " // gap between art and info text - for i := 0; i < 10; i++ { - art := artLines[i] - - // Build info segment - infoText := "" - infoVisualLen := 0 - if info, ok := infoByRow[i]; ok { - infoText = gap + info - infoVisualLen = 2 + lipgloss.Width(info) - } - - leftUsed := leftContentBase + infoVisualLen - - if showShortcuts { - // Two-column layout: left (art+info) | right (shortcuts) - shortcutCol := innerWidth - rightColWidth - midPad := shortcutCol - leftUsed - if midPad < 0 { - midPad = 0 - } - - scIdx := i - scStartRow - shortcutRendered := "" - scVisWidth := 0 - if scIdx >= 0 && scIdx < len(shortcuts) { - shortcutRendered = shortcuts[scIdx].text - scVisWidth = shortcuts[scIdx].visWidth - } - scTrailingPad := rightColWidth - scVisWidth - if scTrailingPad < 0 { - scTrailingPad = 0 - } - - b.WriteString(headerBorderStyle.Render("│")) - b.WriteString(" ") - b.WriteString(art) - b.WriteString(infoText) - b.WriteString(strings.Repeat(" ", midPad)) - b.WriteString(shortcutRendered) - b.WriteString(strings.Repeat(" ", scTrailingPad)) - b.WriteString(headerBorderStyle.Render("│")) - } else { - // Single-column layout: art + info, padded to fill - trailingPad := innerWidth - leftUsed - if trailingPad < 0 { - trailingPad = 0 - } - - b.WriteString(headerBorderStyle.Render("│")) - b.WriteString(" ") - b.WriteString(art) - b.WriteString(infoText) - b.WriteString(strings.Repeat(" ", trailingPad)) - b.WriteString(headerBorderStyle.Render("│")) - } - b.WriteString("\n") + return shared.HeaderConfig{ + ShowArt: true, + Title: "View Stack", + Subtitle: "v" + m.version, + InfoLines: []shared.HeaderInfoLine{ + {Icon: "✓", Label: "Stack initialized"}, + {Icon: "◆", Label: "Base: " + m.trunk.Branch}, + {Icon: branchIcon, Label: branchInfo}, + }, + ShortcutColumns: 1, + Shortcuts: []shared.ShortcutEntry{ + {Key: "↑", Desc: "up"}, + {Key: "↓", Desc: "down"}, + {Key: "c", Desc: "commits"}, + {Key: "f", Desc: "files"}, + {Key: "o", Desc: "open PR"}, + {Key: "↵", Desc: "checkout"}, + {Key: "q", Desc: "quit"}, + }, } - - // Bottom border - b.WriteString(headerBorderStyle.Render("└" + strings.Repeat("─", innerWidth) + "┘")) - b.WriteString("\n") } // renderNode renders a single branch node. func (m Model) renderNode(b *strings.Builder, idx int) { node := m.nodes[idx] isFocused := idx == m.cursor - - // Determine connector character and style - connector := "│" - connStyle := connectorStyle - isMerged := node.Ref.IsMerged() - isQueued := node.Ref.IsQueued() - if !node.IsLinear && !isMerged && !isQueued { - connector = "┊" - connStyle = connectorDashedStyle - } - // Override style when this node is focused - if isFocused { - if node.IsCurrent { - connStyle = connectorCurrentStyle - } else if isMerged { - connStyle = connectorMergedStyle - } else if isQueued { - connStyle = connectorQueuedStyle - } else { - connStyle = connectorFocusedStyle - } - } - - // Render header: either PR line + branch line, or just branch line - if node.PR != nil { - m.renderPRHeader(b, node, isFocused, connStyle) - m.renderBranchLine(b, node, connector, connStyle) - } else { - m.renderBranchHeader(b, node, isFocused, connStyle) - } - - // Files changed toggle + expanded file list - if len(node.FilesChanged) > 0 { - m.renderFiles(b, node, connector, connStyle) - } - - // Commits toggle + expanded commits - if len(node.Commits) > 0 { - m.renderCommits(b, node, connector, connStyle) - } - - // Connector/spacer - b.WriteString(connStyle.Render(connector)) - b.WriteString("\n") -} - -// renderPRHeader renders the top line when a PR exists: bullet + status icon + PR number + state. -func (m Model) renderPRHeader(b *strings.Builder, node BranchNode, isFocused bool, connStyle lipgloss.Style) { - bullet := "├" - if isFocused { - bullet = "▶" - } - - b.WriteString(connStyle.Render(bullet + " ")) - - statusIcon := m.statusIcon(node) - - if statusIcon != "" { - b.WriteString(statusIcon + " ") - } - - // PR number + state label - pr := node.PR - prLabel := fmt.Sprintf("#%d", pr.Number) - stateLabel := "" - style := prOpenStyle - switch { - case pr.Merged: - stateLabel = " MERGED" - style = prMergedStyle - case pr.IsQueued: - stateLabel = " QUEUED" - style = prQueuedStyle - case pr.State == "CLOSED": - stateLabel = " CLOSED" - style = prClosedStyle - case pr.IsDraft: - stateLabel = " DRAFT" - style = prDraftStyle - default: - stateLabel = " OPEN" - } - b.WriteString(style.Underline(true).Render(prLabel) + style.Render(stateLabel)) - - b.WriteString("\n") -} - -// renderBranchLine renders the branch name + diff stats below the PR header. -func (m Model) renderBranchLine(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) { - b.WriteString(connStyle.Render(connector)) - b.WriteString(" ") - - branchName := node.Ref.Branch - if node.IsCurrent { - b.WriteString(currentBranchStyle.Render(branchName + " (current)")) - } else { - b.WriteString(normalBranchStyle.Render(branchName)) - } - - m.renderDiffStats(b, node) - b.WriteString("\n") -} - -// renderBranchHeader renders the header line when there is no PR: bullet + branch name + diff stats. -func (m Model) renderBranchHeader(b *strings.Builder, node BranchNode, isFocused bool, connStyle lipgloss.Style) { - bullet := "├" - if isFocused { - bullet = "▶" - } - - b.WriteString(connStyle.Render(bullet + " ")) - - // Status indicator - statusIcon := m.statusIcon(node) - if statusIcon != "" { - b.WriteString(statusIcon + " ") - } - - // Branch name - branchName := node.Ref.Branch - if node.IsCurrent { - b.WriteString(currentBranchStyle.Render(branchName + " (current)")) - } else { - b.WriteString(normalBranchStyle.Render(branchName)) - } - - m.renderDiffStats(b, node) - b.WriteString("\n") -} - -// renderDiffStats appends +N -N diff stats to the current line if available. -func (m Model) renderDiffStats(b *strings.Builder, node BranchNode) { - if node.Additions > 0 || node.Deletions > 0 { - b.WriteString(" ") - b.WriteString(additionsStyle.Render(fmt.Sprintf("+%d", node.Additions))) - b.WriteString(" ") - b.WriteString(deletionsStyle.Render(fmt.Sprintf("-%d", node.Deletions))) - } -} - -// statusIcon returns the appropriate status icon for a branch. -func (m Model) statusIcon(node BranchNode) string { - if node.Ref.IsMerged() { - return mergedIcon - } - if node.Ref.IsQueued() { - return queuedIcon - } - if !node.IsLinear { - return warningIcon - } - if node.PR != nil && node.PR.Number != 0 { - return openIcon - } - return "" -} - -// renderFiles renders the files changed toggle and optionally the expanded file list. -func (m Model) renderFiles(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) { - b.WriteString(connStyle.Render(connector)) - b.WriteString(" ") - - icon := collapsedIcon - if node.FilesExpanded { - icon = expandedIcon - } - fileLabel := "files changed" - if len(node.FilesChanged) == 1 { - fileLabel = "file changed" - } - b.WriteString(commitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.FilesChanged), fileLabel))) - b.WriteString("\n") - - if !node.FilesExpanded { - return - } - - for _, f := range node.FilesChanged { - b.WriteString(connStyle.Render(connector)) - b.WriteString(" ") - - path := f.Path - maxLen := m.width - 30 - if maxLen < 20 { - maxLen = 20 - } - if len(path) > maxLen { - path = "…" + path[len(path)-maxLen+1:] - } - b.WriteString(normalBranchStyle.Render(path)) - b.WriteString(" ") - b.WriteString(additionsStyle.Render(fmt.Sprintf("+%d", f.Additions))) - b.WriteString(" ") - b.WriteString(deletionsStyle.Render(fmt.Sprintf("-%d", f.Deletions))) - b.WriteString("\n") - } -} - -// renderCommits renders the commits toggle and optionally the expanded commit list. -func (m Model) renderCommits(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) { - b.WriteString(connStyle.Render(connector)) - b.WriteString(" ") - - icon := collapsedIcon - if node.CommitsExpanded { - icon = expandedIcon - } - commitLabel := "commits" - if len(node.Commits) == 1 { - commitLabel = "commit" - } - b.WriteString(commitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.Commits), commitLabel))) - b.WriteString("\n") - - if !node.CommitsExpanded { - return - } - - for _, c := range node.Commits { - b.WriteString(connStyle.Render(connector)) - b.WriteString(" ") - - sha := c.SHA - if len(sha) > 7 { - sha = sha[:7] - } - b.WriteString(commitSHAStyle.Render(sha)) - b.WriteString(" ") - - subject := c.Subject - maxLen := m.width - 35 - if maxLen < 20 { - maxLen = 20 - } - if len(subject) > maxLen { - subject = subject[:maxLen-1] + "…" - } - b.WriteString(commitSubjectStyle.Render(subject)) - b.WriteString(" ") - b.WriteString(commitTimeStyle.Render(timeAgo(c.Time))) - b.WriteString("\n") - } -} - -// timeAgo returns a human-readable time-ago string. -func timeAgo(t time.Time) string { - d := time.Since(t) - switch { - case d < time.Minute: - secs := int(d.Seconds()) - if secs == 1 { - return "1 second ago" - } - return fmt.Sprintf("%d seconds ago", secs) - case d < time.Hour: - mins := int(d.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case d < 24*time.Hour: - hours := int(d.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case d < 30*24*time.Hour: - days := int(d.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - default: - months := int(d.Hours() / 24 / 30) - if months <= 1 { - return "1 month ago" - } - return fmt.Sprintf("%d months ago", months) - } -} - -// browserCmd returns an exec.Cmd to open a URL in the default browser. -func browserCmd(url string) *exec.Cmd { - switch runtime.GOOS { - case "darwin": - return exec.Command("open", url) - case "windows": - return exec.Command("cmd", "/c", "start", url) - default: - return exec.Command("xdg-open", url) - } + shared.RenderNode(b, toBranchNodeData(node), isFocused, m.width, nil) } diff --git a/internal/tui/stackview/model_test.go b/internal/tui/stackview/model_test.go index 96c72f0..fb1774e 100644 --- a/internal/tui/stackview/model_test.go +++ b/internal/tui/stackview/model_test.go @@ -184,7 +184,7 @@ func TestView_HeaderShownWhenTallEnough(t *testing.T) { view := m.View() assert.Contains(t, view, "┌") assert.Contains(t, view, "┘") - assert.Contains(t, view, "GitHub Stacks") + assert.Contains(t, view, "View Stack") assert.Contains(t, view, "v0.0.1") assert.Contains(t, view, "Base: main") assert.Contains(t, view, "2 branches") @@ -203,7 +203,7 @@ func TestView_HeaderHiddenWhenShort(t *testing.T) { view := m.View() // Should NOT contain header box assert.NotContains(t, view, "┌") - assert.NotContains(t, view, "GitHub Stacks") + assert.NotContains(t, view, "View Stack") // Should NOT contain help bar either (hints are only in header) assert.NotContains(t, view, "commits") } @@ -218,21 +218,20 @@ func TestView_HeaderHiddenWhenNarrow(t *testing.T) { view := m.View() assert.NotContains(t, view, "┌") - assert.NotContains(t, view, "GitHub Stacks") + assert.NotContains(t, view, "View Stack") } -func TestView_HeaderWithoutShortcutsWhenMediumWidth(t *testing.T) { +func TestView_HeaderShortcutsAlwaysVisible(t *testing.T) { nodes := makeNodes("b1", "b2") m := New(nodes, testTrunk, "0.0.1") - // Wide enough for header but not for shortcuts (between minWidthForHeader and minWidthForShortcuts) + // Even at medium width, shortcuts should still be visible updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 40}) m = updated.(Model) view := m.View() assert.Contains(t, view, "┌", "header should be shown") - assert.Contains(t, view, "GitHub Stacks", "info should be shown") - assert.NotContains(t, view, "checkout", "shortcuts should be hidden at this width") + assert.Contains(t, view, "checkout", "shortcuts should always be visible") } func TestView_HeaderShowsMergedCount(t *testing.T) { diff --git a/internal/tui/stackview/styles.go b/internal/tui/stackview/styles.go deleted file mode 100644 index 6f90e99..0000000 --- a/internal/tui/stackview/styles.go +++ /dev/null @@ -1,58 +0,0 @@ -package stackview - -import "github.com/charmbracelet/lipgloss" - -var ( - // Branch name styles - currentBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // cyan bold - normalBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white - mergedBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray - trunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) // gray italic - - // Focus indicator — reserved for future use - - // Status indicators - mergedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Render("✓") // magenta - warningIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("⚠") // yellow - openIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("○") // green - queuedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("130")).Render("◎") // brown - - // PR status - prOpenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green - prMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta - prClosedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - prDraftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray - prQueuedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")) // brown - - // Diff stats - additionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green - deletionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - - // Commit lines - commitSHAStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow - commitSubjectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white - commitTimeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray - - // Connector lines - connectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray - connectorDashedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow (non-linear) - connectorFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white (focused) - connectorCurrentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan (current branch focused) - connectorMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta (merged branch focused) - connectorQueuedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("130")) // brown (queued branch focused) - - // Dim text (separators, secondary labels) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - - // Header styles - headerBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray box-drawing chars - headerTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) // white bold - headerInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan - headerInfoLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray - headerShortcutKey = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white - headerShortcutDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray - - // Expand/collapse toggle - expandedIcon = "▾" - collapsedIcon = "▸" -)