From 47e79d13b4fc76e6be3fe613a1d333dbed612222 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 5 May 2026 21:35:58 -0400 Subject: [PATCH] return exit codes instead of interactive prompt for view json mode --- cmd/view.go | 52 +++++++++++++++++++-- cmd/view_test.go | 118 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 5 deletions(-) diff --git a/cmd/view.go b/cmd/view.go index 8bb52fd..5cfe8f8 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -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 @@ -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 } @@ -65,10 +72,6 @@ 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) } @@ -76,6 +79,45 @@ func runView(cfg *config.Config, opts *viewOptions) error { 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 { diff --git a/cmd/view_test.go b/cmd/view_test.go index 44bdc5e..0e639c5 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -3,6 +3,8 @@ package cmd import ( "encoding/json" "io" + "os" + "path/filepath" "testing" "time" @@ -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) +}