Skip to content
Open
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
52 changes: 47 additions & 5 deletions cmd/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ func ViewCmd(cfg *config.Config) *cobra.Command {
}

func runView(cfg *config.Config, opts *viewOptions) error {
// JSON mode must never show interactive prompts so that agents and
// scripts always receive machine-readable output. Resolve the stack
// directly (like push/submit) and return typed exit codes.
if opts.asJSON {
return runViewJSON(cfg)
}

result, err := loadStack(cfg, "")
if err != nil {
return ErrNotInStack
Expand All @@ -52,7 +59,7 @@ func runView(cfg *config.Config, opts *viewOptions) error {

// Show loading indicator for interactive TUI mode.
showingLoader := false
if !opts.asJSON && !opts.short && cfg.IsInteractive() {
if !opts.short && cfg.IsInteractive() {
fmt.Fprintf(cfg.Err, "Loading stack...")
showingLoader = true
}
Expand All @@ -65,17 +72,52 @@ func runView(cfg *config.Config, opts *viewOptions) error {
fmt.Fprintf(cfg.Err, "\r\033[2K")
}

if opts.asJSON {
return viewJSON(cfg, s, currentBranch)
}

if opts.short {
return viewShort(cfg, s, currentBranch)
}

return viewFull(cfg, s, currentBranch, prDetails)
}

// runViewJSON handles `gh stack view --json` without interactive prompts.
// It resolves the stack directly and returns typed exit codes when the
// branch is not part of any stack or belongs to multiple stacks.
func runViewJSON(cfg *config.Config) error {
gitDir, err := git.GitDir()
if err != nil {
cfg.Errorf("not a git repository")
return ErrNotInStack
}

sf, err := stack.Load(gitDir)
if err != nil {
cfg.Errorf("failed to load stack state: %s", err)
return ErrNotInStack
}

currentBranch, err := git.CurrentBranch()
if err != nil {
cfg.Errorf("failed to get current branch: %s", err)
return ErrNotInStack
}

stacks := sf.FindAllStacksForBranch(currentBranch)
if len(stacks) == 0 {
cfg.Errorf("current branch %q is not part of a stack", currentBranch)
return ErrNotInStack
}
if len(stacks) > 1 {
cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", currentBranch)
return ErrDisambiguate
}
s := stacks[0]

syncStackPRs(cfg, s)
stack.SaveNonBlocking(gitDir, sf)

return viewJSON(cfg, s, currentBranch)
}

func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error {
var repoHost, repoOwner, repoName string
if repo, err := cfg.Repo(); err == nil {
Expand Down
118 changes: 118 additions & 0 deletions cmd/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cmd
import (
"encoding/json"
"io"
"os"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -418,3 +420,119 @@ func indexOf(s, substr string) int {
}
return -1
}

// writeStackFileMulti writes a stack file with multiple stacks.
func writeStackFileMulti(t *testing.T, dir string, stacks ...stack.Stack) {
t.Helper()
sf := &stack.StackFile{
SchemaVersion: 1,
Stacks: stacks,
}
data, err := json.MarshalIndent(sf, "", " ")
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644))
}

func TestRunViewJSON_NotInStack(t *testing.T) {
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "feat/01"}},
})

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return tmpDir, nil },
CurrentBranchFn: func() (string, error) { return "unrelated-branch", nil },
})
defer restore()

cfg, _, errR := config.NewTestConfig()
cmd := ViewCmd(cfg)
cmd.SetArgs([]string{"--json"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
errOut, _ := io.ReadAll(errR)

assert.ErrorIs(t, err, ErrNotInStack, "expected exit code 2")
assert.Contains(t, string(errOut), "not part of a stack")
}

func TestRunViewJSON_MultipleStacks(t *testing.T) {
tmpDir := t.TempDir()
// "main" is the trunk of both stacks → disambiguation.
writeStackFileMulti(t, tmpDir,
stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "feat/01"}},
},
stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{{Branch: "feat/02"}},
},
)

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return tmpDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()

cfg, _, errR := config.NewTestConfig()
cmd := ViewCmd(cfg)
cmd.SetArgs([]string{"--json"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Err.Close()
errOut, _ := io.ReadAll(errR)

assert.ErrorIs(t, err, ErrDisambiguate, "expected exit code 6")
assert.Contains(t, string(errOut), "belongs to multiple stacks")
}

func TestRunViewJSON_SingleStack(t *testing.T) {
tmpDir := t.TempDir()
writeStackFile(t, tmpDir, stack.Stack{
Prefix: "feat",
Trunk: stack.BranchRef{Branch: "main", Head: "aaa"},
Branches: []stack.BranchRef{
{
Branch: "feat/01",
Head: "bbb",
Base: "aaa",
PullRequest: &stack.PullRequestRef{Number: 10, URL: "https://github.com/o/r/pull/10"},
},
},
})

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return tmpDir, nil },
CurrentBranchFn: func() (string, error) { return "feat/01", nil },
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
})
defer restore()

cfg, outR, _ := config.NewTestConfig()
cmd := ViewCmd(cfg)
cmd.SetArgs([]string{"--json"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

cfg.Out.Close()
raw, _ := io.ReadAll(outR)

require.NoError(t, err)

var got viewJSONOutput
require.NoError(t, json.Unmarshal(raw, &got), "output should be valid JSON: %s", string(raw))
assert.Equal(t, "main", got.Trunk)
assert.Equal(t, "feat", got.Prefix)
assert.Len(t, got.Branches, 1)
assert.Equal(t, "feat/01", got.Branches[0].Name)
assert.True(t, got.Branches[0].IsCurrent)
}
Loading