From 9f4c2ead1bd471fa9ddd5387de7b6fe183b61d38 Mon Sep 17 00:00:00 2001 From: Dan Costello Date: Thu, 4 Jun 2026 08:04:59 -0700 Subject: [PATCH 1/2] Add watch mode --- cmd/root.go | 4 + cmd/root_test.go | 2 +- cmd/watch.go | 130 ++++++++++++ cmd/watch_test.go | 27 +++ internal/tui/stackview/model.go | 294 ++++++++++++++++++++++++++- internal/tui/stackview/model_test.go | 168 +++++++++++++++ 6 files changed, 613 insertions(+), 12 deletions(-) create mode 100644 cmd/watch.go create mode 100644 cmd/watch_test.go diff --git a/cmd/root.go b/cmd/root.go index 0c9e07e..d96b8ba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -74,6 +74,10 @@ locally, then push to GitHub to create your stack of PRs.`, viewCmd.GroupID = "stack" root.AddCommand(viewCmd) + watchCmd := WatchCmd(cfg) + watchCmd.GroupID = "stack" + root.AddCommand(watchCmd) + checkoutCmd := CheckoutCmd(cfg) checkoutCmd.GroupID = "stack" root.AddCommand(checkoutCmd) diff --git a/cmd/root_test.go b/cmd/root_test.go index 8138c7a..de46a9b 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -10,7 +10,7 @@ import ( func TestRootCmd_SubcommandRegistration(t *testing.T) { root := RootCmd() - expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit"} + expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "view", "watch", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit"} registered := make(map[string]bool) for _, cmd := range root.Commands() { diff --git a/cmd/watch.go b/cmd/watch.go new file mode 100644 index 0000000..34cabff --- /dev/null +++ b/cmd/watch.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "fmt" + + 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/stack" + "github.com/github/gh-stack/internal/tui/stackview" + "github.com/spf13/cobra" +) + +func WatchCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "watch", + Short: "Interactively browse the stack and switch branches in place", + Long: `Watch opens an interactive view of the current stack. + +It works like 'gh stack view', but the view stays open after you act on it. +Pressing enter on a branch checks it out in place and updates the current +branch marker without closing the view, so you can keep navigating. + +Press 'r' to refresh PR and stack state in place, or 'p' to push the whole +stack to the remote (after a confirmation prompt). + +Status icons: + ✓ PR merged + ◎ PR queued + ○ PR open + ⚠ Needs rebase`, + Example: ` # Open the interactive watch view + $ gh stack watch`, + RunE: func(cmd *cobra.Command, args []string) error { + return runWatch(cfg) + }, + } + + return cmd +} + +func runWatch(cfg *config.Config) error { + if !cfg.IsInteractive() { + cfg.Errorf("watch requires an interactive terminal; use 'gh stack view' instead") + return ErrSilent + } + + result, err := loadStack(cfg, "") + if err != nil { + return ErrNotInStack + } + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch + + fmt.Fprintf(cfg.Err, "Loading stack...") + + // Sync PR state and save (best-effort). + prDetails := syncStackPRs(cfg, s) + stack.SaveNonBlocking(gitDir, sf) + + fmt.Fprintf(cfg.Err, "\r\033[2K") + + // Load enriched data for all branches. + nodes := stackview.LoadBranchNodes(cfg, s, currentBranch, prDetails) + + // Reverse nodes so index 0 = top of stack (matches visual order). + reversed := make([]stackview.BranchNode, len(nodes)) + for i, n := range nodes { + reversed[len(nodes)-1-i] = n + } + + // refresh re-syncs PR/stack state and returns fresh nodes in top-down order. + refresh := func() ([]stackview.BranchNode, error) { + details := syncStackPRs(cfg, s) + stack.SaveNonBlocking(gitDir, sf) + cur, err := git.CurrentBranch() + if err != nil { + return nil, err + } + fresh := stackview.LoadBranchNodes(cfg, s, cur, details) + out := make([]stackview.BranchNode, len(fresh)) + for i, n := range fresh { + out[len(fresh)-1-i] = n + } + return out, nil + } + + // push pushes all active branches in the stack to the remote. + push := func() error { + cur, err := git.CurrentBranch() + if err != nil { + return err + } + remote, err := git.ResolveRemote(cur) + if err != nil { + return err + } + _ = syncStackPRs(cfg, s) + active := activeBranchNames(s) + if len(active) == 0 { + return fmt.Errorf("no active branches to push (all merged or queued)") + } + _ = git.FetchBranches(remote, active) + if err := git.Push(remote, active, true, false); err != nil { + return err + } + updateBaseSHAs(s) + return stack.Save(gitDir, sf) + } + + model := stackview.NewInteractive(reversed, s.Trunk, Version, stackview.InteractiveActions{ + Checkout: git.CheckoutBranch, + Refresh: refresh, + Push: push, + }) + + p := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithMouseAllMotion(), + ) + + if _, err := p.Run(); err != nil { + return fmt.Errorf("running TUI: %w", err) + } + + return nil +} diff --git a/cmd/watch_test.go b/cmd/watch_test.go new file mode 100644 index 0000000..cbbca3a --- /dev/null +++ b/cmd/watch_test.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestWatchCmd_Registration(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + cmd := WatchCmd(cfg) + + assert.Equal(t, "watch", cmd.Name()) + assert.NotEmpty(t, cmd.Short) +} + +func TestRunWatch_RequiresInteractive(t *testing.T) { + // NewTestConfig produces a non-interactive config. + cfg, outR, errR := config.NewTestConfig() + + err := runWatch(cfg) + assert.ErrorIs(t, err, ErrSilent) + + output := collectOutput(cfg, outR, errR) + assert.Contains(t, output, "interactive terminal") +} diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index 49cd46f..64ca3e0 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -19,11 +19,13 @@ type keyMap struct { ToggleFiles key.Binding OpenPR key.Binding Checkout key.Binding + Refresh key.Binding + Push key.Binding Quit key.Binding } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Up, k.Down, k.ToggleCommits, k.ToggleFiles, k.OpenPR, k.Checkout, k.Quit} + return []key.Binding{k.Up, k.Down, k.ToggleCommits, k.ToggleFiles, k.OpenPR, k.Checkout, k.Refresh, k.Push, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { @@ -55,6 +57,14 @@ var keys = keyMap{ key.WithKeys("enter"), key.WithHelp("enter", "checkout"), ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), + Push: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "push"), + ), Quit: key.NewBinding( key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q", "quit"), @@ -76,6 +86,65 @@ type Model struct { // checkoutBranch is set when the user wants to checkout a branch after quitting. checkoutBranch string + + // interactive enables in-place actions that keep the TUI open instead of + // quitting (used by `gh stack watch`). + interactive bool + + // checkoutFn performs an in-place checkout in interactive mode. When nil, + // checkout falls back to the quit-then-checkout behavior. + checkoutFn func(string) error + + // refreshFn re-syncs PR/stack state and returns fresh nodes (top-down + // order). When nil, the refresh key is disabled. + refreshFn func() ([]BranchNode, error) + + // pushFn pushes the whole stack. When nil, the push key is disabled. + pushFn func() error + + // busy indicates an async action is in flight; action keys are ignored + // until it completes. + busy bool + + // confirmMode is true while waiting for a y/n answer before a mutating + // action. confirmAction names the pending action (e.g. "push"). + confirmMode bool + confirmAction string + + // infoMsg holds a transient status/success message for the bottom line. + infoMsg string + + // errMsg holds a transient error to display at the bottom of the view. + errMsg string +} + +// InteractiveActions bundles the in-place action callbacks used by `watch`. +// Any nil field disables the corresponding key. +type InteractiveActions struct { + // Checkout switches to the given branch in place. + Checkout func(string) error + // Refresh re-syncs PR/stack state and returns fresh nodes in top-down order. + Refresh func() ([]BranchNode, error) + // Push pushes the whole stack to the remote. + Push func() error +} + +// checkoutResultMsg reports the outcome of an in-place checkout. +type checkoutResultMsg struct { + branch string + err error +} + +// refreshResultMsg reports the outcome of an in-place refresh. +type refreshResultMsg struct { + nodes []BranchNode + err error +} + +// actionResultMsg reports the outcome of a mutating action (e.g. push). +type actionResultMsg struct { + action string + err error } // New creates a new stack view model. @@ -111,11 +180,75 @@ func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model { } } +// NewInteractive creates a stack view model for the interactive `watch` mode. +// Pressing enter on a branch invokes the Checkout action in place and keeps the +// TUI open, updating the current-branch marker instead of quitting. Refresh and +// Push actions, when supplied, are bound to the `r` and `p` keys. +func NewInteractive(nodes []BranchNode, trunk stack.BranchRef, version string, actions InteractiveActions) Model { + m := New(nodes, trunk, version) + m.interactive = true + m.checkoutFn = actions.Checkout + m.refreshFn = actions.Refresh + m.pushFn = actions.Push + return m +} + // CheckoutBranch returns the branch to checkout after the TUI exits, if any. func (m Model) CheckoutBranch() string { return m.checkoutBranch } +// checkoutCmd returns a command that performs an in-place checkout and reports +// the result back to the model. +func (m Model) checkoutCmd(branch string) tea.Cmd { + fn := m.checkoutFn + return func() tea.Msg { + var err error + if fn != nil { + err = fn(branch) + } + return checkoutResultMsg{branch: branch, err: err} + } +} + +// refreshCmd returns a command that re-syncs stack state and reports fresh +// nodes back to the model. +func (m Model) refreshCmd() tea.Cmd { + fn := m.refreshFn + return func() tea.Msg { + if fn == nil { + return refreshResultMsg{} + } + nodes, err := fn() + return refreshResultMsg{nodes: nodes, err: err} + } +} + +// pushCmd returns a command that pushes the stack and reports the result back +// to the model. +func (m Model) pushCmd() tea.Cmd { + fn := m.pushFn + return func() tea.Msg { + var err error + if fn != nil { + err = fn() + } + return actionResultMsg{action: "push", err: err} + } +} + +// activeBranchCount returns the number of branches that are neither merged nor +// queued (i.e. the branches a push would actually update). +func (m Model) activeBranchCount() int { + n := 0 + for _, node := range m.nodes { + if !node.Ref.IsMerged() && !node.Ref.IsQueued() { + n++ + } + } + return n +} + func (m Model) Init() tea.Cmd { return nil } @@ -129,6 +262,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + // While awaiting a y/n confirmation, only confirm keys are handled. + if m.confirmMode { + switch msg.String() { + case "y", "Y", "enter": + action := m.confirmAction + m.confirmMode = false + m.confirmAction = "" + if action == "push" { + m.busy = true + m.errMsg = "" + m.infoMsg = "Pushing stack…" + return m, m.pushCmd() + } + return m, nil + case "n", "N", "esc", "ctrl+c": + m.confirmMode = false + m.confirmAction = "" + m.infoMsg = "Canceled" + m.errMsg = "" + return m, nil + } + return m, nil + } + switch { case key.Matches(msg, keys.Quit): return m, tea.Quit @@ -170,12 +327,74 @@ 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.IsCurrent && !node.Ref.IsMerged() { + if m.interactive { + return m, m.checkoutCmd(node.Ref.Branch) + } m.checkoutBranch = node.Ref.Branch return m, tea.Quit } } return m, nil + + case key.Matches(msg, keys.Refresh): + if m.interactive && m.refreshFn != nil && !m.busy { + m.busy = true + m.errMsg = "" + m.infoMsg = "Refreshing…" + return m, m.refreshCmd() + } + return m, nil + + case key.Matches(msg, keys.Push): + if m.interactive && m.pushFn != nil && !m.busy { + m.confirmMode = true + m.confirmAction = "push" + m.errMsg = "" + m.infoMsg = "" + } + return m, nil + } + + case checkoutResultMsg: + if msg.err != nil { + m.errMsg = fmt.Sprintf("failed to checkout %s: %v", msg.branch, msg.err) + return m, nil + } + for i := range m.nodes { + m.nodes[i].IsCurrent = m.nodes[i].Ref.Branch == msg.branch + } + m.errMsg = "" + return m, nil + + case refreshResultMsg: + m.busy = false + if msg.err != nil { + m.infoMsg = "" + m.errMsg = fmt.Sprintf("refresh failed: %v", msg.err) + return m, nil + } + m.nodes = msg.nodes + if m.cursor >= len(m.nodes) { + m.cursor = len(m.nodes) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + m.clampScroll() + m.errMsg = "" + m.infoMsg = "Refreshed" + return m, nil + + case actionResultMsg: + m.busy = false + if msg.err != nil { + m.infoMsg = "" + m.errMsg = fmt.Sprintf("%s failed: %v", msg.action, msg.err) + return m, nil } + m.errMsg = "" + m.infoMsg = fmt.Sprintf("%s complete", msg.action) + return m, nil case tea.MouseMsg: switch msg.Action { @@ -387,6 +606,10 @@ func (m Model) View() string { if showHeader { reservedLines = shared.HeaderHeight } + statusLine := m.statusLine() + if statusLine != "" { + reservedLines++ // reserve a line for the status message + } viewHeight := m.height - reservedLines if viewHeight < 1 { viewHeight = 1 @@ -394,9 +617,38 @@ func (m Model) View() string { out.WriteString(shared.ApplyScrollToContent(content, m.scrollOffset, viewHeight)) + if statusLine != "" { + out.WriteString("\n") + out.WriteString(statusLine) + } + return out.String() } +// statusLine returns the bottom status line: a pending confirmation prompt, a +// transient error, or a transient info message (in that priority order). +func (m Model) statusLine() string { + if m.confirmMode && m.confirmAction == "push" { + count := m.activeBranchCount() + return fmt.Sprintf("Push %d %s to the remote? (y/n)", count, plural(count, "branch", "branches")) + } + if m.errMsg != "" { + return shared.WarningIcon + " " + m.errMsg + } + if m.infoMsg != "" { + return shared.OpenIcon + " " + m.infoMsg + } + return "" +} + +// plural returns singular or plural based on n. +func plural(n int, singular, pluralForm string) string { + if n == 1 { + return singular + } + return pluralForm +} + // buildHeaderConfig produces the header configuration for the stack view. func (m Model) buildHeaderConfig() shared.HeaderConfig { mergedCount := 0 @@ -429,9 +681,37 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { branchIcon = "●" } + title := "View Stack" + if m.interactive { + title = "Watch Stack" + } + + 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"}, + } + if m.interactive { + shortcuts = []shared.ShortcutEntry{ + {Key: "↑", Desc: "up"}, + {Key: "↓", Desc: "down"}, + {Key: "c", Desc: "commits"}, + {Key: "f", Desc: "files"}, + {Key: "o", Desc: "open PR"}, + {Key: "↵", Desc: "switch (stays open)"}, + {Key: "r", Desc: "refresh"}, + {Key: "p", Desc: "push"}, + {Key: "q", Desc: "quit"}, + } + } + return shared.HeaderConfig{ ShowArt: true, - Title: "View Stack", + Title: title, Subtitle: "v" + m.version, InfoLines: []shared.HeaderInfoLine{ {Icon: "✓", Label: "Stack initialized"}, @@ -439,15 +719,7 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { {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"}, - }, + Shortcuts: shortcuts, } } diff --git a/internal/tui/stackview/model_test.go b/internal/tui/stackview/model_test.go index b351d09..d115453 100644 --- a/internal/tui/stackview/model_test.go +++ b/internal/tui/stackview/model_test.go @@ -173,6 +173,174 @@ func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) { assert.Nil(t, cmd, "enter on current branch should not quit") } +func TestInteractive_EnterChecksOutInPlace(t *testing.T) { + nodes := makeNodes("b1", "b2") + nodes[0].IsCurrent = true + + var checkedOut string + m := NewInteractive(nodes, testTrunk, "0.0.1", InteractiveActions{Checkout: func(branch string) error { + checkedOut = branch + return nil + }}) + + // Move to b2 (non-current). + updated, _ := m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 1, m.cursor) + + // Press enter: interactive mode should not quit and should not set checkoutBranch. + updated, cmd := m.Update(keyMsg("enter")) + m = updated.(Model) + assert.Equal(t, "", m.CheckoutBranch(), "interactive checkout should not use quit-then-checkout path") + if assert.NotNil(t, cmd, "interactive enter should produce a checkout command") { + // Run the command to obtain the result message and feed it back. + msg := cmd() + result, ok := msg.(checkoutResultMsg) + assert.True(t, ok, "command should produce a checkoutResultMsg") + assert.Equal(t, "b2", checkedOut, "checkoutFn should be invoked with the selected branch") + + updated, quitCmd := m.Update(result) + m = updated.(Model) + assert.Nil(t, quitCmd, "successful in-place checkout should not quit") + assert.False(t, m.nodes[0].IsCurrent, "previous current marker should be cleared") + assert.True(t, m.nodes[1].IsCurrent, "selected branch should become current") + assert.Equal(t, "", m.errMsg) + } +} + +func TestInteractive_CheckoutErrorSetsMessage(t *testing.T) { + nodes := makeNodes("b1", "b2") + nodes[0].IsCurrent = true + + m := NewInteractive(nodes, testTrunk, "0.0.1", InteractiveActions{Checkout: func(branch string) error { + return fmt.Errorf("boom") + }}) + + updated, _ := m.Update(keyMsg("down")) + m = updated.(Model) + + _, cmd := m.Update(keyMsg("enter")) + msg := cmd() + result := msg.(checkoutResultMsg) + + updated, quitCmd := m.Update(result) + m = updated.(Model) + assert.Nil(t, quitCmd, "checkout error should not quit") + assert.Contains(t, m.errMsg, "boom", "error message should be surfaced") + assert.True(t, m.nodes[0].IsCurrent, "current marker should be unchanged on error") + assert.False(t, m.nodes[1].IsCurrent) +} + +func TestInteractive_RefreshReplacesNodes(t *testing.T) { + nodes := makeNodes("b1", "b2") + nodes[0].IsCurrent = true + + fresh := makeNodes("b1", "b2", "b3") + called := false + m := NewInteractive(nodes, testTrunk, "0.0.1", InteractiveActions{ + Refresh: func() ([]BranchNode, error) { + called = true + return fresh, nil + }, + }) + + updated, cmd := m.Update(keyMsg("r")) + m = updated.(Model) + assert.True(t, m.busy, "refresh should mark the model busy") + if assert.NotNil(t, cmd, "refresh should produce a command") { + msg := cmd() + result, ok := msg.(refreshResultMsg) + assert.True(t, ok, "command should produce a refreshResultMsg") + assert.True(t, called, "refresh func should be invoked") + + updated, _ = m.Update(result) + m = updated.(Model) + assert.False(t, m.busy, "refresh result should clear busy") + assert.Len(t, m.nodes, 3, "nodes should be replaced with fresh data") + assert.Equal(t, "Refreshed", m.infoMsg) + } +} + +func TestInteractive_RefreshErrorSetsMessage(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := NewInteractive(nodes, testTrunk, "0.0.1", InteractiveActions{ + Refresh: func() ([]BranchNode, error) { + return nil, fmt.Errorf("network down") + }, + }) + + updated, cmd := m.Update(keyMsg("r")) + m = updated.(Model) + result := cmd().(refreshResultMsg) + updated, _ = m.Update(result) + m = updated.(Model) + assert.False(t, m.busy) + assert.Len(t, m.nodes, 2, "nodes should be unchanged on error") + assert.Contains(t, m.errMsg, "network down") +} + +func TestInteractive_PushRequiresConfirmation(t *testing.T) { + nodes := makeNodes("b1", "b2") + pushed := false + m := NewInteractive(nodes, testTrunk, "0.0.1", InteractiveActions{ + Push: func() error { + pushed = true + return nil + }, + }) + + // Press p: should enter confirm mode without pushing. + updated, cmd := m.Update(keyMsg("p")) + m = updated.(Model) + assert.True(t, m.confirmMode, "p should enter confirm mode") + assert.Equal(t, "push", m.confirmAction) + assert.Nil(t, cmd, "p should not dispatch a push yet") + assert.False(t, pushed) + assert.Contains(t, m.statusLine(), "Push 2 branches") + + // Cancel with n. + updated, _ = m.Update(keyMsg("n")) + m = updated.(Model) + assert.False(t, m.confirmMode, "n should cancel confirm mode") + assert.False(t, pushed) + + // Press p then confirm with y. + updated, _ = m.Update(keyMsg("p")) + m = updated.(Model) + updated, cmd = m.Update(keyMsg("y")) + m = updated.(Model) + assert.False(t, m.confirmMode, "y should exit confirm mode") + assert.True(t, m.busy, "confirmed push should mark busy") + if assert.NotNil(t, cmd, "confirmed push should dispatch a command") { + result, ok := cmd().(actionResultMsg) + assert.True(t, ok) + assert.True(t, pushed, "push func should be invoked after confirmation") + updated, _ = m.Update(result) + m = updated.(Model) + assert.False(t, m.busy) + assert.Equal(t, "push complete", m.infoMsg) + } +} + +func TestInteractive_PushErrorSetsMessage(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := NewInteractive(nodes, testTrunk, "0.0.1", InteractiveActions{ + Push: func() error { + return fmt.Errorf("rejected") + }, + }) + + updated, _ := m.Update(keyMsg("p")) + m = updated.(Model) + updated, cmd := m.Update(keyMsg("y")) + m = updated.(Model) + result := cmd().(actionResultMsg) + updated, _ = m.Update(result) + m = updated.(Model) + assert.False(t, m.busy) + assert.Contains(t, m.errMsg, "rejected") +} + func TestView_HeaderShownWhenTallEnough(t *testing.T) { nodes := makeNodes("b1", "b2") m := New(nodes, testTrunk, "0.0.1") From a1b999a5c370b52f92020170e312958f7c96b0f9 Mon Sep 17 00:00:00 2001 From: Dan Costello Date: Thu, 4 Jun 2026 09:38:37 -0700 Subject: [PATCH 2/2] Update docs for watch mode --- README.md | 32 ++++++++++++++++++++++++++ docs/src/content/docs/reference/cli.md | 30 ++++++++++++++++++++++++ skills/gh-stack/SKILL.md | 3 ++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 270c3c6..5cd1641 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,38 @@ gh stack view --short gh stack view --json ``` +### `gh stack watch` + +Open an interactive view of the stack that stays open while you work. + +``` +gh stack watch +``` + +Works like `gh stack view`, but the view does not close after you act on it. Navigate with the arrow keys and press enter to check out a branch in place — the current-branch marker updates without leaving the view, so you can keep moving around the stack. Requires an interactive terminal; use `gh stack view` in non-interactive contexts. + +Press `r` to refresh PR and stack state in place, or `p` to push the whole stack to the remote (after a confirmation prompt). + +**Keybindings:** + +| Key | Action | +|-----|--------| +| `↑`/`↓` | Navigate branch list | +| `c` | Toggle commits | +| `f` | Toggle files changed | +| `o` | Open PR in browser | +| `↵` | Check out branch in place (stays open) | +| `r` | Refresh PR and stack state | +| `p` | Push the stack (with confirmation) | +| `q`/`Esc` | Quit | + +**Examples:** + +```sh +# Open the interactive watch view +gh stack watch +``` + ### `gh stack unstack` Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 3990f69..c27700f 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -133,6 +133,36 @@ gh stack view --short gh stack view --json ``` +### `gh stack watch` + +Open an interactive view of the stack that stays open while you work. + +```sh +gh stack watch +``` + +Works like `gh stack view`, but the view does not close after you act on it. Navigate with the arrow keys and press enter to check out a branch in place — the current-branch marker updates without leaving the view, so you can keep moving around the stack. Requires an interactive terminal; use `gh stack view` in non-interactive contexts. + +Press `r` to refresh PR and stack state in place, or `p` to push the whole stack to the remote (after a confirmation prompt). + +| Key | Action | +|-----|--------| +| `↑`/`↓` | Navigate branch list | +| `c` | Toggle commits | +| `f` | Toggle files changed | +| `o` | Open PR in browser | +| `↵` | Check out branch in place (stays open) | +| `r` | Refresh PR and stack state | +| `p` | Push the stack (with confirmation) | +| `q`/`Esc` | Quit | + +**Examples:** + +```sh +# Open the interactive watch view +gh stack watch +``` + ### `gh stack checkout` Check out a stack from a pull request number or branch name. diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 3ad4a35..9cf5e5f 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -55,7 +55,7 @@ git config remote.pushDefault origin # if multiple remotes exist (skips remo 1. **Always supply branch names as positional arguments** to `init`, `add`, and `checkout`. Running these commands without arguments triggers interactive prompts. 2. **When a prefix is set, pass only the suffix to `add`.** `gh stack add auth` with prefix `feat` → `feat/auth`. Passing `feat/auth` creates `feat/feat/auth`. 3. **Always use `--auto` with `gh stack submit`** to auto-generate PR titles. Without `--auto`, `submit` prompts for a title for each new PR. -4. **Always use `--json` with `gh stack view`.** Without `--json`, the command launches an interactive TUI that cannot be operated by agents. There is no other appropriate flag — always pass `--json`. +4. **Always use `--json` with `gh stack view`.** Without `--json`, the command launches an interactive TUI that cannot be operated by agents. There is no other appropriate flag — always pass `--json`. Never run `gh stack watch`; it is an interactive-only TUI with no non-interactive mode — use `gh stack view --json` instead. 5. **Use `--remote ` when multiple remotes are configured**, or pre-configure `git config remote.pushDefault origin`. Without this, `push`, `submit`, `sync`, `link`, and `checkout` trigger an interactive remote picker. 6. **Avoid branches shared across multiple stacks.** If a branch belongs to multiple stacks, commands exit with code 6. Check out a non-shared branch first. 7. **Plan your stack layers by dependency order before writing code.** Foundational changes (models, APIs, shared utilities) go in lower branches; dependent changes (UI, consumers) go in higher branches. Think through the dependency chain before running `gh stack init`. @@ -65,6 +65,7 @@ git config remote.pushDefault origin # if multiple remotes exist (skips remo **Never do any of the following — each triggers an interactive prompt or TUI that will hang:** - ❌ `gh stack view` or `gh stack view --short` — always use `gh stack view --json` +- ❌ `gh stack watch` — interactive-only TUI with no JSON or non-interactive mode; use `gh stack view --json` - ❌ `gh stack submit` without `--auto` — always use `gh stack submit --auto` - ❌ `gh stack init` without branch arguments — always provide branch names - ❌ `gh stack add` without a branch name — always provide a branch name