From 1a5019f57727d71adb58477cd20cbd9c5da3f573 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 15 Apr 2026 15:24:07 -0400 Subject: [PATCH 01/11] fix for inflated diff counts when base branch has been updated since stack init --- internal/tui/stackview/data.go | 14 ++-- internal/tui/stackview/data_test.go | 102 ++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 internal/tui/stackview/data_test.go diff --git a/internal/tui/stackview/data.go b/internal/tui/stackview/data.go index 64f8191..a4108e9 100644 --- a/internal/tui/stackview/data.go +++ b/internal/tui/stackview/data.go @@ -45,15 +45,13 @@ func LoadBranchNodes(cfg *config.Config, s *stack.Stack, currentBranch string) [ node.IsLinear = isAncestor } - // For merged branches, use the merge-base (fork point) as the diff - // anchor since the base branch has moved past the merge point and - // a two-dot diff would show nothing after a squash merge. - isMerged := b.IsMerged() + // Use the merge-base (fork point) as the diff anchor so that we + // only show changes introduced on this branch. Without this, a + // diverged base (e.g. local main ahead of the branch's fork point) + // would inflate the diff with unrelated files. diffBase := baseBranch - if isMerged { - if mb, err := git.MergeBase(baseBranch, b.Branch); err == nil { - diffBase = mb - } + if mb, err := git.MergeBase(baseBranch, b.Branch); err == nil { + diffBase = mb } // Fetch commit range diff --git a/internal/tui/stackview/data_test.go b/internal/tui/stackview/data_test.go new file mode 100644 index 0000000..f0ff1e2 --- /dev/null +++ b/internal/tui/stackview/data_test.go @@ -0,0 +1,102 @@ +package stackview + +import ( + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + ghapi "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadBranchNodes_UsesMergeBaseForDivergedBranch(t *testing.T) { + // Scenario: local main has diverged from the branch's history. + // Without merge-base, diff would be computed against main directly, + // inflating the file count with unrelated changes. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feature"}}, + } + + var diffBase string + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(ancestor, descendant string) (bool, error) { + // main is NOT an ancestor of feature (diverged) + return false, nil + }, + MergeBaseFn: func(a, b string) (string, error) { + return "abc123", nil + }, + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{{SHA: "def456", Subject: "only commit"}}, nil + }, + DiffStatFilesFn: func(base, head string) ([]git.FileDiffStat, error) { + diffBase = base + return []git.FileDiffStat{ + {Path: "file1.go", Additions: 5, Deletions: 2}, + {Path: "file2.go", Additions: 3, Deletions: 1}, + }, nil + }, + }) + defer restore() + + cfg, outW, errW := config.NewTestConfig() + defer outW.Close() + defer errW.Close() + // Ensure no real GitHub API calls + cfg.GitHubClientOverride = &ghapi.MockClient{} + + nodes := LoadBranchNodes(cfg, s, "feature") + + require.Len(t, nodes, 1) + // Diff must be computed from merge-base, not from "main" directly. + assert.Equal(t, "abc123", diffBase, "diff should use merge-base as base, not the branch name") + assert.Len(t, nodes[0].FilesChanged, 2) + assert.Equal(t, 8, nodes[0].Additions) + assert.Equal(t, 3, nodes[0].Deletions) + assert.False(t, nodes[0].IsLinear) +} + +func TestLoadBranchNodes_LinearBranchStillUsesMergeBase(t *testing.T) { + // When base IS an ancestor (linear history), merge-base returns the + // base tip, so behavior is unchanged. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feature"}}, + } + + var diffBase string + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(ancestor, descendant string) (bool, error) { + return true, nil + }, + MergeBaseFn: func(a, b string) (string, error) { + // For linear history, merge-base returns the base tip + return "main-tip-sha", nil + }, + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return nil, nil + }, + DiffStatFilesFn: func(base, head string) ([]git.FileDiffStat, error) { + diffBase = base + return []git.FileDiffStat{ + {Path: "only.go", Additions: 1, Deletions: 0}, + }, nil + }, + }) + defer restore() + + cfg, outW, errW := config.NewTestConfig() + defer outW.Close() + defer errW.Close() + cfg.GitHubClientOverride = &ghapi.MockClient{} + + nodes := LoadBranchNodes(cfg, s, "other") + + require.Len(t, nodes, 1) + assert.Equal(t, "main-tip-sha", diffBase) + assert.Len(t, nodes[0].FilesChanged, 1) + assert.True(t, nodes[0].IsLinear) +} From 1542900d192d09f5836afbcce90e09066c3d2b96 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Apr 2026 13:41:41 -0400 Subject: [PATCH 02/11] fast forward active branches where local is behind remote --- cmd/rebase.go | 3 + cmd/rebase_test.go | 194 +++++++++++++++++++++++++++++++++++++++++++- cmd/sync.go | 8 +- cmd/sync_test.go | 198 ++++++++++++++++++++++++++++++++++++++++++++- cmd/utils.go | 49 +++++++++++ 5 files changed, 448 insertions(+), 4 deletions(-) diff --git a/cmd/rebase.go b/cmd/rebase.go index 26059cc..0e4ee48 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -135,6 +135,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } } + // Fast-forward stack branches that are behind their remote tracking branch. + fastForwardBranches(cfg, s, remote, currentBranch) + cfg.Printf("Stack detected: %s", s.DisplayChain()) currentIdx := s.IndexOf(currentBranch) diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 1b70db0..f72eef7 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "github.com/github/gh-stack/internal/config" @@ -34,7 +35,13 @@ func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps { return &git.MockOps{ GitDirFn: func() (string, error) { return tmpDir, nil }, CurrentBranchFn: func() (string, error) { return currentBranch, nil }, - RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + RevParseFn: func(ref string) (string, error) { + // Default: origin/ returns same SHA as (no FF needed) + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } + return "sha-" + ref, nil + }, IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, FetchFn: func(string) error { return nil }, EnableRerereFn: func() error { return nil }, @@ -848,3 +855,188 @@ func TestRebase_Abort_WithActiveRebase(t *testing.T) { // Should return to original branch assert.Contains(t, checkouts, "b1", "should checkout original branch at end") } + +// TestRebase_FastForwardsBranchFromRemote verifies that when origin/b1 is ahead +// of local b1 (someone pushed a new commit), the branch is fast-forwarded before +// the cascade rebase so downstream branches include the new commits. +func TestRebase_FastForwardsBranchFromRemote(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var allRebaseCalls []rebaseCall + var updateBranchRefCalls []struct{ branch, sha string } + + mock := newRebaseMock(tmpDir, "b2") + // b1 is behind origin/b1 (remote has new commit) + mock.RevParseFn = func(ref string) (string, error) { + if ref == "b1" { + return "b1-local-sha", nil + } + if ref == "origin/b1" { + return "b1-remote-sha", nil + } + // trunk and origin/trunk same — trunk already up to date + if ref == "main" || ref == "origin/main" { + return "main-sha", nil + } + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + // b1-local is ancestor of b1-remote → can fast-forward + if a == "b1-local-sha" && d == "b1-remote-sha" { + return true, nil + } + return false, nil + } + mock.UpdateBranchRefFn = func(branch, sha string) error { + updateBranchRefCalls = append(updateBranchRefCalls, struct{ branch, sha string }{branch, sha}) + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(base string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base}) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + + // b1 should be fast-forwarded to remote SHA + require.Len(t, updateBranchRefCalls, 1, "should fast-forward b1 via UpdateBranchRef") + assert.Equal(t, "b1", updateBranchRefCalls[0].branch) + assert.Equal(t, "b1-remote-sha", updateBranchRefCalls[0].sha) + + assert.Contains(t, output, "Fast-forwarded b1") + + // Cascade rebase should still occur + assert.NotEmpty(t, allRebaseCalls, "cascade rebase should still happen") +} + +// TestRebase_BranchAlreadyUpToDate_NoFF verifies that when a branch's local +// and remote SHAs match, no fast-forward occurs. +func TestRebase_BranchAlreadyUpToDate_NoFF(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var updateBranchRefCalls int + var mergeFFCalls int + + mock := newRebaseMock(tmpDir, "b1") + // Same SHA for b1 and origin/b1 — already up to date (default mock handles this) + mock.UpdateBranchRefFn = func(string, string) error { + updateBranchRefCalls++ + return nil + } + mock.MergeFFFn = func(string) error { + mergeFFCalls++ + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + assert.Equal(t, 0, updateBranchRefCalls, "no UpdateBranchRef for branches already up to date") + assert.Equal(t, 0, mergeFFCalls, "no MergeFF for branches already up to date") +} + +// TestRebase_BranchDiverged_NoFF verifies that when local and remote branches +// have diverged (e.g., after a previous local rebase), no fast-forward occurs. +func TestRebase_BranchDiverged_NoFF(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var updateBranchRefCalls int + + mock := newRebaseMock(tmpDir, "b1") + // Different SHAs for b1 and origin/b1 + mock.RevParseFn = func(ref string) (string, error) { + if ref == "b1" { + return "b1-local-sha", nil + } + if ref == "origin/b1" { + return "b1-remote-sha", nil + } + if ref == "main" || ref == "origin/main" { + return "main-sha", nil + } + return "sha-" + ref, nil + } + // Neither is ancestor of the other — diverged + mock.IsAncestorFn = func(a, d string) (bool, error) { + return false, nil + } + mock.UpdateBranchRefFn = func(string, string) error { + updateBranchRefCalls++ + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + assert.Equal(t, 0, updateBranchRefCalls, "no FF when branches have diverged") +} diff --git a/cmd/sync.go b/cmd/sync.go index 019059a..c7035a6 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -116,9 +116,13 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } } - // --- Step 3: Cascade rebase (only if trunk moved) --- + // --- Step 2b: Fast-forward stack branches behind their remote tracking branch --- + updatedBranches := fastForwardBranches(cfg, s, remote, currentBranch) + branchesUpdated := len(updatedBranches) > 0 + + // --- Step 3: Cascade rebase (if trunk or any branch moved) --- rebased := false - if trunkUpdated { + if trunkUpdated || branchesUpdated { cfg.Printf("") cfg.Printf("Rebasing stack ...") diff --git a/cmd/sync_test.go b/cmd/sync_test.go index a842ae4..6704040 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "io" + "strings" "testing" "github.com/github/gh-stack/internal/config" @@ -27,7 +28,13 @@ func newSyncMock(tmpDir string, currentBranch string) *git.MockOps { return &git.MockOps{ GitDirFn: func() (string, error) { return tmpDir, nil }, CurrentBranchFn: func() (string, error) { return currentBranch, nil }, - RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + RevParseFn: func(ref string) (string, error) { + // Default: origin/ returns same SHA as (no FF needed) + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } + return "sha-" + ref, nil + }, IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, FetchFn: func(string) error { return nil }, EnableRerereFn: func() error { return nil }, @@ -59,6 +66,9 @@ func TestSync_TrunkAlreadyUpToDate(t *testing.T) { if ref == "main" || ref == "origin/main" { return "aaa111aaa111", nil } + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } return "sha-" + ref, nil } mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { @@ -123,6 +133,10 @@ func TestSync_TrunkFastForward_TriggersRebase(t *testing.T) { if ref == "origin/main" { return "remote-sha", nil } + // Default: origin/ same as — no branch FF + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { @@ -647,3 +661,185 @@ func TestSync_PushFailureAfterRebase(t *testing.T) { assert.True(t, pushCalls[0].force, "push after rebase should use force") assert.Contains(t, output, "Push failed") } + +// TestSync_BranchFastForward_TriggersRebase verifies that when trunk hasn't +// moved but a stack branch has new remote commits, the branch is fast-forwarded, +// downstream branches are cascade-rebased, and force push is used. +func TestSync_BranchFastForward_TriggersRebase(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + var pushCalls []pushCall + var mergeFFCalls []string + + mock := newSyncMock(tmpDir, "b1") + // Trunk is up to date (same SHA), but b1 is behind origin/b1 + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" || ref == "origin/main" { + return "trunk-sha", nil + } + if ref == "b1" { + return "b1-local-sha", nil + } + if ref == "origin/b1" { + return "b1-remote-sha", nil + } + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + if a == "b1-local-sha" && d == "b1-remote-sha" { + return true, nil + } + return false, nil + } + mock.MergeFFFn = func(target string) error { + mergeFFCalls = append(mergeFFCalls, target) + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(base string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{branch: "(rebase)" + base}) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + + // b1 should be fast-forwarded via MergeFF (since we're on b1) + require.Len(t, mergeFFCalls, 1, "should fast-forward b1 via MergeFF") + assert.Equal(t, "origin/b1", mergeFFCalls[0]) + assert.Contains(t, output, "Fast-forwarded b1") + + // Cascade rebase should be triggered (even though trunk didn't move) + assert.NotEmpty(t, rebaseCalls, "rebase should occur when branch was fast-forwarded") + + // Push should use force-with-lease after rebase + require.Len(t, pushCalls, 1) + assert.True(t, pushCalls[0].force, "push should use force when rebase occurred after branch FF") +} + +// TestSync_BranchFastForward_WithTrunkUpdate verifies that when both trunk +// and a stack branch have remote updates, both are handled correctly. +func TestSync_BranchFastForward_WithTrunkUpdate(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var updateBranchRefCalls []struct{ branch, sha string } + var rebaseCalls []rebaseCall + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + // Trunk and b2 both behind remote + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "trunk-local", nil + } + if ref == "origin/main" { + return "trunk-remote", nil + } + if ref == "b2" { + return "b2-local", nil + } + if ref == "origin/b2" { + return "b2-remote", nil + } + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + if a == "trunk-local" && d == "trunk-remote" { + return true, nil + } + if a == "b2-local" && d == "b2-remote" { + return true, nil + } + return false, nil + } + mock.UpdateBranchRefFn = func(branch, sha string) error { + updateBranchRefCalls = append(updateBranchRefCalls, struct{ branch, sha string }{branch, sha}) + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(base string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{branch: "(rebase)" + base}) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + + // Both trunk and b2 should be updated + branchUpdates := make(map[string]string) + for _, c := range updateBranchRefCalls { + branchUpdates[c.branch] = c.sha + } + assert.Equal(t, "trunk-remote", branchUpdates["main"], "trunk should be fast-forwarded") + assert.Equal(t, "b2-remote", branchUpdates["b2"], "b2 should be fast-forwarded") + + assert.Contains(t, output, "fast-forwarded") + assert.NotEmpty(t, rebaseCalls, "rebase should occur") + require.Len(t, pushCalls, 1) + assert.True(t, pushCalls[0].force, "push should use force after rebase") +} diff --git a/cmd/utils.go b/cmd/utils.go index f6cf0cc..f167201 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -317,6 +317,55 @@ func activeBranchNames(s *stack.Stack) []string { return names } +// fastForwardBranches fast-forwards each active stack branch to its remote +// tracking branch when the local branch is strictly behind. Returns the names +// of branches that were updated. Branches that are up-to-date, diverged, or +// have no remote tracking branch are silently skipped. +func fastForwardBranches(cfg *config.Config, s *stack.Stack, remote, currentBranch string) []string { + var updated []string + for _, br := range s.Branches { + if br.IsSkipped() { + continue + } + + remoteRef := remote + "/" + br.Branch + refs, err := git.RevParseMulti([]string{br.Branch, remoteRef}) + if err != nil { + // Remote tracking branch doesn't exist — skip. + continue + } + localSHA, remoteSHA := refs[0], refs[1] + + if localSHA == remoteSHA { + continue + } + + isAncestor, err := git.IsAncestor(localSHA, remoteSHA) + if err != nil || !isAncestor { + // Diverged or error — skip. This commonly happens after a + // local rebase and is handled by the push step. + continue + } + + // Local is behind remote — fast-forward. + if currentBranch == br.Branch { + if err := git.MergeFF(remoteRef); err != nil { + cfg.Warningf("Failed to fast-forward %s from remote: %v", br.Branch, err) + continue + } + } else { + if err := git.UpdateBranchRef(br.Branch, remoteSHA); err != nil { + cfg.Warningf("Failed to fast-forward %s from remote: %v", br.Branch, err) + continue + } + } + + cfg.Successf("Fast-forwarded %s to %s", br.Branch, short(remoteSHA)) + updated = append(updated, br.Branch) + } + return updated +} + // resolvePR resolves a user-provided target to a stack and branch using // waterfall logic: PR URL → PR number → branch name. func resolvePR(cfg *config.Config, sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchRef, error) { From fe3840ff1ff6793e905ef63d6013a6f34d36f330 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 13 Apr 2026 15:33:11 -0400 Subject: [PATCH 03/11] fix for rev-parse error when rebasing over deleted branches --- cmd/rebase.go | 12 ++++++--- cmd/rebase_test.go | 61 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/cmd/rebase.go b/cmd/rebase.go index 0e4ee48..0d0ef41 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -172,9 +172,15 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { // Sync PR state before rebase so we can detect merged PRs. syncStackPRs(cfg, s) - branchNames := make([]string, len(s.Branches)) - for i, b := range s.Branches { - branchNames[i] = b.Branch + branchNames := make([]string, 0, len(s.Branches)) + for _, b := range s.Branches { + // Merged branches that no longer exist locally have no ref to + // resolve. They are always skipped during rebase, but we must + // also exclude them here to avoid a rev-parse error. + if b.IsMerged() && !git.BranchExists(b.Branch) { + continue + } + branchNames = append(branchNames, b.Branch) } originalRefs, err := git.RevParseMap(branchNames) if err != nil { diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index f72eef7..47255cf 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -133,6 +133,7 @@ func TestRebase_SquashMergedBranch_UsesOnto(t *testing.T) { } mock := newRebaseMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } mock.RevParseFn = func(ref string) (string, error) { if sha, ok := branchSHAs[ref]; ok { return sha, nil @@ -197,6 +198,7 @@ func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) { } mock := newRebaseMock(tmpDir, "b3") + mock.BranchExistsFn = func(name string) bool { return true } mock.RevParseFn = func(ref string) (string, error) { if sha, ok := branchSHAs[ref]; ok { return sha, nil @@ -927,7 +929,6 @@ func TestRebase_FastForwardsBranchFromRemote(t *testing.T) { output := string(errOut) assert.NoError(t, err) - // b1 should be fast-forwarded to remote SHA require.Len(t, updateBranchRefCalls, 1, "should fast-forward b1 via UpdateBranchRef") assert.Equal(t, "b1", updateBranchRefCalls[0].branch) @@ -1040,3 +1041,61 @@ func TestRebase_BranchDiverged_NoFF(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 0, updateBranchRefCalls, "no FF when branches have diverged") } + +func TestRebase_SkipsMergedBranchesNotExistingLocally(t *testing.T) { + // Simulates a stack where b1 is merged and its branch was auto-deleted + // from the remote, so it doesn't exist locally. + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + mock := newRebaseMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { + // b1 does not exist locally (deleted from remote after merge) + return name != "b1" + } + mock.RevParseMultiFn = func(refs []string) ([]string, error) { + // Only resolve refs that exist — b1 should not be in the list + shas := make([]string, len(refs)) + for i, r := range refs { + if r == "b1" { + t.Fatalf("RevParseMulti should not be called with non-existent branch b1") + } + shas[i] = "sha-" + r + } + return shas, nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "Skipping b1") + + // Only b2 should be rebased + require.Len(t, rebaseCalls, 1) + assert.Equal(t, "b2", rebaseCalls[0].branch) +} From 7f615ddc6b4fa210dea892539dcf12c443c26393 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 13 Apr 2026 18:21:51 -0400 Subject: [PATCH 04/11] fix for rev-parse error during sync --- cmd/sync.go | 14 +++++--- cmd/sync_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index c7035a6..8bb1362 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -129,10 +129,16 @@ func runSync(cfg *config.Config, opts *syncOptions) error { // Sync PR state to detect merged PRs before rebasing. syncStackPRs(cfg, s) - // Save original refs so we can restore on conflict - branchNames := make([]string, len(s.Branches)) - for i, b := range s.Branches { - branchNames[i] = b.Branch + // Save original refs so we can restore on conflict. + // Merged branches that no longer exist locally have no ref to + // resolve. They are always skipped during rebase but we must + // also exclude them here to avoid a rev-parse error. + branchNames := make([]string, 0, len(s.Branches)) + for _, b := range s.Branches { + if b.IsMerged() && !git.BranchExists(b.Branch) { + continue + } + branchNames = append(branchNames, b.Branch) } originalRefs, _ := git.RevParseMap(branchNames) diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 6704040..b13368d 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -549,6 +549,7 @@ func TestSync_SquashMergedBranch_UsesOnto(t *testing.T) { } mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } // Trunk behind remote to trigger rebase mock.RevParseFn = func(ref string) (string, error) { if ref == "main" { @@ -765,8 +766,8 @@ func TestSync_BranchFastForward_WithTrunkUpdate(t *testing.T) { writeStackFile(t, tmpDir, s) var updateBranchRefCalls []struct{ branch, sha string } - var rebaseCalls []rebaseCall - var pushCalls []pushCall + var rebaseCalls2 []rebaseCall + var pushCalls2 []pushCall mock := newSyncMock(tmpDir, "b1") // Trunk and b2 both behind remote @@ -803,15 +804,15 @@ func TestSync_BranchFastForward_WithTrunkUpdate(t *testing.T) { } mock.CheckoutBranchFn = func(string) error { return nil } mock.RebaseFn = func(base string) error { - rebaseCalls = append(rebaseCalls, rebaseCall{branch: "(rebase)" + base}) + rebaseCalls2 = append(rebaseCalls2, rebaseCall{branch: "(rebase)" + base}) return nil } mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { - rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + rebaseCalls2 = append(rebaseCalls2, rebaseCall{newBase, oldBase, branch}) return nil } mock.PushFn = func(remote string, branches []string, force, atomic bool) error { - pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + pushCalls2 = append(pushCalls2, pushCall{remote, branches, force, atomic}) return nil } @@ -829,7 +830,6 @@ func TestSync_BranchFastForward_WithTrunkUpdate(t *testing.T) { output := string(errOut) assert.NoError(t, err) - // Both trunk and b2 should be updated branchUpdates := make(map[string]string) for _, c := range updateBranchRefCalls { @@ -839,7 +839,83 @@ func TestSync_BranchFastForward_WithTrunkUpdate(t *testing.T) { assert.Equal(t, "b2-remote", branchUpdates["b2"], "b2 should be fast-forwarded") assert.Contains(t, output, "fast-forwarded") - assert.NotEmpty(t, rebaseCalls, "rebase should occur") - require.Len(t, pushCalls, 1) - assert.True(t, pushCalls[0].force, "push should use force after rebase") + assert.NotEmpty(t, rebaseCalls2, "rebase should occur") + require.Len(t, pushCalls2, 1) + assert.True(t, pushCalls2[0].force, "push should use force after rebase") +} + +func TestSync_MergedBranchDeletedFromRemote(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"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseOntoCalls []rebaseCall + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { + // b1 does not exist locally (deleted from remote after merge) + return name != "b1" + } + mock.RevParseMultiFn = func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i, r := range refs { + if r == "b1" { + t.Fatalf("RevParseMulti should not be called with non-existent branch b1") + } + if r == "main" { + shas[i] = "local-sha" + } else if r == "origin/main" { + shas[i] = "remote-sha" + } else { + shas[i] = "sha-" + r + } + } + return shas, nil + } + // Trunk behind remote to trigger rebase + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseOntoCalls = append(rebaseOntoCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "Skipping b1") + + // Only b2 should be rebased + require.Len(t, rebaseOntoCalls, 1) + assert.Equal(t, "b2", rebaseOntoCalls[0].branch) } From b6016ff2841ca8bf16878dc4f094d612af45991e Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 15 Apr 2026 18:12:58 -0400 Subject: [PATCH 05/11] clearer info msg with rebasing over merged PRs --- README.md | 2 +- cmd/rebase.go | 8 ++++---- cmd/rebase_test.go | 8 ++++---- cmd/sync.go | 2 +- cmd/sync_test.go | 4 ++-- docs/src/content/docs/reference/cli.md | 2 +- internal/git/git.go | 2 +- skills/gh-stack/SKILL.md | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f2e6463..baa33d0 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Pull from remote and do a cascading rebase across the stack. gh stack rebase [flags] [branch] ``` -Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. If a branch's PR has been squash-merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. +Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. If a branch's PR has been merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. If a rebase conflict occurs, the operation pauses and prints the conflicted files with line numbers. Resolve the conflicts, stage with `git add`, and continue with `--continue`. To undo the entire rebase, use `--abort` to restore all branches to their pre-rebase state. diff --git a/cmd/rebase.go b/cmd/rebase.go index 0d0ef41..caa3c32 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -188,7 +188,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return ErrSilent } - // Track --onto rebase state for squash-merged branches. + // Track --onto rebase state for merged branches. needsOnto := false var ontoOldBase string @@ -201,7 +201,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { base = s.Branches[absIdx-1].Branch } - // Skip branches whose PRs have already been merged (e.g. via squash). + // Skip branches whose PRs have already been merged. // Record state so subsequent branches can use --onto rebase. if br.IsMerged() { ontoOldBase = originalRefs[br.Branch] @@ -252,7 +252,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return ErrConflict } - cfg.Successf("Rebased %s onto %s (squash-merge detected)", br.Branch, newBase) + cfg.Successf("Rebased %s onto %s (adjusted for merged PR)", br.Branch, newBase) // Keep --onto mode; update old base for the next branch. ontoOldBase = originalRefs[br.Branch] } else { @@ -450,7 +450,7 @@ func continueRebase(cfg *config.Config, gitDir string) error { return ErrConflict } - cfg.Successf("Rebased %s onto %s (squash-merge detected)", branchName, newBase) + cfg.Successf("Rebased %s onto %s (adjusted for merged PR)", branchName, newBase) state.OntoOldBase = state.OriginalRefs[branchName] } else { var rebaseErr error diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 47255cf..cd77a90 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -106,10 +106,10 @@ func TestRebase_CascadeRebase(t *testing.T) { assert.Contains(t, output, "rebased locally") } -// TestRebase_SquashMergedBranch_UsesOnto verifies that when b1 has a merged PR, +// TestRebase_MergedBranch_UsesOnto verifies that when b1 has a merged PR, // it is skipped and b2 uses RebaseOnto with trunk as newBase and b1's original // SHA as oldBase. b3 also uses --onto (propagation). -func TestRebase_SquashMergedBranch_UsesOnto(t *testing.T) { +func TestRebase_MergedBranch_UsesOnto(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ @@ -171,7 +171,7 @@ func TestRebase_SquashMergedBranch_UsesOnto(t *testing.T) { } // TestRebase_OntoPropagatesToSubsequentBranches verifies that when multiple -// branches are squash-merged, --onto propagates correctly through the chain. +// branches are merged, --onto propagates correctly through the chain. func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, @@ -651,7 +651,7 @@ func TestRebase_Continue_RebasesRemainingBranches(t *testing.T) { } // TestRebase_Continue_OntoMode verifies the --continue path when UseOnto is -// set (squash-merged branches upstream). With no remaining branches, only +// set (merged branches upstream). With no remaining branches, only // RebaseContinue runs and the state is cleaned up. func TestRebase_Continue_OntoMode(t *testing.T) { s := stack.Stack{ diff --git a/cmd/sync.go b/cmd/sync.go index 8bb1362..a405817 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -197,7 +197,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { break } - cfg.Successf("Rebased %s onto %s (squash-merge detected)", br.Branch, newBase) + cfg.Successf("Rebased %s onto %s (adjusted for merged PR)", br.Branch, newBase) ontoOldBase = originalRefs[br.Branch] } else { var rebaseErr error diff --git a/cmd/sync_test.go b/cmd/sync_test.go index b13368d..78a58b0 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -522,10 +522,10 @@ func TestSync_PushForceFlagDependsOnRebase(t *testing.T) { } } -// TestSync_SquashMergedBranch_UsesOnto verifies that when a squash-merged +// TestSync_MergedBranch_UsesOnto verifies that when a merged // branch exists in the stack, sync's cascade rebase correctly uses --onto // to skip the merged branch and rebase subsequent branches onto the right base. -func TestSync_SquashMergedBranch_UsesOnto(t *testing.T) { +func TestSync_MergedBranch_UsesOnto(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 5853d9f..75d845f 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -223,7 +223,7 @@ gh stack rebase [flags] [branch] Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. -If a branch's PR has been squash-merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. +If a branch's PR has been merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. If a rebase conflict occurs, the operation pauses and prints the conflicted files with line numbers. Resolve the conflicts, stage with `git add`, and continue with `--continue`. To undo the entire rebase, use `--abort` to restore all branches to their pre-rebase state. diff --git a/internal/git/git.go b/internal/git/git.go index 8af505d..b7b9941 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -179,7 +179,7 @@ func SaveRerereDeclined() error { // git rebase --onto // // This replays commits after oldBase from branch onto newBase. It is used -// when a prior branch was squash-merged and the normal rebase cannot detect +// when a prior branch was merged and the normal rebase cannot detect // which commits have already been applied. // If rerere resolves all conflicts automatically, the rebase continues // without user intervention. diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 1dc3a51..6d58198 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -570,7 +570,7 @@ gh stack sync [flags] 1. **Fetch** latest changes from the remote 2. **Fast-forward trunk** to match remote (skips if already up to date, warns if diverged) -3. **Cascade rebase** all stack branches onto their updated parents (only if trunk moved). Handles squash-merged PRs automatically. If a conflict is detected, **all branches are restored** to their pre-rebase state and the command exits with code 3 — see [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow +3. **Cascade rebase** all stack branches onto their updated parents (only if trunk moved). Handles merged PRs automatically. If a conflict is detected, **all branches are restored** to their pre-rebase state and the command exits with code 3 — see [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow 4. **Push** all active branches atomically 5. **Sync PR state** from GitHub and report the status of each PR @@ -625,7 +625,7 @@ gh stack rebase --abort **Conflict handling:** See [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) in the Workflows section for the full resolution workflow. -**Squash-merge detection:** If a branch's PR was squash-merged on GitHub, the rebase automatically handles this and correctly replays commits on top of the merge target. +**Merged PR detection:** If a branch's PR was merged on GitHub, the rebase automatically handles this using `--onto` mode and correctly replays commits on top of the merge target. **Rerere (conflict memory):** `git rerere` is enabled by `init` so previously resolved conflicts are auto-resolved in future rebases. From 82ba7ea3fc54bf33d1f1b068b0eb0958f73bb983 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Apr 2026 16:13:42 -0400 Subject: [PATCH 06/11] udpate meta tags for docs site --- docs/astro.config.mjs | 12 ++++++++++-- docs/public/github-social-card.jpg | Bin 0 -> 19326 bytes 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 docs/public/github-social-card.jpg diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index f585e2a..28498ef 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -13,14 +13,22 @@ export default defineConfig({ integrations: [ starlight({ title: 'GitHub Stacked PRs', - description: 'Manage stacked branches and pull requests with the gh stack CLI extension.', + description: 'Break large changes into small, reviewable pull requests that build on each other — with native GitHub support and the gh stack CLI.', favicon: '/favicon.svg', logo: { src: './src/assets/github-invertocat.svg', alt: 'GitHub', }, head: [ - { tag: 'meta', attrs: { name: 'robots', content: 'noindex, nofollow' } }, + { tag: 'meta', attrs: { property: 'og:type', content: 'website' } }, + { tag: 'meta', attrs: { property: 'og:site_name', content: 'GitHub Stacked PRs' } }, + { tag: 'meta', attrs: { property: 'og:image', content: 'https://github.github.com/gh-stack/github-social-card.jpg' } }, + { tag: 'meta', attrs: { property: 'og:image:alt', content: 'GitHub Stacked PRs — Break large changes into small, reviewable pull requests' } }, + { tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } }, + { tag: 'meta', attrs: { property: 'og:image:height', content: '630' } }, + { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } }, + { tag: 'meta', attrs: { name: 'twitter:site', content: '@github' } }, + { tag: 'meta', attrs: { name: 'twitter:image', content: 'https://github.github.com/gh-stack/github-social-card.jpg' } }, ], components: { SocialIcons: './src/components/CustomHeader.astro', diff --git a/docs/public/github-social-card.jpg b/docs/public/github-social-card.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7911cf19a22837b2966bbf29bbe6eefa5b7024b3 GIT binary patch literal 19326 zcmb5Ub99|g)IWM+n~l-fYS@@3wr$(ClLjYlY`d{-qfukGv6J4@@B99K>)t=_oU@*_ zo@e%+`OL=5o;~w-?e8xDI#5zZ5&!`K06>6$fWI36F#rq{G&D364EO;90|N_(3=an` zXh=v1$QWptm>6gn7+APOcv#p3I2aiC`zYPs)KRQH69O zKq?hFif9drXi;SVJr#5@Ey#p6MnZ;iTnHE{bQOAvpm2aNVtn#UE?Hp~3|)C?3AT0x zyDoMCoyIUNqf%k8=rBN1Jcf!4GFb_r6(ge-14B^^fG|-46vqT1N*9^c=F%3@73jn$ zyOx*ctLr+eRJVqtw*Azq0!Ed9qzmaNqVmgQD1udJV*of5g-QS=GIXsNfHD9Y%7S}O z+f`Jhwy-ul3#c{0SY@X^r)$orj18HJy;Ox?K?NaQs3aGrmIQ0vMCj&{cqt zLn;6OA$c$6RD-BWDpz5edMo`IV+WI-J!5l&k;*U(y$a~FGLBtw5C9!pP2#gkOp=i@ z4rT>*a*(h}G5|728g|HWWI|7eLt>7rL9MF1$|MyxoJmg`*`85l0<3k&7)T`mxwtYN zOcWh003OhQODC=Zfaw3-etj_6GcQw*H)_|oOxw(~R8qo)U7|}{*vD!20(`+5|8eu8 z8hv=iq^AaKz=e>dMNb6+!qFj<`-K5e)Xu{{jt4z9&bOOwO_Ibb;`kEn&q#z)2Cl!( z7Qg)kq^$e?_;z^lA~$!X(R>z;l%fy6TqJx51SY0Igt@yTkG<3gq-*&qrjn6Kp zXWHYVXu$1Nux4_kJKYO+ylBBsjPHe%B`f*evf^g8%f_Rpv+qgI-{HKgW#)R`cdLWY zA$m`{SJ!;R#goUb%`nIOWl^={k)JmIOUlp}-d^#vBlEP9kxyr88yDWFLWx;;k;L}; zJ!Osxg$CcR&R+!ZBKLhh@AIv=&$tj%Ht$CqPrhnu9Zw=uwe26RxBm!uzwpod0Rq{l zRmiAI*eHuwWg(j8X=Gg~u&wd>2uAI&7?25?ym$`X_Hw_EIY_NMjb(IE8u4`rT*sz*M?YZQP#PItoX@YNJr1m|W*d1zY_0#BbApN^7OXgGh z53jhgg|4KVp%=L0XaDnl@Zat+_b*`XE0osrVES%*&L71Jk8Ob$!Ya@1g!Z?jJI5J= z=Y-sJ#uZMU53$72SfZ=h+2dJNMOELQcLw+KUA-&~E}Ps8d}j(!&=)C+v8(crP%X=& zdG4T<^BoyDvh%O$neChI%o{_S zeuqOEUq8Kz9OpLG$|pQm+?26ge$XW3HZ@&!i+Rrc@ozpi6nsb9rCIPN?7n^(oemW7 zYTe+!@5Ov%ArS-}JpGo^dnua{lJR^0{%RTgjx|@se0%%HHoelbha->YBk?3h=Ipy= z>Al@l6pb)ON$?f&>(!%n}!xmweTOiBSv9}8v8g^qDmM~-^aOb;c8wm9WFf2 z^HRThF1An~$_6##De?id09v>J@DB;{-0EEK>@bv@(Rpp#cbg{q=k0U*>H2K{n2W!u zfak?ajwAJhJ)fr6Zr2A1hk4K5+m4VX3Aedt=f)*R=cWH&z-%|#@|_N0S)e8Qp`2aC z<*8js7w&jJrHa{<3RL=d| z2xd=<^93&WTPRO(9Mpz?f;h!LPIOa=fz*CQ4ZqP&<^o<8(YrjPC&>3CgF zX-SL{x!P^JX71oj{dO5GGd>C^IDGTl4ESO?1*Ivgb}1Ns?El)^>&$GB=aHh^+0^*T zgz+tH-6Tzis#QtbTs=lhJ(d6l8nHukmakV2?MYd-m&w|On|XW4Ywu#j*p?k`Q^48$DhmHoTA#R#@80R23h(^ zCPrnZ#>ycxP(DOCHf|6+wycOOxR+GGVD)~U$=vPl#lz<b9(~64O>5{fB56L#c>G76TMyJKjopZ3^ zIHuU}`M6#5YA%A+HPJX}*C4vVSU4~-4}&6{&qzxfj+lyGdlXQZCNByMtNRQHRTm(D^%7mZdvJu+gYYCK+jKXbU%H zrRs#k03w1AF)HB;5n-%t?tA=NzbYzP-U@xKC?a}-=(-~nyd!?R;N8tDI&BztNIO>4 zt!-%<6q0JA4&(en_#P<)+p4uA#Gk;2X>-X#QZYHcPNUG()sw=Z7xxm>aoqA-_ID`I0a#TqSyRjhfCj);;LYQy@= zC@G8Z&)*(947Wl5Ul`#v{wM)kT&|VW+hl7d_?4BhGu)!^!IF?y~Uf+db-IF%EP9%cznbMX4NJS2Vw#mR1QJwbA*;4W;2G zklS6%a}B9yg8L+U;g55<$Gv6dPAjq1`SC`AAus$W1a)TYu(MPmp*|%qL?vO*iu>Io z(QMs|5+nc|T(YABAfO>2A>m=cktEbVe*y=T|3Xi63^F)OYz|JYFG^TAg5)CK^(d*|M8W&-8vA}fsuwVn_Hql7r46OnR|7_hmh4LH*0QELTV`zQ z2?{P;rCq(Ll6f-Tev?NqZc~enHcFPYSDR^H$fexKGAbo($UqWdCz{z`E~UD#OS(*2 zG%F-5Ym@fE^gBtA_VjNix@v9k5=R5H$2jT5J6csI+nA9;Y-NOMsv6%zdS9kll9F>k zcB4EaX41(mQPWH;sr8c>eBJSQ8Elh1C9^+>YJN62=$CJ`0QI@EEHsGyo(ZFL%x}g+ ze%Q1~YAy4$O`O6_YS2uq)ZE?72jm{E2KW|ri~WFxgfjX5xapW7G*zt^LuE45gkvzJ zq|)mkQ@EalnK8Gs;+r*M&eU^%pGdwWv~^p1ZlNR1!|=Tqw}~4D*{)Ggo3j<^NV4Zk z7)od4$cBX@F*P@NS*5w@n3=W;Fv*y6pN4%kqNlS($4B`wkA1ZEn_q)TSB=e%A``yw zVlUfhxkZczox@`Em2MUP{Z8)5A7$+ZgJ!!~%lTP+%+1Mz16R7Vs=Ey%ld~ocO_41~SPA~V8_s`9w$xOOuOi;%@x3f-~ zgkqE~)79}QLyV{fBlJs{jaD9XJN^+1=xY~-R3_tWw6yILU)aA?*!0+{^b@J`Mc0T_ zRlNM%AJd5W#vwGhKonZu!GDcZtZ*&1c}}YEC-n|1;&i~k=xq&NzE8^x0r;)XKg}1Mf-OOxU-H(zHAQh$avsExh zR(be}(`Lw9QQ81f@b>)_X%d$`p2pM{2B*?`DG{={w>T~IMxqLhSS;V~kAe5^L??$r zBx*6|$)fxK`38m83v|sc%#lZ+Z;5F%N7a{)Pv^XUjLA99g5>vKh9EiA^b^NT*xCfT zc?o^dk`|u*a%Ij{b~+O(lE*g3q>D_8Q_$p;Myl>JUB16GF17?uDaPcYs759IXmJqy zV#YMMn8-*$T+*N;7!51-|@$Gp_3tTxD7BbJuJV1uOwjq?Y#VQXdW_p|ObTO^))Kz(8v z^!&?95ymZFMt1U^%Xd8m8-nXWWLq{u^P$VatCC2+Ba$8s#soMTnIUx31Q}EoxdSGC zLSTuYC4?@!MtuCxd(Qu#txE}3|E4mHJMF1o_LJUay@xtN=fw-kJjBR0*iLQr2gPK z+edhYZ4{;72cU*2WesbUZum~W~)@Fa%9*js41`<*S+eQ$ZLAWC> zmo+g%$+qIT^2WJXp}ZSE5MKZEt+Dalq3p>YHb;dft9@ccO4N}i2q-}!MUdsl5lbT+ z0kqLb;teth)eDp7bK0%2<@E^mJOA`$8!F3{`N1zmxzDO&`p~%D;=xso=Q}C@^SNSH zyp)k9p5!m!gU;Mc)Ymca4hN=!T)&hqKg~k2dYzqjR$`;USioxi_gP%GUiXUcIvGMO z4d(ughppc2I@|zgd`8b3zdnEq>(5F)ds)krz@}$J0bNo{q4Aqb(Zq0s05jC+2gIfA z(6Y1yiI#1T1Tu0wMDzP*`AviIn{ME*lJI1@a~T5K@;$#8#2OO_hz8Zie~eMr^4pu9 zfXClRAx-khv}aeYy8RGiYX2Bv%hwtjzSda_Ul{&&l_ipom~#2?!b65mi0#`y@sHvH z(iX?(&$rbQB4775YH6%Q^!eY+t~wi;8eym3DX)8VruUXsp{5|AlEywvQ!8HEl@V%` z#r14WpQwXYuJtsrfwg;jJ5KFM5ikx1P04oy4J6pmqR?UR{NO1S^gld5T43pyNDi@; zhwyNQG205&#olTj&NT{ZnGZ@bc~3g6R!9d>qQuD#cB-T}lHNw?R0A+p{3;RWhkt7zd^Ud@u(zvxf2Reb)O&dfhl|AFp;Ui1d{kAa-tI+M_HEZL|h>!IyG2HI30 zm8s&#pnsEqml$P|Km(&t7*Wle6rF<9Eg@#^6--x(PU-hqXCE{rEjJ6oc%Qw0-O#q< zG0N{J|(Cq>xp<`1AheYfT~>N^zIaXm3E7mPs?T2i5DK|#U{B7 z%nI#D$)bw93I+8>9p-xNs;(ugLwxY#nuh6)$cU3wPOvd4$IQp zv1vw;+AR?;pZjVePHI2tol^+PRIFyu)uyH6wjsJ@= zpdcWGAl|-xPzd32kVbz(8%EpR$B@sKwtx4PCAE+*(x9M>*e}Royhf~68Td-0U_nT7 z#`rokq-yNsBc)U8cLco%A7lY5?50bxQ&Flu&On-3t#gaFhgM!_PrsKWbeiEO;z@Pr zb9UL>-i3%(O7p2A9-4aLRt*vYJ~nyM;FI-7(_}A)d}GZw&{>V32?g&h+EiJD<>n>B zJOpHZB(i1(o^}3Kz4AkC20y2h9y#vjb!zX1HaB@`WT zn_!8l+t3W?-g1;7M&wUy6OUQOY+j)m5_{Ll{ZkYqG((7#_;Qs2>{vs-L&f%TK>a<< zO&z5o_n-7@IuIwQLH0;`W`^Wv&2U!tjLhbGjsv1H+|dSG^U?xxHa5;AkBd0=Jz9cm z8n+X7u%_$1n6*rr=G$tO%P1QKS`|Mv%5{t2)>&XwzTN{(3QyTvQBHqz6C;KVmslI- z#?iePrMQsf25!NYN=>EvNZZf|~rnX20(vsd6x=eCyWu#VPG`F@;1q(eQ`8ZY z(B_^Y)$h;^jfz8ZYn{$zAq_YF{E6iC!sDo4B1piDSZ9HetUq&+XXw6mj%yAv#B=6~ z8I9Mu3nlKZ#w7Jx_p)vVhV)uCVcwBZgd3(kgKo;dW7buVY(Nb**(g&L+Lf*>t5T@< zo#x0We7@hODoefvlTM47d^J7+6I*m0L@>(4d*DJ9-x(_#l%oKkjOW3YNe8w}NC+q> zuwnk61S|w31{69O8#E?4E4#3g2o^R4hqBRsMhW@PC||Q#J_J7s8b7ioS^5NEH(dBSz zj{6H(Z2#nw?vAOANzNZ2mx-0M`C5U%WV@7v5epS)u!ub3b zkVIskk{Ba4uuf##>=cl_HpGTvHQSGosx%a!I5zx`?#zEI_R90QU?Mza<(EzkeJmzP zoh1A0Od3VpPs)l%q!57=awJiMuQm(a?qyKpA69b-1$q%wrKpJff-Y1-R}K%_X&Mhk z4{3CMN7c?X2~wXQOafr;m~rKKZd^1nznEeP=^jkjkZ8Y_?u3car)}^h;7(<Ovg zoTt#JKSdoJTz)$!JohCZYaaYfl0z*1JCctmdk2}lcJG5*`Q6cs7i$r%tzeqGlZNB~$Sm+Xf=)4% zR1qI5(akE|PhTj0DNuI<1OMxYR70}bgR#u)xca^&Sf|q9Z503t3Jw+l5eDkN`R4!3 zHe}=!Y$D3eSgh>E!3~_kN-8d{Nh$p>*p#A1CLy5(yBw;G^Vgrnl1=lA2Cn{huLCOt z-s`lmi0IgJEzrhG-YWDq+OyyWt)mRklTqcnME9P-575;R{E4fWZRc$QjwY-mF z(Xg8-bpB=N;|O{ER>$;vwF?v-S-iOD`iF|0b-BV%o<4K3(DJw(R}03L?U5ugN6@+| zwyt09=GJwLE<^1-S6gSj`loSz$|PMIlXGY^2YHJr8KX;!VlVT&?I;*&vNMXk+fT}p zjq=d{eHmq1UiHa3G|sk4j&IL_?`K}5TD#B=$~8LNGXb2(vBT|gs~urPC?xGP;>^ml zWaOQgDWT!vTBFE{&(}@qKgu@Cvg;B=^v6UXM<%h#6Re_o)lJz8UJO%9XN5hFiNl$v zlaU?~eT-#Ny{N2Jgg<|o+mYNEpgH@#PWq)u`I6tZLdXj&wp?5P{Xja4oP}A9A{agB z=KKZycN3~gO(^-r2YoZ^<>63=v!ghV%u_mr!V%VqL(9C5X40CKK>e$Z@iWzqb4`eC z=9E+J0L?>2?Owb)F#fV4ci3zPj8a#Jb+<05uUzoX^FSNCQ%U2!;k9`+wV@5wcaQ0% zFZ!0o?fL&um{S%Cl0#3Wfjqr`eRfn=yx6l49 z=p?FD6-#PMW;reJg0-$V_Fp;?ndD5q7kk=87IVX&U1!S>tU*6ySKkn+Geox`KdaPO z(M>X^W4Kq&9@RT_q6dU{wffwKoPnyQEzMD+_0EGjJw=1EI?&IXU*hbP|1-q;yZ7aZQcS$pPPwU%3bmU#``XfEKkx1 z)mLq}&rJjn*eTh6@Ll8;7^=9%9X9Zlt(ZMdO5BI~Vw()(xodPwpzfDbec7vBT9>ms z&JFXsAGH!y-w?;@Qd?oqB1C$v(`pT&PmA+B+TBrW&R38G)IGS&oy@gIdNma$~~KyHPy&@L1#3LH?AK}WF0gN zcATmxG-C#X&0MpCv8VY_^txmAya!KPyV}D=4phNDa&pDM-GO24?V%0dhlrB~vBG?j zecj&e>c`xwI{PH%6Y(Skb_zB}$~$fy?G^-iL-WOM@86%`s2z)3eKb`x9NJ7=bGWfZ zN>*`9Rrl2>^3S7NP3K|DJ!{9TH3AGID)4wM>Kx(CP!}vG`Boh-@h#R5Kgle9ulfs! zADFjDp*OKHS)Py6s2DyiwbiuSrt$0E*i1(go$bPuD9VcOR7TleMt)~q-fF-?IV0R9O|5S- z&tZ}UAjgl$({8M<@Ah{1jj)4(SdC{>FUq0?|K02XMQt%gGu6S=| zeWG9x*5C9H8Z)zqHJe^c5vDXuRm6LS6STN`wD6;+*9__FcdJf?75*tX8R?m|RrZ+C z;GbXDW9jS3q8XID#H3Yljd)5@dRV6#6fb~LSNu|)Z06c3xyD!=;X3bH|MRW^jX`C@ z&)i(xY-s`m!1+npcS&+GRGbkP_o<1sU=_`_euBfCcSza2;A1{)(#cZ=!rPuga@ASU zD0-o~Rb~V@$Atbp>Ca#HI^!Q+-_%EPUlA04kotML(FgwngFcH40g{*$b*pw%Jf23VP z08}=6+cr!yVMa*grf4T;f||Oo&^2%_T3*4Pfo6uFLM1S|SqN?~0na}8y7b=-4vx=b&9;EXktyg!&mWO%w&T>n4jwmQn zRgS03b90z5fPFu9!@-SKDftUvV#tfWIh4td0fK|8m=x7%WeMYUx##xqUjcn;U;O7K zv5#COEG*7qXp6Z~jeP_9=EF@IZ{ye9Pok#y9UX`Uu7r&4BK)rnlK=FIdkiZijsN?` zODgs?!(rnAwkm)AF7vdJ!<-w{NPaMzk~{UBtpW#!$a_C+lf67vtLpT}X+R(yc(vU& zMadJ|W{X~HI8Rucp2=z5O%kRzY3kjhh@oY7dX_?br@Xg`nR1v;f5?8kwPb2D)SNS1 zp^cU6nWZ^x4dk?qBl{&kNYi|9e59bg`zajusZQocuG!U%wI?Se>)0HL$65uiei^MR z9DVIlV&OdDUW$JqeQ8mqU}B1SLXT1 zzS;5X2pikygQMwBquesK25m#&UB^zThz1TUo6I6%>odD1R;qS_aU*SEXa);#fpf-w za~O?lLhf^r4_mk}R22(2{9{B1C$Rz0;2R&1;H>ulB(*Wn$=O86l#PS0oRbPzl^Xhm z=Z&0pum3m1gAgKrl@BbleZMyxue(P43-DTf;tGsiT@_kdrq_Itd$~vJYDUb*oXkQ- z`)>R$mHroC_-g_~-tnH;W-}qfC_gIevlUTYC~s}Y(*2hYO$zk+fQUM)h1hz(%s0ge zIs&Fvk*Ba*-QwS~7cCeTb=9sk?11qXyim-F(sfyx0Rd#*{N`X8ZW=~{obA$`v4W=L z40*RcOE#8rC7%Np14{{;{}-~aN4OKT!E@Wc$mP@8-BJlXgQK-$+qhVYmChVb|LNGmX zEPnIu6q=k(+|4`TwQclu!7BAY;Q;wltzWqbt6qba1t&*pKw~CJ*wV|S=qd0suRiG} zKp0oa$2c=BXOZ&LZ8&+<&&bZX>}?m6=w|WmY-zc7V{H5I zK7}-)ehY2#Lhzn)WXhfEnvI0h_-%29Cz=b%Low=YU?h4+v<4d{VX3TvB3kKSjp%`M|{G&1hq)$a5;F63$)!SYH0 z8zJB=FKAc% z0xq&yMIkx!P#9L7ssdU{ACUe(^zr zahU_X{8aX7MsTG)0<|CiGYO(G>UBcOF^D|1)>$w-;#Ozxsunl=7@kzcD;dzk(df62T<%YJ ze0T<_b(%e%&p+AMUwRGR+7L4>fZLHdJWz8N4^w}LX}Fq z?~A^}7~!1I|N4==L(G|W>wonl`%k|k_fDv`;}_lOv|AI~?08coPz}-Hx}W7EGsOK* z{bIrt`^z{{`7t9WLzPnkb}qd_WgJh_w1zDt9EbUAED!qO01K^Q?xZZrUWDL2e+vhl z@HhhXZ`kPR0A&QVM5AN0Y>`%QGb}zhjXrS z+UN$1obdA;)?v*x$F|bni!=jZV)5JPLwC(5+)255FKE`d(h(uP;d{yss@>$2#xbHF z=xR@?J6;MA(aZ~O846o8>AjMMFuB;YZE7``42K_A$j9zbTIC$MsrJ`;&8Uh}FBF;> zIpw+JJE7>vD(X9fsYY-in{hm-bTm`6Whsk`mOd!6p4?><6$j8K)TA>qh-fxX1>Dmv zS8O3^Q#0@zoTeky!yFx2`ekkc!7pyr&)qg*JcprKdd!r;d7v#BHb1hHoG70ann{2PBP`99e;Nt-;D zglD0l4xNeI=c?w4T1yW`<31k3T)N;p#rpFq2a|Mv3EQ*D!-dVC}7QGzFI3JVFYvMCxmOecZhlJxx~WB*Z0pt1_ivbLXna4iZdVg_VMS@;6D zr;s+Ti$D&C_@8}a)i)DR{0{!;FuG7xw{d;H1jh=jdWUAx1z?-Z=5|{+sdK3-Zm*aj z$zdxeMM(v}7acOKz3tzoDg8J{`l@Mly=>dDlk#c6KYiFG>q*{XykOXkIv1u-emt5($z0gQ{GAYpGfM6=EC#^QKj zMoFKlw~()K_odXaD%7n910=y6ms&@n23Z^2Rc+b(7ohS}mT&pqGgD|tC~2=<-j9+u zlbuvgk55%jYuK4|s+VR*sG_^34co2y&>YxNt!(>37lWDEM{%~X2)H_8=LLiTe@(1e_-XNd_E|{t zK^suYA4+5`p}&CjAkM#lMg&V#BwE%mwrN3AeEd1*D3#0G9p6mGe)5ITJDg6ww^ohq z0RWEIZTB?U=TcP6@o&OJhkGKX(iPtp-z%_FkXwJVbUiKxkLx~jexIR~a~C*%nr8Cx z&N&uboE`BO8f#IG&+zHmi;+Ts?C4{M9PH$=2d^;>mfv1+nm%59N}y`LifP7Q^&N96 zCh(KTm4?dyG)6nCg26u3aD{O9V#3PKmz{s1^P>~F3F){IM}5^9o!^LIbESn*(&i8% z6PknUWy6$v9gnW|Snw~!H;(wEFJdK8pyjtY8$bE=z2aKn30ry$9ijjV1p*%WGr9;g zk=gwPZ7wsBf=AYny3M8g4Px9A0G*W#8yzl+2^wAV4m%~*Ds!iBQ2)u9YR*yKM|u?1 z2m;dxT2^ETUj%K30UF&Sjlue%ewF`<{4*(*0fICD5nGY~`cYT}_M4(w*84JHhw2M? z*TKqP0RJi8z&lhH3M|O3Pgo24R`K|;wicP@rBeMv&F$5tWmr(%Ip<8BZ8{8#UQ+4y zVW@ms2-n&0qXS#1q*+Xr=^ZUxxUg-xn+Mt{2-XmhO#*WeZMe70?~d$?FIr%g1!HK% z6?wH$o1WI{hN2x0y75cHMq}NjL@c)+>mW+I^w_VxH-O3G+};(y-*asMo;E1{ktlX_ z_kqULi}f$y0T2i_{UHDi2H5WZ9oRsF4+#JFBoVx~Bm0-t4`wwk=x><6+D-azTlen- z5giK)Jp){FHLzIbO9<(_4DhfW(YO(!qvYg_zfjY}P^6a70ab2GhUtKM?CG3^CMI5@ zPI7H>CA(fKsU^(T&Rz>wT{8d3!Bw=GUJFO*oL$zgz%MGPa>jg&N#a+TKx$>SJ#aI! z*;xj7OzAOR)NJCz4cPzMy(*z|1sZtbh5w464Wp)}NJqj8XJ(;S*?Z>-V zommRO$m5TsHsMfWt>qsc&-9&gCwHx-zxJrVoSsk^si4$sHlc88*DOKk( zqt>_AX+!z)rH z4-hwUev>m@_|*%}9l}!#iRIn&F3_U|6y0t7rmKXlMHl=@Z7tinX(m8~1<+a`MXoA*Q+Q!kCSK8#8xRj;@3ePJ>W;pfvwJqVK^R@uZH zO|DBBO9#tED`Y?v{U-VjgQv6NtL%dasUVC8ZbTuUOTsVN6CHV)eq&=31QTOpe-v4k z<8^{z-%bX;^E-azB}fIxl^(mO7Hspfj1Razi!~+|tcKufnxHqZr!(0a>%H7vf4ru% zC|A-YUbqdOyd}=hsbG{&Ik-2_7MXh0cH!b;l7%rAFiwd*PojGc#Me{(JrBs0M27;JO%MRvGkj`213u_;! z2lh~K)j( zC5J%0a?YM%X8d|t&XP%MyJt4p?16ObE)RBjOO&A24)U{L6c5A3b!yB3gD^fZMMs1N z31`e%JAfd3>YYKcwZPV?D^jbukGhz@Q{|f25u` zjKsuh&o9qoUmH14M^rnBx~qCe4npp-;E=ASH29O#sR+m?2_OiDufxq`Av~xgE^%`W z$KP=-Mn+vZ#)hg{C!vH71S*{+{~~8#xT8_ZNFyO(`J^8;H^EHEW=b+WTJR-)xX77L z)Ed3qdj3Ze^k8`!(enLYfK-eX|7VXemk27D2)3J|3+3?suVU&+RHn^@o#^CPCspvC zVRa9(*tRTaXn5DrBJtY5FFX`ZTMvq%W!hd|=5J=7P4DJLMuwQbEv`wg!jA^4&b4(3 z4g2A3Ncj*N3s0xRMm<1BOZ6wBEf|DU_K%-E^Z5%56!Qsr=?s3+0>*A@7i3||H6)@f zhoOhnpqDIu$U=6}C#`xIHnlq7{Xx>Q2=<-$w#|hkhCiYwB=1yoNwWGEFoPN@PW(n_ z`50a>mIu|spN_r6gltNJ|85n?(nFo`7oc39LUA!f!QyNX@~JQyo-NMw6G9|t0zCwr zs}m52)Uekb3}gB<<8j3Rj1Nh)Z#>Yu;f75=~JMD!Op znY)D0vxu-@26QU1!f2IfajqI0HAqc$;6e#7wGRYIs`cWIB2P~^c~*{DK(9sx3|?@g z1ud$S0O#h;YEwipi=Ya)6!JmzY6v1_^NxgP0Cz37xZ?ShDnbZmuV1 z>I4Xa)G+solyIjmfK=l|&{;lJq5?)r*eX%Esma^2mk5LJFagiYlfm6o=%|5V)X=My z13~m~!onaF24L#c0|-HhPaad57DNy0V?!(%R-qCtHD95#Ld6GN0Y(I(*J!B$27{mU znj#CB9nu(7Uf^#LAYytAb74?Em3VNbHVE=icEq{dcfiOL2zQ(T(` zDi;SWVV0QYQ-RT{m8V1ggHoZyC69u_Z~}9JVxtCTHPrzF2&Vs^J{f?ZMIB9P@PJi? zQ>|4xY%~g7THwD!BY{w}g6ZMpfuK18wHgGKE+7a6t=t`gUQ-?nks!I2x&?V?p`f(| zk{U#+M5b1u`n4carNj!e1PI3ZPXtR9D*5hed4o}2*$ z(Wde-==?WcwJ8KJ?t{kxV@ev22nNmZ(OeYCAL~Yec%Bu*ml)` z+fgad*NSH7LQnwOy#$ZLDDQ^WH z20UhoSpHA78hMmvcw!s`m5Q9ZN82ZFgtZ>y~z$!=1Q> z67#}9JgGsAF&7WxPN3TP3kW8k*CEhU5`K{rj0X-?7;ur7pTHBamjJKn)wn_NP+--t zcT}pCMTQDtk)!pVXG46wEOZIzJNhf60&b&YnJ17 z{#Q$3(r*DE%hga|@S|b|P3JI+*~H_V7+5mmk-`?lTy++;wxdpWrXCMuFM7x$zU2W6 zuFHx8$fe`*;c!`k!rwLuqlj_n;j}TKDa41$F$;ww;!s3%sJAaB$m?vB6ED(5-ODjc zqzFeF*=)}t;y&qzZgyKECeLqq?msUJTP9_2!~NZYDxoZ z#4y4jsnc4m)1P7cH`W~}*RoCLJOr?bVOPgg?4pz6(Zkc%3qGX2MywrRG9rX2xR+0_ zBt0@g-~Np9-weVdK>G77FX}X!Lbz>85_)<*i28wLy)wv02Q?v>70sf+jTW~p?86*< zD{e@R!WBUV({8wM4!JaT1ENrlGlWtVix@vruFP)E`VTYiS`n{wJqi@u`-a7IT>Dy4&%Jtwd-ggl$wW2zvz3FuJ(4khH(q z)CF>h(nIpHKr}(3wPJFqQCpX^P;JsZ5Tq1?gM;nAIuY6Q#G$F6Ma1X*1^|GGPP25k zOf%7bd8}kJ*7^n4E+df^z_I~gVShj)Vpu<{E918C8DOGh4QWi8k+D_4rfF=|BcZr0-7JCu{7xf7J~TT3@nF;8^qfLR`-Bi3 zoo?owRWlUNyhgxPb8Wa$*B;}9w-rkK;qfGBU6~kb5-U z<6`&d4u_lG&C7_6n>>GnsIQyCM`UL6q}mT|&V{1Sg>6G~5ldU0d#tg5Pn|pFSuicT zp^niq>`Nrkn^#4~m0yb<<}~f$wpkv<7912k0*kk}ztl-44&jCTQr)y)*Pe_gA5v254?**& z!#kECIvaRXOP^eSY_V+f$gQuL6bA~Ecy8sll}Gu4h(7<+E7O_(;rSrtb^c_Ro@e)( z$j`rOj~C}DqHRe0D?gLJkGmXpNWMY`njBsfJyIB$%`piCVKS;jF}2Xc`AH0!JB__h zmo+m$01w;O6e+}X_t~6(5tjB%3|0ios?FcDc^$If2JtAAEWnHLuU5G93TP7iA115c| z`my4ATXnKL1Z%P9!m&SFCo3z2V`^gCnSad{i$H*p*pa=&Fa2x{tv}a{2Df{T$MPLQ zsS&ppWk-Q#o*>d&lrPrxxdu7DCPI+0db|r8OcD>MCO+-)UBuDh8lLv7&J6H*Q6G5=~u=u_#ec7)&>t{k75vjTqXk3=N&6HV`JbJHc0 z0Il7zV!3DtLwSvG*)b94?iETZ#eN_U>T?eyb!t_bVq8bM!{OD_!B=u5_u=Lnb_O5o zFEf`K?*Q02NQx{NE&Y9uU42)zb#wkjt=d4k>gE8$2Pto<$8{o$O&Q{@B*Z zjrFB6n~eEH5MAOjwF0da#e0457u#jAzg{N`r>tq`hL}M312VRFp{O{Au%7j`7y=*~ zW9)m;1$N5krZA-4vM7U7sT?TfQ_%Y}N=g1kTCnIf!Zmsh zHdTJ81O!zhtr7G86CerS_NOS?WS2=r%)z?LovAKUv&%qYkj=i7KKHwqGpyph; z0lKI(Q&0^C4hPJxM%Kb)JdkSlxs?X&ArA89?KeUiY}DvAbg5NuU65cO0Ab|BLNM6B zYQ7qLLIfRb2}hE|zT=>*+^q%y-C0p*1(%k#d1!)KqsII@0om;AfE*R0sH0+!ahE0c zXYxv6I|2M^DA=RnpZloqpu36bss8}Jf|(Tb&;9nIjfy@e{`xAmtL@QMwYR&xRa&dv zfZ!v-#R^dJz%U1fYmd(=2)&#ZtkgLmDFq8w%#(4xomcA<$*Rc$wCv zbze!hRc2O>-&*C%m-b`rC>HxU^$;;HjIep(=z*;|OG(>OphtAU?Z4^bCrBEw88+a0 zjGK65U-RZ!A*?v^^Br8oPGXl$tvVG18b&YyUfsRQ{&t2(1B2<04NifA5YQ-xLb^jA zLZi{0Nc6w{6M6o0M+$x%kK>kZ1IKHs3FXo9L7kQ??r`g4+r(5dSoC1?y1^MsMpoAm z)tJWp8xue0&Iyr=!K1%1H)(J?Lwz=BvK#0^ZqU#*BN#)h-ZG_hGNBzvG12-i`kLU3 zL+9(W76b4%^z+vMQCLlhKw8Dt?%8{Igvt2+W?0YVMBBu6z4YzIKZz{QJ)Ps$2FP!r z3A;iN)YhPC@2@c5UV5IAGRXAYxq3QROZ0vQ-baRB7sP#ch>HOdk|?W5+(qSOS|5)o z;GD0Vh4cRaAp{VE`uR`rP0xKh8c^z)k1(XhV0VW48)|Q;o3tSgg-V3I9+Ow8=ox=P zwYh&rhfOHPKkv-C1L4T<zof>X`Gm;fd(~01P*TA)&ueLJ%cPIxDKP14l^iBO$;&BlaY9N;R*uibRZw8FRt>tRgh=z;zZT|q+ zLK{um8}v75LK{LG^}3oG-nS}Lsa>T{S3dIEh6S_Qc6cgRE5K7XQ zEvRn@Y8>>2hNk^)q`11ZtEJ12Q=zB|8z3H5P~O{w_HXU`P0T@Y_2BUj@H<4D9Icr_ z=-n4jQC;FR1wl8JkS5(P&dr@WXYzZYzp)kD9T=E3WwOGlIX{8u`^}it=p_@)JoC#M zB{U9d3?UAN%?(DIwKwZ}9dD+lHDS=}a12v95AlLqLBFbh)144RW*dLjASATfXI__c z<{6%O9nBJi=6q-Y0=3(v`QE14{hsKYIFGY=gx*ZygU1hv)F* zu!8XvZo+JgUW#^aD0v=8XzAOf`Ps9f1j7?FP!EO&?XEHZ0AEt9Bf+<)AHJTSEU%g7 z#O=zrm+YHOJG3-5gg1eqp{b?12Cl3l9*fm=b-8o@0F!n>%iw@*;mhha{GU|;8D5B;7U-(BofDE4h0OD+v zrLuW6e3Q$i$+;c+O~n4UiL~kcnXu6WKJi=;rWuUS-~C5Wai;#<{-Ab)H(?i(@o;%&YfNkp z$>_vZ^ATZUo%&F56>l6E6H(upVPl4Q;*e?utFUt(d*n2qo<4T?h7OwxTHP|e!XSh< z(|AKd;h_$IiJ*z2s*>f-dM+%#pwHxXO~~)iuE?~+RA26(R`5ro_hKTGd3MvY0loYK zc3UFF{{RQ^h^YwLt*Z%80)Se0J9~_EtroAjdEPC>`XEZVc-~M2oZNoVA2@C?=V-Jm zs%h(i;QYbNs#prR3Ub2b{{W45{X!7l8%^CIp$T&3%hKTIMXg;O4uy*@UsB7D`Z!I< z@1U6b2*iVk4+oKWna@ZzS8oug$t~s!tzR^&5aI=@fbz$K*+U;resLEq?zixEJ!-WO zh}AHvYsWA79HDW@`eLRO+pUeFhVO|$fab@){BHrObIJJS?fXEiaj5J;PUIoSD}Am% zXhIvqZAPPg4ug2j-8V(h$5T}o=sN!Z1F~*Mcnm`wQE@@C`8}r2N;)G1w>mjEWo4`L z8IK0w)N2cd9WUk%LNkT&ck;juv|>bxV#n)TBm}%Ihlp#a4Wd)1wp1)b6(lnOdEH(L QhJ9*~d`p)uT)A`q+1dF=dH?_b literal 0 HcmV?d00001 From dcbed25eb716dfc83d34f72db37afa06d3932d5a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 15 Apr 2026 07:37:51 -0400 Subject: [PATCH 07/11] stack diagram styling fixes --- docs/src/assets/stack-diagram.svg | 48 -------------------- docs/src/components/StackDiagram.astro | 62 ++++++++++++++++++++++++++ docs/src/content/docs/index.mdx | 6 +-- 3 files changed, 64 insertions(+), 52 deletions(-) delete mode 100644 docs/src/assets/stack-diagram.svg create mode 100644 docs/src/components/StackDiagram.astro diff --git a/docs/src/assets/stack-diagram.svg b/docs/src/assets/stack-diagram.svg deleted file mode 100644 index b0296ec..0000000 --- a/docs/src/assets/stack-diagram.svg +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - main - - - - - PR #1 · auth-layer - - - - - PR #2 · api-endpoints - - - - - PR #3 · frontend - - - - - - -← bottom -← top - diff --git a/docs/src/components/StackDiagram.astro b/docs/src/components/StackDiagram.astro new file mode 100644 index 0000000..583267b --- /dev/null +++ b/docs/src/components/StackDiagram.astro @@ -0,0 +1,62 @@ +--- +/** + * Inline SVG stack diagram — uses currentColor to inherit text color from the + * parent element, which is styled via external CSS. This avoids Safari bugs + * where @media (prefers-color-scheme) doesn't work inside SVG elements. + */ +--- +
+ + + + + + + + + + + main + + + + + PR #1 · auth-layer + + + + + PR #2 · api-endpoints + + + + + PR #3 · frontend + + + + + + + ← bottom + ← top + +
+ + diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index fdcb414..d6b8804 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -17,7 +17,7 @@ hero: import { Card, CardGrid, Aside } from '@astrojs/starlight/components'; import { Image } from 'astro:assets'; -import stackDiagram from '../../assets/stack-diagram.svg'; +import StackDiagram from '../../components/StackDiagram.astro'; import stackNavigator from '../../assets/screenshots/stack-navigator.png'; @@ -43,9 +43,7 @@ Large pull requests are hard to review, slow to merge, and prone to conflicts. R A **stack** is a series of pull requests in the same repository where each PR targets the branch of the PR below it, forming an ordered chain that ultimately lands on your main branch. -
- A stack of pull requests: main at the bottom, with auth-layer (PR #1), api-endpoints (PR #2), and frontend (PR #3) stacked on top -
+ GitHub understands stacks end-to-end: the pull request UI shows a **stack map** so reviewers can navigate between layers, branch protection rules are enforced against the **final target branch** (not just the direct base), and CI runs for every PR in the stack as if they were targeting the final branch. From b63aa165aaac59a81f8ee9e9bde0bcf6522a8bc3 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 16 Apr 2026 00:12:56 -0400 Subject: [PATCH 08/11] backfill ontoOldBase for deleted merged branches --- cmd/rebase.go | 11 +++++++++++ cmd/rebase_test.go | 10 +++++++--- cmd/sync.go | 14 ++++++++++++++ cmd/sync_test.go | 7 +++++-- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/cmd/rebase.go b/cmd/rebase.go index caa3c32..5310aa3 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -188,6 +188,17 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return ErrSilent } + // Backfill originalRefs for merged branches that were deleted locally. + // The rebase loop uses originalRefs[br.Branch] as ontoOldBase; without + // a valid entry the subsequent --onto rebase would receive an empty ref. + for _, b := range s.Branches { + if b.IsMerged() && !git.BranchExists(b.Branch) { + if b.Head != "" { + originalRefs[b.Branch] = b.Head + } + } + } + // Track --onto rebase state for merged branches. needsOnto := false var ontoOldBase string diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index cd77a90..3fdf5a5 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -1044,11 +1044,12 @@ func TestRebase_BranchDiverged_NoFF(t *testing.T) { func TestRebase_SkipsMergedBranchesNotExistingLocally(t *testing.T) { // Simulates a stack where b1 is merged and its branch was auto-deleted - // from the remote, so it doesn't exist locally. + // from the remote, so it doesn't exist locally. The stored Head SHA is + // used as ontoOldBase for the next branch's --onto rebase. s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ - {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}}, + {Branch: "b1", Head: "b1-stored-head-sha", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}}, {Branch: "b2"}, }, } @@ -1095,7 +1096,10 @@ func TestRebase_SkipsMergedBranchesNotExistingLocally(t *testing.T) { assert.NoError(t, err) assert.Contains(t, output, "Skipping b1") - // Only b2 should be rebased + // Only b2 should be rebased, and the rebase should use b1's stored + // Head SHA as oldBase so `git rebase --onto` receives valid arguments. require.Len(t, rebaseCalls, 1) assert.Equal(t, "b2", rebaseCalls[0].branch) + assert.Equal(t, "main", rebaseCalls[0].newBase) + assert.Equal(t, "b1-stored-head-sha", rebaseCalls[0].oldBase) } diff --git a/cmd/sync.go b/cmd/sync.go index a405817..26c38d3 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -142,6 +142,20 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } originalRefs, _ := git.RevParseMap(branchNames) + // Backfill originalRefs for merged branches that were deleted locally. + // The rebase loop uses originalRefs[br.Branch] as ontoOldBase; without + // a valid entry the subsequent --onto rebase would receive an empty ref. + for _, b := range s.Branches { + if b.IsMerged() && !git.BranchExists(b.Branch) { + if b.Head != "" { + if originalRefs == nil { + originalRefs = make(map[string]string) + } + originalRefs[b.Branch] = b.Head + } + } + } + needsOnto := false var ontoOldBase string diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 78a58b0..709d63c 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -848,7 +848,7 @@ func TestSync_MergedBranchDeletedFromRemote(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ - {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b1", Head: "b1-stored-head-sha", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, {Branch: "b2"}, }, } @@ -915,7 +915,10 @@ func TestSync_MergedBranchDeletedFromRemote(t *testing.T) { assert.NoError(t, err) assert.Contains(t, output, "Skipping b1") - // Only b2 should be rebased + // Only b2 should be rebased, and the rebase should use b1's stored + // Head SHA as oldBase so `git rebase --onto` receives valid arguments. require.Len(t, rebaseOntoCalls, 1) assert.Equal(t, "b2", rebaseOntoCalls[0].branch) + assert.Equal(t, "main", rebaseOntoCalls[0].newBase) + assert.Equal(t, "b1-stored-head-sha", rebaseOntoCalls[0].oldBase) } From 27b8478ad18185d394802acdf2e5d1060016e9fd Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 15 Apr 2026 19:45:49 -0400 Subject: [PATCH 09/11] ensure upstack rebase checks immediate predecessor --- cmd/rebase.go | 13 ++++++++++ cmd/rebase_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/cmd/rebase.go b/cmd/rebase.go index 5310aa3..4f8e550 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -203,6 +203,19 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { needsOnto := false var ontoOldBase string + // Get --onto state from merged branches below the rebase range. + // Ensures that when --upstack excludes merged branches, we still check + // the immediate predecessor for a merged PR and use --onto if needed. + if startIdx > 0 { + prev := s.Branches[startIdx-1] + if prev.IsMerged() { + if sha, ok := originalRefs[prev.Branch]; ok { + needsOnto = true + ontoOldBase = sha + } + } + } + for i, br := range branchesToRebase { var base string absIdx := startIdx + i diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 3fdf5a5..cd2e2d1 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -495,6 +495,67 @@ func TestRebase_UpstackOnly(t *testing.T) { assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2") } +// TestRebase_UpstackWithMergedBranchBelow verifies that --upstack pre-seeds +// --onto state when a merged branch exists immediately below the rebase range. +func TestRebase_UpstackWithMergedBranchBelow(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var allRebaseCalls []rebaseCall + var currentCheckedOut string + + mock := newRebaseMock(tmpDir, "b2") + mock.CheckoutBranchFn = func(name string) error { + currentCheckedOut = name + return nil + } + mock.BranchExistsFn = func(name string) bool { return true } + mock.RebaseFn = func(base string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut}) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--upstack"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + // b2 is at index 1, upstack = [b2, b3]. b1 is merged below. + // b2 should use --onto because b1 was merged. + require.Len(t, allRebaseCalls, 2, "upstack should rebase b2 and b3") + + // b2: --onto rebase with b1's old SHA as old base + assert.Equal(t, "main", allRebaseCalls[0].newBase, "b2 should be rebased onto main (first non-merged ancestor)") + assert.Equal(t, "sha-b1", allRebaseCalls[0].oldBase, "b2 should use b1's original SHA as old base") + assert.Equal(t, "b2", allRebaseCalls[0].branch, "b2 should be the branch being rebased") + + // b3: --onto continues to propagate + assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2") + assert.NotEmpty(t, allRebaseCalls[1].oldBase, "b3 should also use --onto") +} + // TestRebase_SkipsMergedBranches verifies that merged branches are skipped // with an appropriate message. func TestRebase_SkipsMergedBranches(t *testing.T) { From 1612fa5ff35545be14e790e752b1d7eb65416ae2 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 15 Apr 2026 20:01:36 -0400 Subject: [PATCH 10/11] fix for stale ontoOldBase causing rebase conflicts --- cmd/rebase.go | 13 +++++- cmd/rebase_test.go | 79 +++++++++++++++++++++++++++++++++++ cmd/sync.go | 13 +++++- cmd/sync_test.go | 101 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 202 insertions(+), 4 deletions(-) diff --git a/cmd/rebase.go b/cmd/rebase.go index 4f8e550..a97199d 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -245,7 +245,18 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } } - if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil { + // If ontoOldBase is stale (not an ancestor of the branch), the + // branch was already rebased past it (e.g. by a previous run). + // Fall back to merge-base(newBase, branch) which gives the correct + // divergence point and avoids replaying already-applied commits. + actualOldBase := ontoOldBase + if isAnc, err := git.IsAncestor(ontoOldBase, br.Branch); err == nil && !isAnc { + if mb, err := git.MergeBase(newBase, br.Branch); err == nil { + actualOldBase = mb + } + } + + if err := git.RebaseOnto(newBase, actualOldBase, br.Branch); err != nil { cfg.Warningf("Rebasing %s onto %s — conflict", br.Branch, newBase) remaining := make([]string, 0) diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index cd2e2d1..6cca55b 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -240,6 +240,85 @@ func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) { "b4 should rebase --onto b3 with b3's original SHA as oldBase") } +// TestRebase_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch +// was already rebased past the merged branch's tip (e.g. by a previous run), +// the stale ontoOldBase is detected via IsAncestor and replaced with +// merge-base(newBase, branch) to avoid replaying already-applied commits. +func TestRebase_StaleOntoOldBase_FallsBackToMergeBase(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + // b1's local ref is the stale pre-squash tip from before a previous rebase. + // b2 was already rebased onto main by a previous run, so b1's old tip + // is NOT an ancestor of b2. + branchSHAs := map[string]string{ + "main": "main-sha", + "b1": "b1-stale-presquash-sha", + "b2": "b2-on-main-sha", + "b3": "b3-on-b2-sha", + } + + mock := newRebaseMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.RevParseFn = func(ref string) (string, error) { + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "default-sha", nil + } + mock.IsAncestorFn = func(ancestor, descendant string) (bool, error) { + // b1's stale SHA is NOT an ancestor of b2 (b2 was already rebased onto main) + if ancestor == "b1-stale-presquash-sha" { + return false, nil + } + return true, nil + } + mock.MergeBaseFn = func(a, b string) (string, error) { + if a == "main" && b == "b2" { + return "main-b2-mergebase", nil + } + return "default-mergebase", nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + require.Len(t, rebaseCalls, 2) + + // b2: stale ontoOldBase detected → falls back to merge-base(main, b2) + assert.Equal(t, rebaseCall{"main", "main-b2-mergebase", "b2"}, rebaseCalls[0], + "b2 should use merge-base as oldBase when ontoOldBase is stale") + + // b3: b2's SHA is a valid ancestor → uses it directly + assert.Equal(t, rebaseCall{"b2", "b2-on-main-sha", "b3"}, rebaseCalls[1], + "b3 should use b2's original SHA as oldBase (not stale)") +} + // TestRebase_ConflictSavesState verifies that when a rebase conflict occurs, // the state is saved with the conflict branch and remaining branches. func TestRebase_ConflictSavesState(t *testing.T) { diff --git a/cmd/sync.go b/cmd/sync.go index 26c38d3..2a5471c 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -195,7 +195,18 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } } - if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil { + // If ontoOldBase is stale (not an ancestor of the branch), the + // branch was already rebased past it (e.g. by a previous run). + // Fall back to merge-base(newBase, branch) to avoid replaying + // already-applied commits. + actualOldBase := ontoOldBase + if isAnc, err := git.IsAncestor(ontoOldBase, br.Branch); err == nil && !isAnc { + if mb, err := git.MergeBase(newBase, br.Branch); err == nil { + actualOldBase = mb + } + } + + if err := git.RebaseOnto(newBase, actualOldBase, br.Branch); err != nil { // Conflict detected — abort and restore everything if git.IsRebaseInProgress() { _ = git.RebaseAbort() diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 709d63c..e3dc532 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -564,7 +564,12 @@ func TestSync_MergedBranch_UsesOnto(t *testing.T) { return "default-sha", nil } mock.IsAncestorFn = func(a, d string) (bool, error) { - return a == "local-sha" && d == "remote-sha", nil + // Trunk: local is behind remote → triggers fast-forward + if a == "local-sha" && d == "remote-sha" { + return true, nil + } + // For --onto stale-check: old bases are valid ancestors (first-run) + return true, nil } mock.UpdateBranchRefFn = func(string, string) error { return nil } mock.CheckoutBranchFn = func(string) error { return nil } @@ -603,6 +608,93 @@ func TestSync_MergedBranch_UsesOnto(t *testing.T) { assert.True(t, pushCalls[0].force) } +// TestSync_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch +// was already rebased past the merged branch's tip, sync detects the stale +// ontoOldBase and falls back to merge-base for the correct divergence point. +func TestSync_StaleOntoOldBase_FallsBackToMergeBase(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"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseOntoCalls []rebaseCall + + branchSHAs := map[string]string{ + "b1": "b1-stale-presquash-sha", + "b2": "b2-on-main-sha", + "b3": "b3-on-b2-sha", + } + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "default-sha", nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + // Trunk: local is behind remote + if a == "local-sha" && d == "remote-sha" { + return true, nil + } + // b1's stale SHA is NOT an ancestor of b2 (already rebased) + if a == "b1-stale-presquash-sha" { + return false, nil + } + return true, nil + } + mock.MergeBaseFn = func(a, b string) (string, error) { + if a == "main" && b == "b2" { + return "main-b2-mergebase", nil + } + return "default-mergebase", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseOntoCalls = append(rebaseOntoCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.PushFn = func(string, []string, bool, bool) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + require.Len(t, rebaseOntoCalls, 2) + + // b2: stale ontoOldBase → falls back to merge-base(main, b2) + assert.Equal(t, rebaseCall{"main", "main-b2-mergebase", "b2"}, rebaseOntoCalls[0], + "b2 should use merge-base as oldBase when ontoOldBase is stale") + + // b3: b2's SHA is a valid ancestor → uses it directly + assert.Equal(t, rebaseCall{"b2", "b2-on-main-sha", "b3"}, rebaseOntoCalls[1], + "b3 should use b2's original SHA as oldBase") +} + // TestSync_PushFailureAfterRebase verifies that when push fails after a // successful rebase, the command does not return a fatal error — only a // warning is printed about the push failure. @@ -890,7 +982,12 @@ func TestSync_MergedBranchDeletedFromRemote(t *testing.T) { return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { - return a == "local-sha" && d == "remote-sha", nil + // Trunk FF check + if a == "local-sha" && d == "remote-sha" { + return true, nil + } + // For --onto stale-check: old bases are valid ancestors (first-run) + return true, nil } mock.UpdateBranchRefFn = func(string, string) error { return nil } mock.CheckoutBranchFn = func(string) error { return nil } From a4735672618e1602140b6afa980003a5680879e5 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Thu, 16 Apr 2026 09:32:07 -0400 Subject: [PATCH 11/11] preflight check for stacked prs before submit --- cmd/submit.go | 30 +++- cmd/submit_test.go | 226 +++++++++++++++++++++++++ cmd/utils.go | 15 +- docs/src/content/docs/reference/cli.md | 1 + internal/config/config.go | 6 +- skills/gh-stack/SKILL.md | 2 + 6 files changed, 271 insertions(+), 9 deletions(-) diff --git a/cmd/submit.go b/cmd/submit.go index 483015a..f2f2242 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -77,6 +77,32 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { return ErrAPIFailure } + // Verify that the repository has stacked PRs enabled. + stacksAvailable := s.ID != "" + if s.ID == "" { + if _, err := client.ListStacks(); err != nil { + cfg.Warningf("Stacked PRs are not enabled for this repository") + if cfg.IsInteractive() { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + proceed, promptErr := p.Confirm("Would you still like to create regular PRs?", false) + if promptErr != nil { + if isInterruptError(promptErr) { + printInterrupt(cfg) + return ErrSilent + } + return ErrStacksUnavailable + } + if !proceed { + return ErrStacksUnavailable + } + } else { + return ErrStacksUnavailable + } + } else { + stacksAvailable = true + } + } + // Sync PR state to detect merged/queued PRs before pushing. syncStackPRs(cfg, s) @@ -194,7 +220,9 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { } // Create or update the stack on GitHub - syncStack(cfg, client, s) + if stacksAvailable { + syncStack(cfg, client, s) + } // Update base commit hashes and sync PR state updateBaseSHAs(s) diff --git a/cmd/submit_test.go b/cmd/submit_test.go index 514d091..53c0ee4 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "net/url" + "os" "testing" "github.com/cli/go-gh/v2/pkg/api" @@ -829,3 +830,228 @@ func TestSubmit_CreatesMissingPRsAndUpdatesExisting(t *testing.T) { // Stack should be created with all 3 PRs assert.Contains(t, output, "Stack created on GitHub with 3 PRs") } + +func TestSubmit_PreflightCheck_404_BailsOut(t *testing.T) { + s := stack.Stack{ + // No ID — this is a new stack, so the pre-flight check will run. + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + pushed := false + mock := newSubmitMock(tmpDir, "b1") + mock.PushFn = func(string, []string, bool, bool) error { + pushed = true + return nil + } + restore := git.SetOps(mock) + defer restore() + + // Non-interactive config — should bail out immediately. + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + } + + cmd := SubmitCmd(cfg) + cmd.SetArgs([]string{"--auto"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrStacksUnavailable) + assert.Contains(t, output, "Stacked PRs are not enabled for this repository") + assert.False(t, pushed, "should not push when stacks are unavailable") +} + +func TestSubmit_PreflightCheck_404_Interactive_UserDeclinesAborts(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + pushed := false + mock := newSubmitMock(tmpDir, "b1") + mock.PushFn = func(string, []string, bool, bool) error { + pushed = true + return nil + } + restore := git.SetOps(mock) + defer restore() + + // Force interactive mode; survey will fail on the pipe, + // which is treated as a decline — same as user saying "no". + inR, inW, _ := os.Pipe() + inW.Close() + + cfg, _, errR := config.NewTestConfig() + cfg.In = inR + cfg.ForceInteractive = true + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + } + + cmd := SubmitCmd(cfg) + cmd.SetArgs([]string{"--auto"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrStacksUnavailable) + assert.Contains(t, output, "Stacked PRs are not enabled for this repository") + assert.False(t, pushed, "should not push when user declines") +} + +func TestSyncStack_SkippedWhenStacksUnavailable(t *testing.T) { + // Verify that syncStack is not called when stacksAvailable is false. + // This is the core behavior enabling unstacked PR creation. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, + }, + } + + createCalled := false + mock := &github.MockClient{ + CreateStackFn: func(prNumbers []int) (int, error) { + createCalled = true + return 42, nil + }, + } + + cfg, _, errR := config.NewTestConfig() + + // When stacksAvailable=true, syncStack should be called. + syncStack(cfg, mock, s) + assert.True(t, createCalled, "syncStack should call CreateStack when invoked") + + // When stacksAvailable=false, the caller (runSubmit) skips syncStack + // entirely — verified by the submit_test integration tests above. + // Here we just confirm the contract: if syncStack is NOT called, + // CreateStack is NOT called. + createCalled = false + // (not calling syncStack) + assert.False(t, createCalled, "CreateStack should not be called when syncStack is skipped") + + cfg.Err.Close() + _, _ = io.ReadAll(errR) +} + +func TestSubmit_PreflightCheck_EmptyList_Proceeds(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + pushed := false + mock := newSubmitMock(tmpDir, "b1") + mock.PushFn = func(string, []string, bool, bool) error { + pushed = true + return nil + } + mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{{Subject: "commit for " + head}}, nil + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil + }, + CreateStackFn: func([]int) (int, error) { return 99, nil }, + } + + cmd := SubmitCmd(cfg) + cmd.SetArgs([]string{"--auto"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + assert.True(t, pushed, "should proceed with push when ListStacks succeeds") +} + +func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) { + s := stack.Stack{ + ID: "42", // Existing stack — pre-flight check should be skipped. + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + listStacksCalled := false + mock := newSubmitMock(tmpDir, "b1") + mock.PushFn = func(string, []string, bool, bool) error { return nil } + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + listStacksCalled = true + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + FindPRForBranchFn: func(string) (*github.PullRequest, error) { + return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10"}, nil + }, + UpdateStackFn: func(string, []int) error { return nil }, + } + + cmd := SubmitCmd(cfg) + cmd.SetArgs([]string{"--auto"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + assert.False(t, listStacksCalled, "ListStacks should not be called when stack ID already exists") +} diff --git a/cmd/utils.go b/cmd/utils.go index f167201..9a09e48 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -20,13 +20,14 @@ var ErrSilent = &ExitError{Code: 1} // Typed exit errors for programmatic detection by scripts and agents. var ( - ErrNotInStack = &ExitError{Code: 2} // branch/stack not found - ErrConflict = &ExitError{Code: 3} // rebase conflict - ErrAPIFailure = &ExitError{Code: 4} // GitHub API error - ErrInvalidArgs = &ExitError{Code: 5} // invalid arguments or flags - ErrDisambiguate = &ExitError{Code: 6} // multiple stacks/remotes, can't auto-select - ErrRebaseActive = &ExitError{Code: 7} // rebase already in progress - ErrLockFailed = &ExitError{Code: 8} // could not acquire stack file lock + ErrNotInStack = &ExitError{Code: 2} // branch/stack not found + ErrConflict = &ExitError{Code: 3} // rebase conflict + ErrAPIFailure = &ExitError{Code: 4} // GitHub API error + ErrInvalidArgs = &ExitError{Code: 5} // invalid arguments or flags + ErrDisambiguate = &ExitError{Code: 6} // multiple stacks/remotes, can't auto-select + ErrRebaseActive = &ExitError{Code: 7} // rebase already in progress + ErrLockFailed = &ExitError{Code: 8} // could not acquire stack file lock + ErrStacksUnavailable = &ExitError{Code: 9} // stacked PRs not available for this repository ) // ExitError is returned by commands to indicate a specific exit code. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 75d845f..2a90504 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -441,3 +441,4 @@ gh stack feedback "Support for reordering branches" | 6 | Disambiguation required (branch belongs to multiple stacks) | | 7 | Rebase already in progress | | 8 | Stack is locked by another process | +| 9 | Stacked PRs not enabled for this repository | diff --git a/internal/config/config.go b/internal/config/config.go index f4ea5c7..f254e56 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,10 @@ type Config struct { // GitHubClientOverride, when non-nil, is returned by GitHubClient() // instead of creating a real client. Used in tests to inject a MockClient. GitHubClientOverride ghapi.ClientOps + + // ForceInteractive, when true, makes IsInteractive() return true + // regardless of the terminal state. Used in tests. + ForceInteractive bool } // New creates a new Config with terminal-aware output and color support. @@ -106,7 +110,7 @@ func (c *Config) PRLink(number int, url string) string { } func (c *Config) IsInteractive() bool { - return c.Terminal.IsTerminalOutput() + return c.ForceInteractive || c.Terminal.IsTerminalOutput() } func (c *Config) Repo() (repository.Repository, error) { diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 6d58198..ad928a4 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -539,6 +539,7 @@ gh stack submit --auto --draft - Pushes all active (non-merged) branches atomically (`--force-with-lease --atomic`) - Creates a new PR for each branch that doesn't have one (base set to the first non-merged ancestor branch) - After creating PRs, links them together as a **Stack** on GitHub (requires the repository to have stacks enabled) +- If stacks are not available (exit code 9), the repository does not have stacked PRs enabled. In interactive mode, `submit` offers to create regular (unstacked) PRs instead. In non-interactive mode, it exits with code 9. - Syncs PR metadata for branches that already have PRs **PR title auto-generation (`--auto`):** @@ -783,6 +784,7 @@ gh stack unstack feature-auth | 6 | Disambiguation required | A branch belongs to multiple stacks. Run `gh stack checkout ` to switch to a non-shared branch first | | 7 | Rebase already in progress | Run `gh stack rebase --continue` (after resolving conflicts) or `gh stack rebase --abort` to start over | | 8 | Stack is locked | Another `gh stack` process is writing the stack file. Wait and retry — the lock times out after 5 seconds | +| 9 | Stacked PRs unavailable | The repository does not have stacked PRs enabled. `submit` will offer to create regular (unstacked) PRs in interactive mode | ## Known limitations