Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
130 changes: 130 additions & 0 deletions cmd/watch.go
Original file line number Diff line number Diff line change
@@ -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
}
27 changes: 27 additions & 0 deletions cmd/watch_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
30 changes: 30 additions & 0 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading