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
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260805-051311.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'merge: Eligible linear GitHub pull request stacks are merged atomically, with bottom-up fallback when native range merging is unavailable.'
time: 2026-08-05T05:13:11.409252-07:00
2 changes: 2 additions & 0 deletions internal/forge/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ type Repository interface {

FindChangesByBranch(ctx context.Context, branch string, opts FindChangesOptions) ([]*FindChangeItem, error)
FindChangeByID(ctx context.Context, id ChangeID) (*FindChangeItem, error)

// ChangeStatuses returns one status for each ID in the same order.
ChangeStatuses(ctx context.Context, ids []ChangeID) ([]ChangeStatus, error)

// ChangeChecks reports CI/checks for the given change.
Expand Down
72 changes: 30 additions & 42 deletions internal/handler/merge/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,33 @@ func (h *Handler) executePlan(
}
}

// The forge plans only the selected changes it can merge atomically. The
// scheduler will preserve every omitted change as an ordinary merge item.
var nativePlans []forge.MergeRangePlan
if stackRepository, ok := h.RemoteRepository.(forge.StackRepository); opts.Command == "" && ok {
changeByBranch := make(map[string]forge.ChangeID, len(plan))
for _, item := range plan {
changeByBranch[item.branch] = item.changeID
}

changes := make([]forge.StackChange, len(plan))
for i, item := range plan {
changes[i] = forge.StackChange{
Change: item.changeID,
BaseChange: changeByBranch[item.base],
BaseBranch: item.base,
}
}

var err error
nativePlans, err = stackRepository.PlanMergeRanges(ctx, changes)
if errors.Is(err, forge.ErrUnsupported) {
nativePlans = nil
} else if err != nil {
return fmt.Errorf("plan native merge ranges: %w", err)
}
}

