Skip to content
Merged
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
9 changes: 8 additions & 1 deletion cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,20 @@ func runPush(cfg *config.Config, opts *pushOptions) error {
}
return ErrSilent
}
// Sync PR state to detect merged/queued PRs before pushing.
syncStackPRs(cfg, s)

merged := s.MergedBranches()
if len(merged) > 0 {
cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches"))
}
queued := s.QueuedBranches()
if len(queued) > 0 {
cfg.Printf("Skipping %d queued %s", len(queued), plural(len(queued), "branch", "branches"))
}
activeBranches := activeBranchNames(s)
if len(activeBranches) == 0 {
cfg.Printf("No active branches to push (all merged)")
cfg.Printf("No active branches to push (all merged or queued)")
return nil
}
cfg.Printf("Pushing %d %s to %s...", len(activeBranches), plural(len(activeBranches), "branch", "branches"), remote)
Expand Down
11 changes: 9 additions & 2 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
return ErrAPIFailure
}

// Sync PR state to detect merged/queued PRs before pushing.
syncStackPRs(cfg, s)

// Push all active branches atomically
remote, err := pickRemote(cfg, currentBranch, opts.remote)
if err != nil {
Expand All @@ -89,9 +92,13 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
if len(merged) > 0 {
cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches"))
}
queued := s.QueuedBranches()
if len(queued) > 0 {
cfg.Printf("Skipping %d queued %s", len(queued), plural(len(queued), "branch", "branches"))
}
activeBranches := activeBranchNames(s)
if len(activeBranches) == 0 {
cfg.Printf("All branches are merged, nothing to submit")
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)
Expand All @@ -104,7 +111,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error {
// correct base branch. This makes submit idempotent: running it again
// fills gaps and fixes base branches before syncing the stack.
for i, b := range s.Branches {
if s.Branches[i].IsMerged() {
if s.Branches[i].IsMerged() || s.Branches[i].IsQueued() {
continue
}
baseBranch := s.ActiveBaseBranch(b.Branch)
Expand Down
19 changes: 17 additions & 2 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,20 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
continue
}

// Skip branches whose PRs are currently in a merge queue.
if br.IsQueued() {
ontoOldBase = originalRefs[br.Branch]
needsOnto = true
cfg.Successf("Skipping %s (PR %s queued)", br.Branch, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL))
continue
}

if needsOnto {
// Find --onto target: first non-merged ancestor, or trunk.
// Find --onto target: first non-merged/queued ancestor, or trunk.
newBase := trunk
for j := i - 1; j >= 0; j-- {
b := s.Branches[j]
if !b.IsMerged() {
if !b.IsSkipped() {
newBase = b.Branch
break
}
Expand Down Expand Up @@ -233,6 +241,9 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
if mergedCount := len(s.MergedBranches()); mergedCount > 0 {
cfg.Printf("Skipping %d merged %s", mergedCount, plural(mergedCount, "branch", "branches"))
}
if queuedCount := len(s.QueuedBranches()); queuedCount > 0 {
cfg.Printf("Skipping %d queued %s", queuedCount, plural(queuedCount, "branch", "branches"))
}

if len(branches) == 0 {
cfg.Printf("No active branches to push (all merged)")
Expand Down Expand Up @@ -265,6 +276,10 @@ func runSync(cfg *config.Config, opts *syncOptions) error {
if b.IsMerged() {
continue
}
if b.IsQueued() {
cfg.Successf("PR %s (%s) — Queued", cfg.PRLink(b.PullRequest.Number, b.PullRequest.URL), b.Branch)
continue
}
if b.PullRequest != nil {
cfg.Successf("PR %s (%s) — Open", cfg.PRLink(b.PullRequest.Number, b.PullRequest.URL), b.Branch)
} else {
Expand Down
2 changes: 2 additions & 0 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac
// syncStackPRs discovers and updates pull request metadata for branches in a stack.
// For each branch, it queries GitHub for the most recent PR and updates the
// PullRequestRef including merge status. Branches with already-merged PRs are skipped.
// The transient Queued flag is also populated from the API response.
func syncStackPRs(cfg *config.Config, s *stack.Stack) {
client, err := cfg.GitHubClient()
if err != nil {
Expand All @@ -238,6 +239,7 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) {
URL: pr.URL,
Merged: pr.Merged,
}
b.Queued = pr.IsQueued()
}
}

Expand Down
19 changes: 18 additions & 1 deletion cmd/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,14 @@ func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error {
for i := len(s.Branches) - 1; i >= 0; i-- {
b := s.Branches[i]
merged := b.IsMerged()
queued := b.IsQueued()

// Insert separator when transitioning from active to merged section
// Insert separator when transitioning from active to queued section
if queued && !merged && (i == len(s.Branches)-1 || (!s.Branches[i+1].IsQueued() && !s.Branches[i+1].IsMerged())) {
cfg.Outf("├─── %s ────\n", cfg.ColorWarning("queued"))
}

// Insert separator when transitioning from active/queued to merged section
if merged && (i == len(s.Branches)-1 || !s.Branches[i+1].IsMerged()) {
cfg.Outf("├─── %s ────\n", cfg.ColorMagenta("merged"))
}
Expand All @@ -88,6 +94,8 @@ func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error {
cfg.Outf("» %s%s%s %s\n", cfg.ColorBold(b.Branch), indicator, prSuffix, cfg.ColorCyan("(current)"))
} else if merged {
cfg.Outf("│ %s%s%s\n", cfg.ColorGray(b.Branch), indicator, prSuffix)
} else if queued {
cfg.Outf("│ %s%s%s\n", cfg.ColorWarning(b.Branch), indicator, prSuffix)
} else {
cfg.Outf("├ %s%s%s\n", b.Branch, indicator, prSuffix)
}
Expand All @@ -98,13 +106,18 @@ func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error {

// branchStatusIndicator returns a colored status icon for a branch:
// - ✓ (purple) if the PR has been merged
// - ◎ (yellow) if the PR is queued in a merge queue
// - ⚠ (yellow) if the branch needs rebasing (non-linear history)
// - ○ (green) if there is an open PR
func branchStatusIndicator(cfg *config.Config, s *stack.Stack, b stack.BranchRef) string {
if b.IsMerged() {
return " " + cfg.ColorMagenta("✓")
}

if b.IsQueued() {
return " " + cfg.ColorWarning("◎")
}

baseBranch := s.ActiveBaseBranch(b.Branch)
if needsRebase, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !needsRebase {
return " " + cfg.ColorWarning("⚠")
Expand All @@ -131,6 +144,7 @@ type viewJSONBranch struct {
Base string `json:"base,omitempty"`
IsCurrent bool `json:"isCurrent"`
IsMerged bool `json:"isMerged"`
IsQueued bool `json:"isQueued"`
NeedsRebase bool `json:"needsRebase"`
PR *viewJSONPR `json:"pr,omitempty"`
}
Expand All @@ -156,6 +170,7 @@ func viewJSON(cfg *config.Config, s *stack.Stack, currentBranch string) error {
Base: b.Base,
IsCurrent: b.Branch == currentBranch,
IsMerged: b.IsMerged(),
IsQueued: b.IsQueued(),
}

// Check if the branch needs rebasing (base not ancestor of branch).
Expand All @@ -170,6 +185,8 @@ func viewJSON(cfg *config.Config, s *stack.Stack, currentBranch string) error {
state := "OPEN"
if b.PullRequest.Merged {
state = "MERGED"
} else if b.IsQueued() {
state = "QUEUED"
}
jb.PR = &viewJSONPR{
Number: b.PullRequest.Number,
Expand Down
133 changes: 133 additions & 0 deletions cmd/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,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/stack"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -283,3 +284,135 @@ func TestViewShort_FullyMergedStack(t *testing.T) {
assert.Contains(t, output, "b1")
assert.Contains(t, output, "b2")
}

// TestViewShort_QueuedStack verifies that --short output shows queued
// branches with a "queued" separator and the ◎ icon.
func TestViewShort_QueuedStack(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}},
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3}},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return tmpDir, nil },
CurrentBranchFn: func() (string, error) { return "b3", nil },
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
})
defer restore()

// Mock GitHub client to return b1 as queued (MergeQueueEntry set)
cfg, outR, _ := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindAnyPRForBranchFn: func(branch string) (*github.PullRequest, error) {
switch branch {
case "b1":
return &github.PullRequest{
Number: 1,
ID: "PR_1",
MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_1"},
}, nil
case "b2":
return &github.PullRequest{Number: 2, ID: "PR_2"}, nil
case "b3":
return &github.PullRequest{Number: 3, ID: "PR_3"}, nil
}
return nil, nil
},
}

cmd := ViewCmd(cfg)
cmd.SetArgs([]string{"--short"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

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

assert.NoError(t, err)
assert.Contains(t, output, "b1")
assert.Contains(t, output, "b2")
assert.Contains(t, output, "b3")
assert.Contains(t, output, "queued", "should show queued separator")
assert.Contains(t, output, "◎", "should show queued icon for b1")
}

// TestViewShort_MixedQueuedAndMerged verifies that --short output shows
// both "queued" and "merged" separators in the correct order.
func TestViewShort_MixedQueuedAndMerged(t *testing.T) {
s := stack.Stack{
Trunk: stack.BranchRef{Branch: "main"},
Branches: []stack.BranchRef{
{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}},
{Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}},
{Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3}},
},
}

tmpDir := t.TempDir()
writeStackFile(t, tmpDir, s)

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return tmpDir, nil },
CurrentBranchFn: func() (string, error) { return "b3", nil },
IsAncestorFn: func(string, string) (bool, error) { return true, nil },
RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil },
})
defer restore()

// b1 is merged (persisted), b2 is queued (from API)
cfg, outR, _ := config.NewTestConfig()
cfg.GitHubClientOverride = &github.MockClient{
FindAnyPRForBranchFn: func(branch string) (*github.PullRequest, error) {
switch branch {
case "b2":
return &github.PullRequest{
Number: 2,
ID: "PR_2",
MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_2"},
}, nil
case "b3":
return &github.PullRequest{Number: 3, ID: "PR_3"}, nil
}
return nil, nil
},
}

cmd := ViewCmd(cfg)
cmd.SetArgs([]string{"--short"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
err := cmd.Execute()

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

assert.NoError(t, err)
assert.Contains(t, output, "queued", "should show queued separator")
assert.Contains(t, output, "merged", "should show merged separator")

// "merged" section (b1) should appear below "queued" section (b2) in output
// Since we render top-to-bottom: b3 (active) -> queued separator -> b2 -> merged separator -> b1
queuedIdx := indexOf(output, "queued")
mergedIdx := indexOf(output, "merged")
assert.Less(t, queuedIdx, mergedIdx, "queued separator should appear before merged separator")
}

func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/sys v0.39.0
golang.org/x/text v0.32.0
)

Expand Down Expand Up @@ -45,7 +46,6 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/thlib/go-timezone-local v0.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading
Loading