Skip to content

Commit f3ffc3f

Browse files
committed
addressing review comments
1 parent 09ed2ff commit f3ffc3f

6 files changed

Lines changed: 66 additions & 9 deletions

File tree

cmd/merge.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ prompting, using your last-used merge method unless one is specified.
6363
6464
Only basic pull request state is checked before merging (open and not a draft);
6565
GitHub evaluates branch protection and repository rules when the merge runs, so
66-
any such failure is reported back to you. Bypassing merge requirements with admin
67-
privileges is not supported for stacks.
66+
any such failure is reported back to you. Bypassing merge requirements is not
67+
supported for stacks.
6868
6969
If the base branch uses a merge queue, the stack is added to the queue and merges
7070
once the queue processes it; otherwise it is merged directly.`,
@@ -151,6 +151,15 @@ func runMerge(cfg *config.Config, opts *mergeOptions, args []string) error {
151151
// Non-interactive (or --yes): merge the whole stack (or up to the given PR)
152152
// without prompting.
153153
if !target.hasPR {
154+
// A draft or closed pull request partway up the stack blocks everything
155+
// above it. Rather than silently merging only the portion below it,
156+
// refuse and let the user target an explicit pull request.
157+
if blocker != nil {
158+
top := candidates[len(candidates)-1].Number
159+
cfg.Errorf("cannot merge the whole stack: pull request #%d is %s", blocker.Number, blockerState(blocker))
160+
cfg.Printf("Merge up to #%d with `%s`", top, cfg.ColorCyan(fmt.Sprintf("gh stack merge %d", top)))
161+
return ErrInvalidArgs
162+
}
154163
targetPR = candidates[len(candidates)-1].Number
155164
}
156165
if method == "" {

cmd/merge_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,32 @@ func TestRunMerge_AlreadyMergedTarget(t *testing.T) {
272272
assert.Contains(t, output, "#1 is already merged")
273273
}
274274

275+
func TestRunMerge_WholeStackBlockedByDraft(t *testing.T) {
276+
submitCalled := false
277+
cfg, outR, errR := config.NewTestConfig()
278+
cfg.GitHubClientOverride = &github.MockClient{
279+
GetStackFn: func(n int) (*github.RemoteStack, error) {
280+
return remoteStack(5, "main", openStackPR(1, "b1"), draftStackPR(2, "b2"), openStackPR(3, "b3")), nil
281+
},
282+
RepoMergeConfigFn: func() (*github.RepoMergeConfig, error) {
283+
return &github.RepoMergeConfig{MergeAllowed: true, DefaultMethod: "merge"}, nil
284+
},
285+
MergeStackAsyncFn: func(pr int, method string) (*github.AsyncMergeResult, error) {
286+
submitCalled = true
287+
return nil, nil
288+
},
289+
}
290+
291+
err := runMerge(cfg, fastOptions(), []string{"5"})
292+
output := collectOutput(cfg, outR, errR)
293+
294+
assert.ErrorIs(t, err, ErrInvalidArgs)
295+
assert.Contains(t, output, "cannot merge the whole stack")
296+
assert.Contains(t, output, "#2 is a draft")
297+
assert.Contains(t, output, "gh stack merge 1")
298+
assert.False(t, submitCalled, "must not silently merge only the portion below the blocker")
299+
}
300+
275301
func TestRunMerge_NothingToMerge_AllMerged(t *testing.T) {
276302
setupLocalStack(t, 100, "b1", "b1", "b2")
277303
cfg, outR, errR := config.NewTestConfig()

docs/src/content/docs/guides/workflows.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ In an interactive terminal, a short wizard lets you choose how far up the stack
145145

146146
If the base branch uses a merge queue, `gh stack merge` adds the stack to the queue instead of merging directly — it merges once the queue processes it.
147147

148-
:::note[Admin bypass not supported]
149-
Stack merges currently do not support admin bypass merging.
148+
:::note[Bypassing merge requirements not supported]
149+
Stack merges do not support bypassing merge requirements.
150150
:::
151151

152152
## Syncing After Merges

internal/tui/mergeview/model.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,14 @@ const maxVisibleItems = 10
181181

182182
// visibleItems is the number of pull requests shown in the select window at
183183
// once — capped at maxVisibleItems and shrunk to fit a short terminal (each
184-
// item renders on two lines). When the terminal size is unknown, all are shown.
184+
// item renders on two lines). When the terminal size is unknown, it still caps
185+
// at maxVisibleItems so the first frame can't overflow a large stack.
185186
func (m Model) visibleItems() int {
186187
n := len(m.opts.PRs)
187188
if m.height <= 0 {
189+
if n > maxVisibleItems {
190+
return maxVisibleItems
191+
}
188192
return n
189193
}
190194
// Reserve lines for the header, scroll indicators, summary, and footer.

internal/tui/mergeview/model_test.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88

99
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/charmbracelet/lipgloss"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213
)
@@ -91,8 +92,9 @@ func TestSelect_Viewport(t *testing.T) {
9192
}
9293
m := New(opts)
9394

94-
// No size yet: all items are shown.
95-
assert.Equal(t, 30, m.visibleItems())
95+
// No size yet: capped at maxVisibleItems so a large stack can't overflow
96+
// the first frame.
97+
assert.Equal(t, 10, m.visibleItems())
9698

9799
// A tall terminal caps the window at maxVisibleItems (10).
98100
nm, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 60})
@@ -137,6 +139,21 @@ func TestSelect_ArrowDirection(t *testing.T) {
137139
assert.Equal(t, 2, m.cursor)
138140
}
139141

142+
func TestTruncate_WideRunes(t *testing.T) {
143+
// ASCII truncates to the requested width with a trailing ellipsis (plus an
144+
// ANSI reset, which has zero display width).
145+
ascii := truncate("abcdef", 3)
146+
assert.True(t, strings.HasPrefix(ascii, "ab…"))
147+
assert.LessOrEqual(t, lipgloss.Width(ascii), 3)
148+
149+
// Double-width runes must not push the result past the requested display
150+
// width (each CJK rune is two cells).
151+
assert.LessOrEqual(t, lipgloss.Width(truncate("你好世界", 5)), 5)
152+
153+
// A string that already fits is returned unchanged.
154+
assert.Equal(t, "hi", truncate("hi", 5))
155+
}
156+
140157
func TestSelect_AdvanceRequiresSelection(t *testing.T) {
141158
m := New(baseOptions())
142159

internal/tui/mergeview/view.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,13 +385,14 @@ func truncate(s string, width int) string {
385385
}
386386
continue
387387
}
388-
if w >= width-1 {
388+
rw := lipgloss.Width(string(r))
389+
if w+rw > width-1 {
389390
b.WriteString("…")
390391
b.WriteString("\x1b[0m")
391392
break
392393
}
393394
b.WriteRune(r)
394-
w++
395+
w += rw
395396
}
396397
return b.String()
397398
}

0 commit comments

Comments
 (0)