var progress mergeProgress
if runner, ok := h.View.(ui.ModelView); ok {
widgetProgress := newWidgetMergeProgress(
Expand Down Expand Up @@ -673,6 +700,8 @@ func (h *Handler) executePlan(
Method: opts.Method,
})
if opts.Command != "" {
// A custom command owns merge transport and may implement semantics
// that one provider-native range request cannot preserve.
mergeRequester = &commandMergeRequester{
Runner: getCommandRunner(),
Script: opts.Command,
Expand Down Expand Up @@ -701,6 +730,7 @@ func (h *Handler) executePlan(
Progress: progress,
MergeRequester: mergeRequester,
ReadinessChecker: readinessChecker,
MergeRangePlans: nativePlans,

Trunk: h.Store.Trunk(),
ReadyTimeout: opts.ReadyTimeout,
Expand Down Expand Up @@ -933,48 +963,6 @@ func (e *mergePlanExecutor) awaitMergeabilityWithDelay(
}
}

// awaitMerged polls until the given change shows as merged.
// Uses exponential backoff starting at 500ms, capped at 8s.
func (e *mergePlanExecutor) awaitMerged(
ctx context.Context, item *mergeItem,
) error {
const (
_initialDelay = 500 * time.Millisecond
_maxDelay = 8 * time.Second
)

ctx, cancel := context.WithTimeout(ctx, e.MergeTimeout)
defer cancel()

// TODO: This only waits for the immediate change to reach
// the merged state.
// Server-side merge queues and richer merge workflows
// need a more expressive wait state.
delay := _initialDelay
for {
statuses, err := e.RemoteRepository.ChangeStatuses(
ctx, []forge.ChangeID{item.changeID},
)
if err != nil {
return fmt.Errorf("poll state: %w", err)
}

if statuses[0].State == forge.ChangeMerged {
return nil
}

e.Progress.Event(mergeProgressEvent{
Kind: mergeProgressWaitingForMerge,
Item: item,
})
if err := sleep(ctx, delay); err != nil {
return errors.New("timed out waiting for merge")
}

delay = min(delay*2, _maxDelay)
}
}

func sleep(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
Expand Down
87 changes: 82 additions & 5 deletions internal/handler/merge/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ type fakeChangeID string

func (f fakeChangeID) String() string { return string(f) }

type scriptedMergeOperation struct {
statuses []forge.MergeOperationStatus
calls int
}

func (o *scriptedMergeOperation) Status(
context.Context,
) (forge.MergeOperationStatus, error) {
status := o.statuses[o.calls]
o.calls++
return status, nil
}

func TestOptions_mergeTimeoutDefault(t *testing.T) {
var got Options
parser, err := kong.New(&got)
Expand Down Expand Up @@ -92,7 +105,12 @@ func TestAwaitMerged_immediate(t *testing.T) {
Method: forge.MergeMethodDefault,
}

err := executor.awaitMerged(t.Context(), item)
items := []*mergeItem{item}
err := executor.awaitMerged(
t.Context(),
items,
newChangeCompletionChecker(h.RemoteRepository, items),
)
require.NoError(t, err)
}

Expand Down Expand Up @@ -144,8 +162,53 @@ func TestAwaitMerged_afterPolling(t *testing.T) {
Method: forge.MergeMethodDefault,
}

err := executor.awaitMerged(t.Context(), item)
items := []*mergeItem{item}
err := executor.awaitMerged(
t.Context(),
items,
newChangeCompletionChecker(h.RemoteRepository, items),
)
require.NoError(t, err)
})
}

func TestAwaitMerged_operationAcceptedThenChangesMerge(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctrl := gomock.NewController(t)
items := []*mergeItem{
{branch: "feat1", changeID: fakeChangeID("pr-1")},
{branch: "feat2", changeID: fakeChangeID("pr-2")},
}
ids := []forge.ChangeID{
fakeChangeID("pr-1"),
fakeChangeID("pr-2"),
}

mockRepo := forgetest.NewMockRepository(ctrl)
mockRepo.EXPECT().
ChangeStatuses(gomock.Any(), ids).
Return([]forge.ChangeStatus{
{State: forge.ChangeMerged},
{State: forge.ChangeMerged},
}, nil)
h := newTestHandler(t, ctrl, testHandlerOpts{forgeRepo: mockRepo})
executor := new(mergePlanExecutor)
executor.Progress = newLogMergeProgress(silog.Nop())
executor.MergeTimeout = 2 * time.Minute
operation := &scriptedMergeOperation{
statuses: []forge.MergeOperationStatus{
forge.MergeOperationPending,
forge.MergeOperationAccepted,
},
}

changes := newChangeCompletionChecker(h.RemoteRepository, items)
err := executor.awaitMerged(t.Context(), items, &operationCompletionChecker{
operation: operation,
finalState: changes,
})
require.NoError(t, err)
assert.Equal(t, 2, operation.calls)
})
}

Expand Down Expand Up @@ -190,7 +253,12 @@ func TestAwaitMerged_respectsMergeTimeout(t *testing.T) {
Method: forge.MergeMethodDefault,
}

err := executor.awaitMerged(t.Context(), item)
items := []*mergeItem{item}
err := executor.awaitMerged(
t.Context(),
items,
newChangeCompletionChecker(h.RemoteRepository, items),
)
require.Error(t, err)
assert.EqualError(t, err, "timed out waiting for merge")
})
Expand Down Expand Up @@ -2359,7 +2427,16 @@ func TestExecutePlan_mergeCommandRequestsThenAwaitsMerge(t *testing.T) {
Return(nil)

h := newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
forgeRepo: &testStackRepository{
Repository: mockForge,
planMergeRanges: func(
context.Context,
[]forge.StackChange,
) ([]forge.MergeRangePlan, error) {
t.Fatal("custom merge command must bypass native range planning")
return nil, nil
},
},
sync: mockSync,
logBuffer: &logBuffer,
})
Expand Down Expand Up @@ -2702,7 +2779,7 @@ func TestValidateSynced_errorSkipped(t *testing.T) {
// newTestHandler supplies inert collaborators and an in-memory store
// for fields left unset.
type testHandlerOpts struct {
forgeRepo *forgetest.MockRepository
forgeRepo forge.Repository
store Store
service *MockService
restack *MockRestackHandler
Expand Down
Loading
Loading