From e06284ff5cc134d7432017c10e4ca17212ba1a21 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 13 Apr 2026 18:36:35 -0400 Subject: [PATCH 01/13] skip pulling merged branches during remote checkout (#16) * skip pulling merged branches during remote checkout * show message if stack fully merged --- cmd/checkout.go | 21 +++++++-- cmd/checkout_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++ cmd/merge.go | 2 +- cmd/utils.go | 6 +-- cmd/utils_test.go | 15 ++++--- 5 files changed, 133 insertions(+), 12 deletions(-) diff --git a/cmd/checkout.go b/cmd/checkout.go index e0d21624..d66087c1 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -92,7 +92,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { } else { // Non-numeric target — resolve against local stacks only var br *stack.BranchRef - s, br, err = resolvePR(sf, opts.target) + s, br, err = resolvePR(cfg, sf, opts.target) if err != nil { cfg.Errorf("%s", err) return ErrNotInStack @@ -194,10 +194,13 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, // Determine trunk (base branch of the first PR) and the target branch trunk := prs[0].BaseRefName var targetBranch string + allMerged := true for _, pr := range prs { if pr.Number == prNumber { targetBranch = pr.HeadRefName - break + } + if !pr.Merged { + allMerged = false } } if targetBranch == "" { @@ -205,6 +208,12 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, return nil, "", ErrAPIFailure } + if allMerged { + cfg.Infof("All PRs in this stack have been merged") + cfg.Printf("To start a new stack, use `%s`", cfg.ColorCyan("gh stack init")) + return nil, "", ErrSilent + } + remoteStackID := strconv.Itoa(remoteStack.ID) // Step 3: Check if the target branch is already in a local stack @@ -461,7 +470,9 @@ func importRemoteStack( } } - // Create local branches for each PR's head branch + // Create local branches for each PR's head branch. + // Skip merged PRs whose branches were deleted from the remote — + // these no longer exist upstream and can't be created locally. for _, pr := range prs { branch := pr.HeadRefName if git.BranchExists(branch) { @@ -469,6 +480,10 @@ func importRemoteStack( } remoteRef := remote + "/" + branch if err := git.CreateBranch(branch, remoteRef); err != nil { + if pr.Merged { + cfg.Infof("Skipping merged branch %s", branch) + continue + } cfg.Errorf("failed to pull branch %s from %s: %v", branch, remoteRef, err) return nil, ErrSilent } diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go index 7ce18fbd..61da63e8 100644 --- a/cmd/checkout_test.go +++ b/cmd/checkout_test.go @@ -618,6 +618,107 @@ func TestCheckout_NumericTarget_ClosedMergedPR(t *testing.T) { assert.False(t, sf.Stacks[0].Branches[1].PullRequest.Merged) } +func TestCheckout_NumericTarget_MergedBranchDeletedFromRemote(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { + return name == "main" + }, + FetchFn: func(remote string) error { return nil }, + CreateBranchFn: func(name, base string) error { + // Simulate merged branch deleted from remote: origin/feat-1 doesn't exist + if base == "origin/feat-1" { + return fmt.Errorf("failed to run git: fatal: not a valid object name: 'origin/feat-1'") + } + return nil + }, + SetUpstreamTrackingFn: func(branch, remote string) error { return nil }, + ResolveRemoteFn: func(branch string) (string, error) { + return "origin", nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { + return "abc123", nil + }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 60, PullRequests: []int{10, 11}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", Merged: true, State: "MERGED", URL: "https://github.com/o/r/pull/10"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", State: "OPEN", URL: "https://github.com/o/r/pull/11"}, + } + return prs[number], nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "11"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "feat-2", checkedOut) + assert.Contains(t, output, "Skipping merged branch feat-1") + assert.Contains(t, output, "Imported stack with 2 branches") +} + +func TestCheckout_NumericTarget_AllPRsMerged(t *testing.T) { + gitDir := t.TempDir() + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 70, PullRequests: []int{10, 11}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", Merged: true, State: "MERGED", URL: "https://github.com/o/r/pull/10"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", Merged: true, State: "MERGED", URL: "https://github.com/o/r/pull/11"}, + } + return prs[number], nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "11"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrSilent) + assert.Contains(t, output, "All PRs in this stack have been merged") + assert.Contains(t, output, "gh stack init") +} + func TestCheckout_NumericTarget_APIError(t *testing.T) { gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ diff --git a/cmd/merge.go b/cmd/merge.go index b882551a..8a298e27 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -49,7 +49,7 @@ func runMerge(cfg *config.Config, target string) error { // Resolve which branch to operate on. var br *stack.BranchRef if target != "" { - _, br, err = resolvePR(result.StackFile, target) + _, br, err = resolvePR(cfg, result.StackFile, target) if err != nil { cfg.Errorf("%s", err) return ErrNotInStack diff --git a/cmd/utils.go b/cmd/utils.go index 60cb0da8..f6cf0cc9 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -319,7 +319,7 @@ func activeBranchNames(s *stack.Stack) []string { // resolvePR resolves a user-provided target to a stack and branch using // waterfall logic: PR URL → PR number → branch name. -func resolvePR(sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchRef, error) { +func resolvePR(cfg *config.Config, sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchRef, error) { // Try parsing as a GitHub PR URL (e.g. https://github.com/owner/repo/pull/42). if prNumber, ok := parsePRURL(target); ok { s, b := sf.FindStackByPRNumber(prNumber) @@ -352,9 +352,9 @@ func resolvePR(sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchR return nil, nil, fmt.Errorf( "no locally tracked stack found for %q\n"+ - "This command currently only works with stacks created locally.\n"+ - "Server-side stack discovery will be available in a future release.", + "To pull down a stack from remote, use the PR number: `%s`", target, + cfg.ColorCyan("gh stack checkout "), ) } diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 0d0005f7..80901326 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -156,7 +156,8 @@ func TestResolvePR_ByPRNumber(t *testing.T) { }, } - s, br, err := resolvePR(sf, "42") + cfg, _, _ := config.NewTestConfig() + s, br, err := resolvePR(cfg, sf, "42") assert.NoError(t, err) assert.Equal(t, "feat-1", br.Branch) assert.Equal(t, 42, br.PullRequest.Number) @@ -176,7 +177,8 @@ func TestResolvePR_ByPRURL(t *testing.T) { }, } - s, br, err := resolvePR(sf, "https://github.com/o/r/pull/42") + cfg, _, _ := config.NewTestConfig() + s, br, err := resolvePR(cfg, sf, "https://github.com/o/r/pull/42") assert.NoError(t, err) assert.Equal(t, "feat-1", br.Branch) assert.Equal(t, "main", s.Trunk.Branch) @@ -196,7 +198,8 @@ func TestResolvePR_ByBranchName(t *testing.T) { }, } - s, br, err := resolvePR(sf, "feat-2") + cfg, _, _ := config.NewTestConfig() + s, br, err := resolvePR(cfg, sf, "feat-2") assert.NoError(t, err) assert.Equal(t, "feat-2", br.Branch) assert.Equal(t, 43, br.PullRequest.Number) @@ -214,7 +217,8 @@ func TestResolvePR_NotFound(t *testing.T) { }, } - _, _, err := resolvePR(sf, "nonexistent") + cfg, _, _ := config.NewTestConfig() + _, _, err := resolvePR(cfg, sf, "nonexistent") assert.Error(t, err) assert.Contains(t, err.Error(), "no locally tracked stack found") } @@ -234,7 +238,8 @@ func TestResolvePR_URLPrecedesNumber(t *testing.T) { }, } - _, br, err := resolvePR(sf, "https://github.com/o/r/pull/99") + cfg, _, _ := config.NewTestConfig() + _, br, err := resolvePR(cfg, sf, "https://github.com/o/r/pull/99") assert.NoError(t, err) assert.Equal(t, 99, br.PullRequest.Number) } From c80e10c9c585bb3380b58444ad1c47d62515c843 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Apr 2026 13:39:03 -0400 Subject: [PATCH 02/13] docs site styling (#20) * docs site styling * nav styling * tidying up some small styles * mobile nav styling --------- Co-authored-by: Dean --- docs/astro.config.mjs | 7 +- docs/public/fonts/MonaSans-Bold.woff2 | Bin 0 -> 59988 bytes docs/public/fonts/MonaSans-Regular.woff2 | Bin 0 -> 59400 bytes docs/src/assets/github-invertocat.svg | 1 + docs/src/components/CustomHeader.astro | 177 ++--- docs/src/styles/custom.css | 954 ++++++++++++++++++++++- 6 files changed, 1029 insertions(+), 110 deletions(-) create mode 100644 docs/public/fonts/MonaSans-Bold.woff2 create mode 100644 docs/public/fonts/MonaSans-Regular.woff2 create mode 100644 docs/src/assets/github-invertocat.svg diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 395e4aa0..f585e2a6 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -15,6 +15,10 @@ export default defineConfig({ title: 'GitHub Stacked PRs', description: 'Manage stacked branches and pull requests with the gh stack CLI extension.', favicon: '/favicon.svg', + logo: { + src: './src/assets/github-invertocat.svg', + alt: 'GitHub', + }, head: [ { tag: 'meta', attrs: { name: 'robots', content: 'noindex, nofollow' } }, ], @@ -24,9 +28,6 @@ export default defineConfig({ customCss: [ './src/styles/custom.css', ], - social: [ - { icon: 'github', label: 'GitHub', href: 'https://github.com/github/gh-stack' }, - ], tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 4 diff --git a/docs/public/fonts/MonaSans-Bold.woff2 b/docs/public/fonts/MonaSans-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..1f86963d994c9d0f3ba7cd21bbf9f0824691830d GIT binary patch literal 59988 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 `K9k-v8~6)ieLP zm#kd7&>t_j^`zfcuNrN+vksbTCr1t!sCrFv51e$o38c(~=3h{JBBV%o<17w`3ul~j zUqGlBE##l_Axg~ACsqAK4|_?Mw|mXQ7F)-qN<#|kGX;dHRYP-g@?YVr;C~gw@0};b zeG%;Ief`t*Y-H%@ZC~xCc3{&xywUBsWX<*W{}zs#{chIW-s~#IIzV%E4R?zsHJMs> z7cG2Yi&$UQ7-l4_*s>$#&y6Qv$^5c{Qh!y%GJA}Q9rBl%|B11Dr}}FB@ai2?E8PJp zF(D3F5Mh!6Qq#x&J8w>m6_j3PW##Q~rA=*YWv-@-)0+9#rqTM$vn7k|%5uB4WvxH5-tSqbrXnjc9nVK``4~`!1*x|*lP%6f zzh#moX%L!=ex;a^;pS%a#bqdC%9t}ehE4D9!5;1Do~?8Dc6ZPBWDoaPQSr8AiOpGL zYknj2osFOPJ`?z#g|94qW9|#KH%z?4^`5B@INtKhUOf~D>Iw}iOL^Iblw)YQ2G`55iq+OfU44zHpW*e^oGF%Pnw6Phb!J(U*;+Eks?4-E ztL({2`?A{pp!(#MU#Z^%ynQu0wEa7{ecQW&8TV1hzFoR5OOfA+4tep4q*&Q|cySTD{9ZHmpUIN-*}WkkWXF+4 zNUI-ZKr%S&b_2Ee=NL1MV2PEsK;m|ljBs0b5XqG%VbAOd<2vIniI7X4T5fSn+p&7d z%W?Cd+%zd0%6Pd8+0Id!cvyCuCScZh34eBGHN=k7?`q{*4T)E$=fbqEUOzhsugmPO z+c)&>W6?Qy`_3j_-|e)qCf=%Ya<0psx6h+K{^ZE2##!N)e59?czVr2r{Z*k`Qy=&E z62P0?aL#&BaXRh#k75iug&v*i@=_CQkBF)+&V3EpSc2p-&q$8tBZa-U z;lig~y5O>pQ9tl|0lOb@!*#^XcV_vyuPbc2v%H(abEC>c1)s!UHGYVX6bpSc7uKx@ zEmCykNV{-P>yA1f@ z=MAfGbjal&3V#IJQOr9z<1e#;YVV|K8TTd8FB(6}tER7<)kIw8%V|X<3+AyZF=N4T zsXSLjb0zT3k=)xNX|)z~UKEcRXh91sIpBt;2+F6?l!P{nw-C zI8@H_&Bn~6gdz+@Z;V6(LMM%GLf!do|5_$(WHRs|-ydd$6kXB$_7UToL+yW-l~7d6 zxiPGp$hSu7dS&Nr;NA+lmIK2u8RIY)E08m(Dc~aD6i<>0SFAhHO`oW*Vtf}j4(w*h zT|0RalAH-zOH}=tH0*FXWwVQ;J;UBZ=8*Mobc2RJ74L<_79?G(Iu$iK`q4h$|ESyN z;2Xucr_`IH(b{-A@EOYx6_!rXNOn!(QI>QRI~Kq@LxfTGbgFvwKs&j0s}b-#mv$H3 zL_L_ZG?@&DX>`3?l)UDu1_D`js(`(DK9P@o659j8D;|O&BPzH{O>9J z`$o5O3VMUNy)IXQ$0N7HUy1`6?0@ZLIa?js+u3{KAlib+SX96<(rRg=y2FuynE`Mp z8G}qreVI;(5kj3YO5jV3p^X(|Q4|_ui>^4vA!~Jvi=XzGDM=kMGmbrCel_wdW*)_) zF#+vm7;Wh#(Q33vN)QhtfcP4jV+4w;F-GBa?Tj%9yl%u;kluW-BVI8MEbqja3BvFE z7#D8$Vp89tDa7|*V*HN_({c7~Hx7q@3YzkG88`VVj3pjETp`PJx3kDohPuLuXwu!7 zvwhPj6qye9>2zu9)iPE|<8WaRYx*EZu5Cx3P zVST&2q|O>d^MzNQCXR+|s|4bMQDn92iMCQe-WLhunuH4k?D*wQj2 zd$)$S`LthBb*ZC}DUfAiX@rDrX|p@Nv6mHT=qWwi^gBx`_dNU;qthm|m#-X06(%}O ztJD>Lq0kN&HsRpDtM=OQhh9WJv6HDschFZFgQBBHD!Ickpbu8rZ#Q&+DDs3+-Mtzd p6wIlf=v;m3pej80uD~^Zr0pRf|D@k~IKM^J>m~gU?|iNB9suOA!jJ#} literal 0 HcmV?d00001 diff --git a/docs/src/assets/github-invertocat.svg b/docs/src/assets/github-invertocat.svg new file mode 100644 index 00000000..4752432c --- /dev/null +++ b/docs/src/assets/github-invertocat.svg @@ -0,0 +1 @@ + diff --git a/docs/src/components/CustomHeader.astro b/docs/src/components/CustomHeader.astro index 45bffdee..03fadbe8 100644 --- a/docs/src/components/CustomHeader.astro +++ b/docs/src/components/CustomHeader.astro @@ -1,5 +1,4 @@ --- -import Default from '@astrojs/starlight/components/SocialIcons.astro'; const base = import.meta.env.BASE_URL; --- @@ -11,162 +10,150 @@ const base = import.meta.env.BASE_URL; FAQ - -
- -
- - diff --git a/docs/src/styles/custom.css b/docs/src/styles/custom.css index 703db78c..c1b86e94 100644 --- a/docs/src/styles/custom.css +++ b/docs/src/styles/custom.css @@ -1,29 +1,959 @@ -/* Custom styles for gh-stack docs */ +/* ============================================================ + gh-stack docs — GitHub Primer Brand theme + Colors: https://primer.style/brand/primitives/color/ + Reference: github.com dark/light themes + ============================================================ */ +/* --- Mona Sans (GitHub typeface) --- */ +@font-face { + font-family: 'Mona Sans'; + src: url('/gh-stack/fonts/MonaSans-Regular.woff2') format('woff2'); + font-style: normal; + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: 'Mona Sans'; + src: url('/gh-stack/fonts/MonaSans-Bold.woff2') format('woff2'); + font-style: normal; + font-weight: 700; + font-display: swap; +} + +/* ============================================================ + 1. Color palette — Dark mode (default) + Values from github.com CSS custom properties + ============================================================ */ :root { - --sl-color-accent-low: #3730a3; - --sl-color-accent: #6366f1; - --sl-color-accent-high: #c7d2fe; + --sl-font: 'Mona Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', + 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji'; + --sl-font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + 'Liberation Mono', monospace; + + /* GitHub green accent */ + --sl-color-accent-low: #0e4a2e; + --sl-color-accent: #3fb950; + --sl-color-accent-high: #a7f3d0; + --sl-color-text-accent: #3fb950; + + --sl-color-white: #ffffff; + --sl-color-gray-1: #f0f6fc; + --sl-color-gray-2: #c9d1d9; + --sl-color-gray-3: #9198a1; + --sl-color-gray-4: #656d76; + --sl-color-gray-5: #3d444d; + --sl-color-gray-6: #2d333b; + --sl-color-black: #0d1117; + + --sl-color-hairline: #3d444d; + --sl-color-hairline-light: rgba(240, 246, 252, 0.1); + --sl-color-text: #f0f6fc; + --sl-color-text-invert: #0d1117; } +/* ============================================================ + 2. Color palette — Light mode + ============================================================ */ :root[data-theme='light'] { - --sl-color-accent-low: #c7d2fe; - --sl-color-accent: #4f46e5; - --sl-color-accent-high: #312e81; + --sl-color-accent-low: #dafbe1; + --sl-color-accent: #1a7f37; + --sl-color-accent-high: #0e4a2e; + --sl-color-text-accent: #1a7f37; + + --sl-color-white: #1f2328; + --sl-color-gray-1: #1f2328; + --sl-color-gray-2: #656d76; + --sl-color-gray-3: #656d76; + --sl-color-gray-4: #9198a1; + --sl-color-gray-5: #d1d9e0; + --sl-color-gray-6: #eaeef2; + --sl-color-black: #ffffff; + + --sl-color-hairline: #d1d9e0; + --sl-color-hairline-light: rgba(31, 35, 40, 0.1); + --sl-color-text: #1f2328; + --sl-color-text-invert: #ffffff; +} + +/* ============================================================ + 3. Body backgrounds + Clean flat backgrounds matching github.com + ============================================================ */ +body { + background: #0d1117 !important; + font-family: var(--sl-font) !important; +} + +:root[data-theme='light'] body { + background: #ffffff !important; +} + +/* Hero gradient glow — matches github.com/features/copilot green→purple pattern */ +body::before { + content: ''; + position: fixed; + inset: 0; + background: + radial-gradient(ellipse 90% 80% at 50% -5%, rgba(95, 237, 131, 0.18) 0%, rgba(131, 66, 250, 0.08) 30%, transparent 70%), + radial-gradient(ellipse 600px 600px at 15% 20%, rgba(131, 66, 250, 0.1), transparent), + radial-gradient(ellipse 500px 500px at 85% 60%, rgba(68, 147, 248, 0.06), transparent); + z-index: 0; + pointer-events: none; + display: none; +} + +body:has(.hero)::before { + display: block; +} + +:root[data-theme='light'] body::before { + background: + radial-gradient(ellipse 110% 50% at 50% 0%, rgba(15, 191, 62, 0.26) 0%, rgba(48, 148, 255, 0.18) 30%, transparent 60%), + radial-gradient(ellipse 500px 500px at 0% 15%, rgba(48, 148, 255, 0.22), transparent), + radial-gradient(ellipse 500px 500px at 100% 30%, rgba(15, 191, 62, 0.20), transparent); +} + +/* ============================================================ + 4. Header — Primer SubdomainNavBar style + ============================================================ */ +header { + background: rgba(13, 17, 23, 0.95) !important; + border-bottom: 1px solid #3d444d !important; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +:root[data-theme='light'] header { + background: rgba(255, 255, 255, 0.95) !important; + border-bottom: 1px solid #d1d9e0 !important; +} + +/* Override Starlight grid to flex for reordering */ +header .header { + display: flex !important; + align-items: center; + gap: 1rem; + padding: 0 1rem; +} + +/* Even padding on outer header container */ +header { + padding: 0.75rem 0.25rem !important; +} + +/* Flatten right-group so its children join the header flex container */ +header .right-group { + display: contents !important; +} + +/* Reorder header children: title(1) → links(2) → search(3) → theme(4) */ +header .title-wrapper { + order: 1; + flex: 0 0 auto; +} + +header .social-icons { + order: 2; + display: flex !important; + align-items: center; + gap: 0; + flex: 1 1 auto; + justify-content: flex-end; +} + +header .social-icons::after { + display: none !important; +} + +header div:has(> site-search) { + order: 3; + flex: 0 0 auto; + margin: 0 0.5rem; +} + +header site-search button { + border: 1px solid #3d444d !important; + border-radius: 6px; +} + +:root[data-theme='light'] header site-search button { + border-color: #d1d9e0 !important; +} + +header starlight-theme-select { + order: 4; + flex: 0 0 auto; + margin-top: 1px; +} + +/* Site title styling */ +.site-title { + color: #f0f6fc !important; + font-size: 1rem !important; + font-weight: 600 !important; + gap: 0.5rem !important; +} + +.site-title img { + width: 24px !important; + height: 24px !important; + filter: invert(1); +} + +:root[data-theme='light'] .site-title { + color: #1f2328 !important; +} + +:root[data-theme='light'] .site-title img { + filter: brightness(0); +} + +/* Announcement banner — Primer attention yellow */ +.sl-banner { + background-color: #FABF21 !important; + color: #1f2328 !important; +} + +.sl-banner a { + color: #1f2328 !important; + font-weight: 600; +} + +/* ============================================================ + 5. Hero section — Primer CTA Banner style (centered) + ============================================================ */ +.hero { + text-align: center; + display: flex !important; + flex-direction: column !important; + align-items: center !important; +} + +.hero > .sl-flex { + align-items: center !important; + width: 100%; + max-width: 720px; +} + +.hero h1 { + text-align: center !important; + margin: 0 auto; +} + +.hero .tagline { + text-align: center !important; + max-width: 640px; + margin-left: auto; + margin-right: auto; +} + +/* Center the action button group */ +.hero .sl-flex .sl-flex { + justify-content: center !important; + align-items: center !important; + gap: 0.75rem !important; + width: 100% !important; +} + +/* Primer ButtonBase shared styles (large size) */ +.hero .sl-link-button { + align-items: center !important; + appearance: none !important; + border: 0.0625rem solid !important; + border-radius: 0.375rem !important; + cursor: pointer !important; + display: inline-flex !important; + font-family: inherit !important; + font-size: 0.875rem !important; + font-weight: 500 !important; + gap: 0.5rem !important; + height: 2.5rem !important; + justify-content: center !important; + min-width: 10rem !important; + padding: 0 1rem !important; + text-align: center !important; + text-decoration: none !important; + transition: 80ms cubic-bezier(0.33, 1, 0.68, 1) !important; +} + +/* Primary CTA button — Primer primary variant */ +.hero .sl-link-button:first-of-type, +a.sl-link-button[data-variant='primary'], +.sl-link-button.primary { + background-color: #238636 !important; + border-color: rgba(240, 246, 252, 0.1) !important; + box-shadow: 0 1px 0 rgba(27, 31, 36, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.03) !important; + color: #ffffff !important; +} + +.hero .sl-link-button:first-of-type:hover, +a.sl-link-button[data-variant='primary']:hover, +.sl-link-button.primary:hover { + background-color: #2ea043 !important; +} + +/* Secondary CTA button — Primer default variant */ +.hero .sl-link-button:not(:first-of-type) { + background-color: #21262d !important; + border-color: #3d444d !important; + box-shadow: 0 1px 0 rgba(27, 31, 36, 0.04) !important; + color: #f0f6fc !important; +} + +.hero .sl-link-button:not(:first-of-type):hover { + background-color: #30363d !important; + border-color: #656d76 !important; +} + +:root[data-theme='light'] .hero .sl-link-button:first-of-type, +:root[data-theme='light'] a.sl-link-button[data-variant='primary'], +:root[data-theme='light'] .sl-link-button.primary { + background-color: #1f883d !important; + border-color: rgba(27, 31, 36, 0.15) !important; + box-shadow: 0 1px 0 rgba(27, 31, 36, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.03) !important; + color: #ffffff !important; +} + +:root[data-theme='light'] .hero .sl-link-button:first-of-type:hover, +:root[data-theme='light'] a.sl-link-button[data-variant='primary']:hover { + background-color: #1a7f37 !important; +} + +:root[data-theme='light'] .hero .sl-link-button:not(:first-of-type) { + background-color: #f6f8fa !important; + border-color: rgba(27, 31, 36, 0.15) !important; + box-shadow: 0 1px 0 rgba(27, 31, 36, 0.04) !important; + color: #1f2328 !important; +} + +:root[data-theme='light'] .hero .sl-link-button:not(:first-of-type):hover { + background-color: #eaeef2 !important; + border-color: rgba(27, 31, 36, 0.25) !important; +} + +:root[data-theme='light'] .hero h1 { + color: #1f2328; +} + +:root[data-theme='light'] .hero .tagline { + color: #656d76; +} + +/* ============================================================ + 6. Sidebar + ============================================================ */ +#starlight__sidebar, +.sidebar { + border-right: 1px solid #3d444d; +} + +:root[data-theme='light'] #starlight__sidebar, +:root[data-theme='light'] .sidebar { + border-right-color: #d1d9e0; +} + +@media (min-width: 769px) { + #starlight__sidebar, + .sidebar { + background-color: transparent !important; + } + .sidebar-content, + .sidebar-pane { + background: transparent !important; + } +} + +/* Remove default nesting indentation and gray vertical line on sub-items */ +.sidebar ul ul li { + margin-inline-start: 0 !important; + border-inline-start: none !important; + padding-inline-start: 0 !important; +} + +/* Sub-page links get extra internal left padding for indent */ +.sidebar ul ul li a { + padding-inline-start: 1.25rem !important; +} + +/* Uniform font size and weight — override Starlight .large class on top-level items */ +.sidebar .large { + font-size: 0.875rem !important; + font-weight: 400 !important; + color: inherit !important; +} + +/* All sidebar links — uniform styling, no weight changes */ +.sidebar .top-level > li > details > summary, +.sidebar .top-level > li > a, +.sidebar nav li li a { + font-weight: 400; + font-size: 0.875rem; + color: #e6edf3; + text-transform: none; + letter-spacing: normal; + background: none !important; + border-left: none !important; + border-radius: 8px !important; + transition: color 0.15s ease, background-color 0.15s ease; + line-height: 1.5; + padding-block: 0.4em !important; +} + +.sidebar .top-level > li > details > summary:hover, +.sidebar .top-level > li > a:hover, +.sidebar nav li li a:hover { + color: #e6edf3; + background: rgba(177, 186, 196, 0.08) !important; +} + +:root[data-theme='light'] .sidebar .top-level > li > details > summary, +:root[data-theme='light'] .sidebar .top-level > li > a, +:root[data-theme='light'] .sidebar nav li li a { + color: #1f2328; +} + +:root[data-theme='light'] .sidebar .top-level > li > details > summary:hover, +:root[data-theme='light'] .sidebar .top-level > li > a:hover, +:root[data-theme='light'] .sidebar nav li li a:hover { + color: #1f2328; + background: rgba(209, 217, 224, 0.3) !important; +} + +/* Active state — GitHub Docs: subtle gray bg + blue left indicator */ +/* No font-weight change, no margin shift — only background + blue line */ +.sidebar a[aria-current='page'], +.sidebar .top-level > li > a[aria-current='page'], +.sidebar-pane a[aria-current='page'] { + color: #e6edf3 !important; + background: rgba(177, 186, 196, 0.08) !important; + font-weight: 400 !important; + border-left: none !important; + border-radius: 8px !important; + position: relative; +} + +.sidebar a[aria-current='page']::before, +.sidebar .top-level > li > a[aria-current='page']::before, +.sidebar-pane a[aria-current='page']::before { + content: ''; + position: absolute; + left: calc(-1 * var(--sl-sidebar-pad-x, 1rem)); + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 60%; + background: #4493f8; + border-radius: 2px; +} + +.sidebar a[aria-current='page']:hover, +.sidebar .top-level > li > a[aria-current='page']:hover, +.sidebar a[aria-current='page']:focus, +.sidebar-pane a[aria-current='page']:hover { + background: rgba(177, 186, 196, 0.15) !important; +} + +/* Light mode active state */ +:root[data-theme='light'] .sidebar a[aria-current='page'], +:root[data-theme='light'] .sidebar .top-level > li > a[aria-current='page'], +:root[data-theme='light'] .sidebar-pane a[aria-current='page'] { + color: #1f2328 !important; + background: rgba(209, 217, 224, 0.4) !important; + font-weight: 400 !important; + border-left: none !important; + border-radius: 8px !important; + position: relative; +} + +:root[data-theme='light'] .sidebar a[aria-current='page']::before, +:root[data-theme='light'] .sidebar .top-level > li > a[aria-current='page']::before, +:root[data-theme='light'] .sidebar-pane a[aria-current='page']::before { + content: ''; + position: absolute; + left: calc(-1 * var(--sl-sidebar-pad-x, 1rem)); + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 60%; + background: #0969da; + border-radius: 2px; +} + +:root[data-theme='light'] .sidebar a[aria-current='page']:hover, +:root[data-theme='light'] .sidebar .top-level > li > a[aria-current='page']:hover, +:root[data-theme='light'] .sidebar a[aria-current='page']:focus, +:root[data-theme='light'] .sidebar-pane a[aria-current='page']:hover { + background: rgba(209, 217, 224, 0.5) !important; +} + +/* ============================================================ + 7. Content typography + ============================================================ */ +.sl-markdown-content { + color: #f0f6fc; + font-family: var(--sl-font) !important; + overflow-wrap: break-word; + word-wrap: break-word; +} + +:root[data-theme='light'] .sl-markdown-content { + color: #1f2328; +} + +.sl-markdown-content p { + line-height: 1.65; + color: #c9d1d9; +} + +.sl-markdown-content li { + color: #c9d1d9; +} + +:root[data-theme='light'] .sl-markdown-content p, +:root[data-theme='light'] .sl-markdown-content li, +:root[data-theme='light'] .sl-markdown-content td, +:root[data-theme='light'] .sl-markdown-content th { + color: #1f2328; +} + +/* Headings — medium weight like github.com (font-weight: 440–600) */ +.sl-markdown-content h1 { + font-size: 2rem; + font-weight: 600; + line-height: 1.25; + color: #f0f6fc; + border-bottom: none; +} + +.sl-markdown-content h2 { + font-size: 1.75rem; + font-weight: 600; + border-bottom: none; + color: #f0f6fc; +} + +.sl-markdown-content h3 { + font-size: 1.25rem; + font-weight: 600; + border-bottom: none; + color: #f0f6fc; +} + +.sl-markdown-content h4, +.sl-markdown-content h5, +.sl-markdown-content h6 { + font-size: 1rem; + font-weight: 600; + border-bottom: none; + color: #f0f6fc; +} + +:root[data-theme='light'] .sl-markdown-content h1, +:root[data-theme='light'] .sl-markdown-content h2, +:root[data-theme='light'] .sl-markdown-content h3, +:root[data-theme='light'] .sl-markdown-content h4, +:root[data-theme='light'] .sl-markdown-content h5, +:root[data-theme='light'] .sl-markdown-content h6 { + color: #1f2328; +} + + +/* Heading anchor links */ +.sl-markdown-content h1 a.header-anchor, +.sl-markdown-content h2 a.header-anchor, +.sl-markdown-content h3 a.header-anchor, +.sl-markdown-content h4 a.header-anchor, +.sl-markdown-content :is(h1, h2, h3, h4, h5, h6) a[aria-label^='Permalink'] { + font-size: 0.2em; + opacity: 0; + transition: opacity 0.2s ease; +} + +.sl-markdown-content :is(h1, h2, h3, h4, h5, h6):hover a[aria-label^='Permalink'] { + opacity: 0.2; +} + +/* Links — Primer accent blue */ +.sl-markdown-content a:not(.sl-link-button) { + color: #4493f8; + text-decoration: underline; + transition: color 0.15s ease; +} + +.sl-markdown-content a:hover { + color: #79c0ff; +} + +:root[data-theme='light'] .sl-markdown-content a:not(.sl-link-button) { + color: #0969da; +} + +:root[data-theme='light'] .sl-markdown-content a:hover { + color: #0550ae; +} + +/* Code blocks and tables — let Starlight/ExpressiveCode handle layout. + Colors flow through the --sl-color-* variables set above. */ + +/* Blockquotes — light color adjustments only */ +.sl-markdown-content blockquote { + color: #9198a1; +} + +:root[data-theme='light'] .sl-markdown-content blockquote { + color: #656d76; +} + +/* ============================================================ + 8. Feature cards — Primer Brand Card style + ============================================================ */ +.card { + border: 1px solid #3d444d !important; + border-radius: 24px !important; + background: #0f1511 !important; + padding: 2rem !important; + position: relative; + overflow: hidden; +} + +.card .card-title { + color: #ffffff !important; + font-size: 1.25rem !important; + font-weight: 500 !important; +} + +.card .card-body { + color: #a4aea6; + font-size: 1rem; + line-height: 1.5; +} + +.card .card-icon { + color: #3fb950; +} + +:root[data-theme='light'] .card { + background: #ffffff !important; + border: 1px solid #d1d9e0 !important; +} + +:root[data-theme='light'] .card .card-title { + color: #1f2328 !important; +} + +:root[data-theme='light'] .card .card-body { + color: #656d76; +} + +:root[data-theme='light'] .card .card-icon { + color: #1a7f37; +} + +/* ============================================================ + 9. Right sidebar / Table of Contents + ============================================================ */ +@media (min-width: 769px) { + .right-sidebar, + .right-sidebar[class*='astro-'] { + border-left: 1px solid #3d444d; + } + :root[data-theme='light'] .right-sidebar, + :root[data-theme='light'] .right-sidebar[class*='astro-'] { + border-left-color: #d1d9e0; + } +} + +starlight-toc nav { + font-size: 0.875rem; +} + +starlight-toc a { + color: #656d76; + text-decoration: none; + border-left: none; + line-height: 1.4; + font-weight: 400; + transition: color 0.15s ease; +} + +starlight-toc a:hover { + color: #ffffff; + background: none !important; +} + +starlight-toc a[aria-current='true'] { + color: #ffffff; + font-weight: 500; + background: none !important; +} + +starlight-toc h2 { + font-size: 0.6875rem; + font-weight: 600; + color: #656d76; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +:root[data-theme='light'] starlight-toc a { + color: #656d76; +} + +:root[data-theme='light'] starlight-toc a:hover { + color: #1f2328; + background: none !important; +} + +:root[data-theme='light'] starlight-toc a[aria-current='true'] { + color: #1f2328; + font-weight: 500; + background: none !important; +} + +:root[data-theme='light'] starlight-toc h2 { + color: #656d76; +} + +/* ============================================================ + 10. Pagination links + ============================================================ */ +.pagination-links { + border-top: none; +} + +/* Remove the horizontal line above the pagination area */ +.content-panel { + border-top: none !important; +} + +.pagination-links a { + border: 1px solid #3d444d; + border-radius: 6px; + background: #161b22; + transition: all 0.2s ease; + text-decoration: none; +} + +.pagination-links a:hover { + border-color: #3fb950; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(63, 185, 80, 0.08); +} + +.pagination-links a .meta { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9198a1; +} + +.pagination-links .link-title { + font-weight: 600; + font-size: 0.9375rem; + color: #f0f6fc; +} + +.pagination-links a:hover .link-title { + color: #3fb950; +} + +:root[data-theme='light'] .pagination-links { + border-top: none; +} + +:root[data-theme='light'] .pagination-links a { + border-color: #d1d9e0; + background: #ffffff; +} + +:root[data-theme='light'] .pagination-links a:hover { + border-color: #1a7f37; + box-shadow: 0 2px 8px rgba(26, 127, 55, 0.08); +} + +:root[data-theme='light'] .pagination-links .link-title { + color: #1f2328; +} + +:root[data-theme='light'] .pagination-links a:hover .link-title { + color: #1a7f37; +} + +/* ============================================================ + 11. Buttons + ============================================================ */ +.sl-markdown-content button, +.sl-markdown-content .button, +a.button { + background-color: #21262d; + border: 1px solid #3d444d; + color: #f0f6fc; + border-radius: 6px; + font-weight: 500; + transition: all 0.15s ease; +} + +.sl-markdown-content button:hover, +.sl-markdown-content .button:hover, +a.button:hover { + background-color: #30363d; + border-color: #656d76; +} + +/* ============================================================ + 12. Nav links + ============================================================ */ +nav a { + color: #c9d1d9; + transition: color 0.15s ease; +} + +nav a:hover { + color: #f0f6fc; +} + +nav a[aria-current='page'] { + color: #f0f6fc; + font-weight: 400; +} + +:root[data-theme='light'] nav a { + color: #656d76; +} + +:root[data-theme='light'] nav a:hover { + color: #1f2328; +} + +:root[data-theme='light'] nav a[aria-current='page'] { + color: #1f2328; +} + +/* ============================================================ + 13. Scrollbars + ============================================================ */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(61, 68, 77, 0.5); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(101, 109, 118, 0.6); +} + +* { + scrollbar-width: thin; + scrollbar-color: rgba(61, 68, 77, 0.5) transparent; +} + +:root[data-theme='light'] ::-webkit-scrollbar-track { + background: transparent; +} + +:root[data-theme='light'] ::-webkit-scrollbar-thumb { + background: rgba(209, 217, 224, 0.6); +} + +:root[data-theme='light'] ::-webkit-scrollbar-thumb:hover { + background: rgba(175, 184, 193, 0.8); +} + +/* ============================================================ + 14. Responsive header nav (custom component) + ============================================================ */ + +/* Always hide splash hamburger by default */ +.splash-nav-wrapper { + display: none !important; } -/* Show full nav by default, switch to hamburger on narrow viewports */ @media (max-width: 900px) { .custom-header-links { display: none !important; } - .tablet-nav-wrapper { + header div:has(> site-search) { + margin: 0 2.5rem 0 0; + } + header site-search button { + border: none !important; + font-size: 1rem !important; + color: var(--sl-color-gray-3) !important; + } + header site-search button span, + header site-search button kbd { + display: none !important; + } + header site-search button:hover { + color: var(--sl-color-text) !important; + } + header starlight-theme-select { + display: none !important; + } + + /* Show our custom hamburger only on splash (homepage), fixed to top-right */ + body:has(.hero) .splash-nav-wrapper { display: block !important; + position: fixed; + top: 16px; + right: 16px; + z-index: 10; + } + body:has(.hero) .splash-hamburger-btn { + margin: 0 !important; + } + body:has(.hero) header starlight-theme-select label svg.caret { + display: none; + } + + /* Keep theme toggle visible on homepage (splash), compact icon-only */ + body:has(.hero) header starlight-theme-select { + display: block !important; + margin-right: 1.25em; + } + body:has(.hero) header starlight-theme-select label { + --sl-select-width: 1em !important; + overflow: hidden; + } + body:has(.hero) header starlight-theme-select label svg.icon { + width: 1.25em; + height: 1.25em; + } + body:has(.hero) header starlight-theme-select select { + color: transparent; + width: 1.25rem; + } + /* On homepage, make room for theme toggle + hamburger to the right */ + body:has(.hero) header div:has(> site-search) { + margin-right: 0; } } -@media (min-width: 901px) { - .tablet-nav-wrapper { - display: none !important; +@media (max-width: 800px) { + body:has(.hero) .splash-nav-wrapper { + top: 12px; } } + +/* ============================================================ + Search dialog — collapse empty results area + ============================================================ */ +[data-pagefind-ui] .pagefind-ui__results-area:empty, +dialog .results-inner:empty, +dialog [data-search-results]:empty { + display: none !important; +} + +dialog.search-dialog .dialog-frame { + max-height: fit-content !important; +} From 5c1fe16868c5a6e55ab008b111b1dcfab0967b2b Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Apr 2026 15:17:23 -0400 Subject: [PATCH 03/13] docs site content updates (#28) * additional private preview callouts * squash merge clarification * home page updates * merge process clarification --- docs/src/content/docs/faq.md | 36 ++++++++++++++++++- .../docs/getting-started/quick-start.md | 4 +++ docs/src/content/docs/guides/stacked-prs.md | 10 +++--- docs/src/content/docs/index.mdx | 12 ++++--- .../src/content/docs/introduction/overview.md | 3 ++ docs/src/styles/custom.css | 18 ++++++++++ 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index aaa4206e..38d7381d 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -100,7 +100,41 @@ You cannot merge a PR in the middle of the stack before the PRs below it are mer ### How does squash merge work? -Squash merges are fully supported. Each PR in the stack produces one clean, squashed commit when merged. The rebase engine automatically detects squash-merged PRs and replays commits from the remaining branches onto the squashed result. +Squash merges are fully supported. Each PR in the stack produces one clean, squashed commit when merged. Merging `n` PRs will create `n` squashed commits on the base. + +When a PR is squash-merged, the original commits disappear from the history, which can cause artificial merge conflicts during rebasing. Both the CLI and the server handle this using `git rebase --onto`: + +``` +git rebase --onto +``` + +**Example:** Consider a stack with three PRs: + +``` +PR1: main ← A, B (branch1) +PR2: main ← A, B, C, D (branch2) +PR3: main ← A, B, C, D, E, F (branch3) +``` + +When PR1 and PR2 are squash-merged, `main` now looks like: + +``` +S1 (squash of A+B), S2 (squash of C+D) +``` + +Then the following rebase is run: + +``` +git rebase --onto S2 D branch3 +``` + +Which rewrites `branch3` to: + +``` +S1, S2, E, F +``` + +This moves the unique commits from the unmerged branch and replays them on top of the newly squashed commits on the base branch, avoiding any merge conflicts. ### How does merge commit work? diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index 9b2e50f3..b9c4b52a 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -9,6 +9,10 @@ description: Install the gh stack CLI and create your first Stacked PR in minute - Git 2.20 or later - A GitHub repository you can push to +:::caution[Private Preview] +Stacked PRs is currently in private preview. This feature will **not work** unless enabled for your repository. [Sign up for the waitlist →](https://gh.io/stacksbeta) +::: + ## Install the CLI Extension ```sh diff --git a/docs/src/content/docs/guides/stacked-prs.md b/docs/src/content/docs/guides/stacked-prs.md index 5ff2bb73..5be37ce8 100644 --- a/docs/src/content/docs/guides/stacked-prs.md +++ b/docs/src/content/docs/guides/stacked-prs.md @@ -21,13 +21,13 @@ Each PR in a stack shows only the diff for its layer — the changes between its - **Review individual PRs** when you're focusing on a specific concern (e.g., reviewing only the API layer). - **Use the stack map** to navigate between PRs without going back to the PR list. -## Merging Step by Step +## Merging from the Bottom Up -Stacks are merged **from the bottom up**. You cannot merge a PR in the middle of the stack before the PRs below it are merged. +Stacks are merged **from the bottom up** — you can merge any number of PRs at once, as long as they form a contiguous group starting from the lowest unmerged PR. For example, in a stack of four PRs, you can merge just the bottom one, or the bottom three together, but you cannot merge only the second and third PRs while leaving the first unmerged. Mid-stack merges are not allowed. -1. When the bottom PR meets all merge requirements, merge it. -2. After the bottom PR is merged, the remaining stack is **automatically rebased** — the next PR's base is updated to target `main` directly. -3. The next PR is now at the bottom and can be reviewed, approved, and merged. +1. When the lowest unmerged PR (and any PRs above it that you want to include) meet all merge requirements, merge them. +2. After the merge, the remaining stack is **automatically rebased** — the next unmerged PR's base is updated to target `main` directly. +3. The next unmerged PR is now at the bottom and can be reviewed, approved, and merged. 4. Repeat until the entire stack is landed. For details on merge methods (squash, merge commit, rebase) and merge requirements, see [Merging Stacks](/gh-stack/introduction/overview/#merging-stacks) in the Overview. diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 1a05930b..fdcb4144 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -15,7 +15,7 @@ hero: icon: right-arrow --- -import { Card, CardGrid } from '@astrojs/starlight/components'; +import { Card, CardGrid, Aside } from '@astrojs/starlight/components'; import { Image } from 'astro:assets'; import stackDiagram from '../../assets/stack-diagram.svg'; import stackNavigator from '../../assets/screenshots/stack-navigator.png'; @@ -53,14 +53,18 @@ GitHub understands stacks end-to-end: the pull request UI shows a **stack map** The stack navigator in a pull request header -## How It Works +## Working with Stacks -The `gh stack` CLI handles the local workflow: creating branches, managing rebases, pushing to GitHub, and creating PRs with the correct base branches. On GitHub, the PR UI gives reviewers the context they need — a stack map for navigation, focused diffs for each layer, and proper rules enforcement. +**While the `gh stack` CLI makes the local workflow seamless, it is entirely optional.** You can create and manage Stacked PRs directly via the GitHub UI, the API, or your standard Git workflow. If you choose to use the CLI, it handles creating branches, managing rebases, pushing to GitHub, and creating PRs with the correct base branches. On GitHub, the PR UI gives reviewers the context they need — a stack map for navigation, focused diffs for each layer, and proper rules enforcement. -When you're ready to merge, you can merge all or a part of the stack. Each PR can be merged directly or through the merge queue. After a merge, the remaining PRs in the stack are automatically rebased so the lowest unmerged PR targets the base branch. +When you're ready to merge, you can merge all or a part of the stack. Each PR can be merged directly or through the merge queue. **If you want to merge multiple PRs at once (e.g., the bottom two PRs in a stack), simply wait for CI to pass on those specific layers, and you can merge them in a single step.** After a merge, the remaining PRs in the stack are automatically rebased so the lowest unmerged PR targets the updated base branch. ## Get Started + + ```sh # Install the CLI extension gh extension install github/gh-stack diff --git a/docs/src/content/docs/introduction/overview.md b/docs/src/content/docs/introduction/overview.md index 947dd073..8d4b0f63 100644 --- a/docs/src/content/docs/introduction/overview.md +++ b/docs/src/content/docs/introduction/overview.md @@ -77,6 +77,7 @@ Rebasing is the trickiest part of working with Stacked PRs, and GitHub handles i - **In the PR UI** — A rebase button lets you trigger a cascading rebase across all branches in the stack. - **From the CLI** — `gh stack rebase` performs the same cascading rebase locally. - **After partial merges** — When you merge a PR at the bottom of the stack, the remaining branches are automatically rebased so the next PR targets `main` and is ready for review and merge. +- **Safe squash-merge handling** — Squash merges are fully supported. The rebase engine safely replays your unique commits on top of the squashed base, avoiding artificial merge conflicts. See the [FAQ](/gh-stack/faq/#how-does-squash-merge-work) for a detailed description of how this works. ## The CLI: `gh stack` @@ -93,6 +94,8 @@ While the PR UI provides the review and merge experience, the `gh stack` CLI han The CLI is not required to use Stacked PRs — the underlying git operations are standard. But it makes the workflow simpler, and you can create Stacked PRs from the CLI instead of the UI. +**Bring Your Own Tools:** You don't need to use the `gh stack` CLI for your local workflow. If you use tools like Jujutsu, Sapling, or custom tools to manage and push your local branches, you can then use the CLI or the GitHub UI to open a stack of PRs from those branches. See the [FAQ](/gh-stack/faq/#will-this-work-with-a-different-tool-for-stacking) for examples. + ## Thinking About Stack Structure Each branch in a stack should represent a **discrete, logical unit of work** that can be reviewed independently. Think of a stack from the reviewer's perspective: the PRs should tell a cohesive story, where each one is a small, logical piece of the whole. diff --git a/docs/src/styles/custom.css b/docs/src/styles/custom.css index c1b86e94..3795cd8d 100644 --- a/docs/src/styles/custom.css +++ b/docs/src/styles/custom.css @@ -248,6 +248,16 @@ header starlight-theme-select { margin-right: auto; } +.hero .tagline code { + font-size: 0.9em; + letter-spacing: -0.02em; + word-spacing: -0.1em; + padding: 0.15em 0.4em; + border-radius: 6px; + background: rgba(110, 118, 129, 0.15); + font-weight: 500; +} + /* Center the action button group */ .hero .sl-flex .sl-flex { justify-content: center !important; @@ -596,6 +606,14 @@ a.sl-link-button[data-variant='primary']:hover, /* Code blocks and tables — let Starlight/ExpressiveCode handle layout. Colors flow through the --sl-color-* variables set above. */ +/* Light mode copy button: make icon visible on light code blocks */ +:root[data-theme='light'] .expressive-code .copy button { + background-color: rgba(175, 184, 193, 0.3) !important; +} +:root[data-theme='light'] .expressive-code .copy button::after { + background-color: #1f2328 !important; +} + /* Blockquotes — light color adjustments only */ .sl-markdown-content blockquote { color: #9198a1; From 72b4dc563cc7445d26e401249008957abdfb1f1f Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:02 -0400 Subject: [PATCH 04/13] fix for inflated diff counts when base branch has been updated since stack init (#39) * fix for inflated diff counts when base branch has been updated since stack init --- internal/tui/stackview/data.go | 18 +++-- internal/tui/stackview/data_test.go | 102 ++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 10 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 64f81914..ffd21e88 100644 --- a/internal/tui/stackview/data.go +++ b/internal/tui/stackview/data.go @@ -13,8 +13,8 @@ type BranchNode struct { IsCurrent bool IsLinear bool // whether history is linear with base branch BaseBranch string - Commits []git.CommitInfo // commits unique to this branch (base..head) - FilesChanged []git.FileDiffStat // per-file diff stats + Commits []git.CommitInfo // commits unique to this branch (base..head) + FilesChanged []git.FileDiffStat // per-file diff stats PR *ghapi.PRDetails Additions int Deletions int @@ -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 00000000..f0ff1e2e --- /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 766d0e2ab9fe531f61dcb314b00913e4927664bc Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:02 -0400 Subject: [PATCH 05/13] fast forward active branches where local is behind remote (#40) * 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 26059cc5..0e4ee484 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 1b70db08..f72eef74 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 019059ab..c7035a6f 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 a842ae46..67040400 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 f6cf0cc9..f167201f 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 b038eedcb4b806805475c881f1db7363c40a3daa Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:03 -0400 Subject: [PATCH 06/13] fix for rev-parse error when rebasing over deleted branches (#41) * 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 0e4ee484..0d0ef419 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 f72eef74..47255cf3 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 22c8ef460b48d9795f7fc80cefdd03b6dfa57a26 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:03 -0400 Subject: [PATCH 07/13] fix for rev-parse error during sync with deleted branches (#42) * fix for rev-parse error during sync * clearer info msg with rebasing over merged PRs --- README.md | 2 +- cmd/rebase.go | 8 +-- cmd/rebase_test.go | 8 +-- cmd/sync.go | 16 +++-- cmd/sync_test.go | 98 +++++++++++++++++++++++--- docs/src/content/docs/reference/cli.md | 2 +- internal/git/git.go | 2 +- skills/gh-stack/SKILL.md | 4 +- 8 files changed, 111 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f2e6463e..baa33d04 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 0d0ef419..caa3c329 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 47255cf3..cd77a903 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 c7035a6f..a405817f 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) @@ -191,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 67040400..78a58b0d 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{ @@ -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) } diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 5853d9f2..75d845fe 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 8af505d6..b7b99414 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 1dc3a517..6d58198f 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 f706944b632dafcd29607be50bda2200540e2515 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:04 -0400 Subject: [PATCH 08/13] fix --onto rebase for merged branches (#43) * backfill ontoOldBase for deleted merged branches * ensure upstack rebase checks immediate predecessor * fix for stale ontoOldBase causing rebase conflicts --- cmd/rebase.go | 37 ++++++++++- cmd/rebase_test.go | 150 ++++++++++++++++++++++++++++++++++++++++++++- cmd/sync.go | 27 +++++++- cmd/sync_test.go | 108 ++++++++++++++++++++++++++++++-- 4 files changed, 313 insertions(+), 9 deletions(-) diff --git a/cmd/rebase.go b/cmd/rebase.go index caa3c329..a97199d1 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -188,10 +188,34 @@ 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 + // 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 @@ -221,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 cd77a903..6cca55b1 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) { @@ -495,6 +574,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) { @@ -1044,11 +1184,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 +1236,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 a405817f..2a5471cb 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 @@ -181,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 78a58b0d..e3dc5328 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. @@ -848,7 +940,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"}, }, } @@ -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 } @@ -915,7 +1012,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 8aba9802c8bd089284138765ea32724948e5b225 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:04 -0400 Subject: [PATCH 09/13] minor docs styling & content updates (#48) * udpate meta tags for docs site * stack diagram styling fixes * update favicon * recommend gh skill to install skills file --- README.md | 2 +- docs/astro.config.mjs | 12 +++- docs/public/favicon.svg | 5 +- docs/public/github-social-card.jpg | Bin 0 -> 19326 bytes docs/src/assets/stack-diagram.svg | 48 -------------- docs/src/components/StackDiagram.astro | 62 ++++++++++++++++++ .../docs/getting-started/quick-start.md | 6 ++ docs/src/content/docs/guides/workflows.md | 6 ++ docs/src/content/docs/index.mdx | 8 +-- 9 files changed, 90 insertions(+), 59 deletions(-) create mode 100644 docs/public/github-social-card.jpg delete mode 100644 docs/src/assets/stack-diagram.svg create mode 100644 docs/src/components/StackDiagram.astro diff --git a/README.md b/README.md index baa33d04..1834eeec 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Requires the [GitHub CLI](https://cli.github.com/) (`gh`) v2.0+. Install the gh-stack skill so your AI coding agent knows how to work with stacked PRs and the `gh stack` CLI: ```sh -npx skills add github/gh-stack +gh skill install github/gh-stack ``` ## Quick start diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index f585e2a6..28498ef4 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/favicon.svg b/docs/public/favicon.svg index 8f0bb343..848cf2c0 100644 --- a/docs/public/favicon.svg +++ b/docs/public/favicon.svg @@ -1,5 +1,4 @@ - + - + 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 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 QhJ9*~d`p)uT)A`q+1dF=dH?_b literal 0 HcmV?d00001 diff --git a/docs/src/assets/stack-diagram.svg b/docs/src/assets/stack-diagram.svg deleted file mode 100644 index b0296ece..00000000 --- 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 00000000..583267b9 --- /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/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index b9c4b52a..4c942d47 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -23,6 +23,12 @@ gh extension install github/gh-stack If you use AI coding agents (like GitHub Copilot), install the gh-stack skill so they know how to work with Stacked PRs: +```sh +gh skill install github/gh-stack +``` + +Or if you prefer to use `npx skills`: + ```sh npx skills add github/gh-stack ``` diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 9afa06a5..1bb3a45c 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -191,6 +191,12 @@ The `unstack` command deletes the stack on GitHub first, then removes local trac AI coding agents (like GitHub Copilot) can create and manage Stacked PRs on your behalf. Install the gh-stack skill to give them the context they need: +```sh +gh skill install github/gh-stack +``` + +Or if you prefer to use `npx skills`: + ```sh npx skills add github/gh-stack ``` diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index fdcb4144..285b784e 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'; @@ -31,7 +31,7 @@ import stackNavigator from '../../assets/screenshots/stack-navigator.png'; The `gh stack` CLI makes it easy to create stacks, perform cascading rebases, push branches and create PRs, and navigate between layers — all from your terminal. - Run `npx skills add github/gh-stack` to teach your AI coding agents how to work with stacks. Break up a large diff into a stack or develop with stacks from the start. + Run `gh skill install github/gh-stack` to teach your AI coding agents how to work with stacks. Break up a large diff into a stack or develop with stacks from the start. @@ -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 8893d274f2a62f47e43b947ca16fd44deb734f3d Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:05 -0400 Subject: [PATCH 10/13] preflight check for stacked PR availability in submit (#44) * preflight check for stacked prs before submit * close pipe read end in test to avoid FD leak * concise var reuse --- cmd/submit.go | 30 +++- cmd/submit_test.go | 227 +++++++++++++++++++++++++ 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, 272 insertions(+), 9 deletions(-) diff --git a/cmd/submit.go b/cmd/submit.go index 483015af..987eddbf 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 !stacksAvailable { + 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 514d091a..570baf95 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,229 @@ 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() + defer inR.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 f167201f..9a09e481 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 75d845fe..2a90504f 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 f4ea5c77..f254e568 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 6d58198f..ad928a4d 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 From 42e43a47ae7e1a26e690bcaa6869b2d41c18a3af Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:05 -0400 Subject: [PATCH 11/13] ignore stale merged/closed PRs for reused branch names (#49) * ignore stale merged/closed PRs for reused branch names * guard for edge case of deleted PR --- cmd/merge_test.go | 46 ++++ cmd/push_test.go | 12 +- cmd/submit_test.go | 27 ++- cmd/utils.go | 108 ++++++++- cmd/utils_test.go | 354 ++++++++++++++++++++++++++++ cmd/view_test.go | 26 +- internal/github/github.go | 3 + internal/tui/stackview/data.go | 10 +- internal/tui/stackview/data_test.go | 126 ++++++++++ 9 files changed, 689 insertions(+), 23 deletions(-) diff --git a/cmd/merge_test.go b/cmd/merge_test.go index bb59b946..3a5e2824 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -6,6 +6,7 @@ import ( "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" "github.com/stretchr/testify/assert" ) @@ -32,6 +33,7 @@ func TestMerge_NoPullRequest(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} cmd := MergeCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) @@ -65,6 +67,7 @@ func TestMerge_AlreadyMerged(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} cmd := MergeCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) @@ -104,6 +107,7 @@ func TestMerge_FullyMergedStack(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} cmd := MergeCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) @@ -136,6 +140,7 @@ func TestMerge_OnTrunk(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} cmd := MergeCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) @@ -168,6 +173,19 @@ func TestMerge_NonInteractive_PrintsURL(t *testing.T) { // NewTestConfig is non-interactive (piped output), so no confirm prompt. cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + if number == 42 { + return &github.PullRequest{ + Number: 42, + ID: "PR_42", + URL: "https://github.com/owner/repo/pull/42", + State: "OPEN", + }, nil + } + return nil, nil + }, + } cmd := MergeCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) @@ -196,6 +214,7 @@ func TestMerge_NoArgs(t *testing.T) { defer restore() cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} cmd := MergeCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) @@ -229,6 +248,17 @@ func TestMerge_ByPRNumber(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + switch number { + case 42: + return &github.PullRequest{Number: 42, URL: "https://github.com/owner/repo/pull/42", State: "OPEN"}, nil + case 43: + return &github.PullRequest{Number: 43, URL: "https://github.com/owner/repo/pull/43", State: "OPEN"}, nil + } + return nil, nil + }, + } cmd := MergeCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) @@ -261,6 +291,14 @@ func TestMerge_ByPRURL(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + if number == 42 { + return &github.PullRequest{Number: 42, URL: "https://github.com/owner/repo/pull/42", State: "OPEN"}, nil + } + return nil, nil + }, + } cmd := MergeCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) @@ -293,6 +331,14 @@ func TestMerge_ByBranchName(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + if number == 42 { + return &github.PullRequest{Number: 42, URL: "https://github.com/owner/repo/pull/42", State: "OPEN"}, nil + } + return nil, nil + }, + } cmd := MergeCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) diff --git a/cmd/push_test.go b/cmd/push_test.go index 371b857a..ff70e571 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -84,7 +84,17 @@ func TestPush_NoSubmitHintWhenPRsExist(t *testing.T) { defer restore() cfg, _, errR := config.NewTestConfig() - cfg.GitHubClientOverride = &github.MockClient{} + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + switch number { + case 10: + return &github.PullRequest{Number: 10, State: "OPEN", HeadRefName: "b1"}, nil + case 11: + return &github.PullRequest{Number: 11, State: "OPEN", HeadRefName: "b2"}, nil + } + return nil, nil + }, + } cmd := PushCmd(cfg) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) diff --git a/cmd/submit_test.go b/cmd/submit_test.go index 570baf95..07d67d42 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -196,7 +196,12 @@ func TestSubmit_SkipsMergedBranches(t *testing.T) { cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { - return &github.PullRequest{Number: 2, URL: "https://github.com/owner/repo/pull/2"}, nil + // Only return an OPEN PR for the active branch (b2). + // Merged branches (b1, b3) should have no open PR. + if branch == "b2" { + return &github.PullRequest{Number: 2, URL: "https://github.com/owner/repo/pull/2", State: "OPEN"}, nil + } + return nil, nil }, } cmd := SubmitCmd(cfg) @@ -1026,7 +1031,7 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) { tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) - listStacksCalled := false + listStacksCallCount := 0 mock := newSubmitMock(tmpDir, "b1") mock.PushFn = func(string, []string, bool, bool) error { return nil } restore := git.SetOps(mock) @@ -1035,8 +1040,17 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) { cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ ListStacksFn: func() ([]github.RemoteStack, error) { - listStacksCalled = true - return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + listStacksCallCount++ + return []github.RemoteStack{{ID: 42, PullRequests: []int{10, 11}}}, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + switch number { + case 10: + return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10", HeadRefName: "b1", State: "OPEN"}, nil + case 11: + return &github.PullRequest{Number: 11, URL: "https://github.com/o/r/pull/11", HeadRefName: "b2", State: "OPEN"}, nil + } + return nil, nil }, FindPRForBranchFn: func(string) (*github.PullRequest, error) { return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10"}, nil @@ -1054,5 +1068,8 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) { _, _ = io.ReadAll(errR) assert.NoError(t, err) - assert.False(t, listStacksCalled, "ListStacks should not be called when stack ID already exists") + // ListStacks is called by syncStackPRs (remote sync), but NOT by the + // preflight check. Two syncStackPRs calls happen in submit (before and + // after PR creation), so expect exactly 2 ListStacks calls. + assert.Equal(t, 2, listStacksCallCount, "ListStacks should only be called by syncStackPRs, not by the preflight check") } diff --git a/cmd/utils.go b/cmd/utils.go index 9a09e481..dc107ea5 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -11,6 +11,7 @@ import ( "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" ) @@ -231,8 +232,18 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac } // syncStackPRs discovers and updates pull request metadata for branches in a stack. -// For each branch, it queries GitHub for the most recent PR and updates the -// PullRequestRef including merge status. Branches with already-merged PRs are skipped. +// +// When the stack has a remote ID, the stack API is the source of truth: the +// authoritative PR list is fetched from the server and matched to local +// branches by head branch name. PRs remain associated even if closed. +// +// When no remote stack exists, branch-name-based discovery is used: +// +// 1. No tracked PR — look for an OPEN PR by head branch name. +// 2. Tracked PR (not merged) — refresh status by number; if closed, +// clear the association and fall through to path 1. +// 3. Tracked PR (merged) — skip; the merged state is final. +// // The transient Queued flag is also populated from the API response. func syncStackPRs(cfg *config.Config, s *stack.Stack) { client, err := cfg.GitHubClient() @@ -240,6 +251,14 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) { return } + // When the stack has a remote ID, the stack API is the source of truth. + if s.ID != "" { + if syncStackPRsFromRemote(client, s) { + return + } + } + + // No remote stack (or remote sync failed) — local discovery. for i := range s.Branches { b := &s.Branches[i] @@ -247,11 +266,92 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) { continue } - pr, err := client.FindAnyPRForBranch(b.Branch) + if b.PullRequest != nil && b.PullRequest.Number != 0 { + // Tracked PR — refresh its state. + pr, err := client.FindPRByNumber(b.PullRequest.Number) + if err != nil { + continue // API error — keep existing tracked PR + } + if pr == nil { + // PR not found — clear stale ref and fall through + // to the open-PR lookup below. + b.PullRequest = nil + b.Queued = false + } else { + b.PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + Merged: pr.Merged, + } + b.Queued = pr.IsQueued() + + // If the PR was closed (not merged), remove the association + // so we fall through to the open-PR lookup below. + if pr.State == "CLOSED" { + b.PullRequest = nil + b.Queued = false + } else { + continue + } + } + } + + // No tracked PR (or just cleared) — only adopt OPEN PRs to avoid + // picking up stale merged/closed PRs from a previous use of this + // branch name. + pr, err := client.FindPRForBranch(b.Branch) + if err != nil || pr == nil { + continue + } + b.PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + } + b.Queued = pr.IsQueued() + } +} + +// syncStackPRsFromRemote uses the stack API to sync PR state. The remote +// stack's PR list is the source of truth — PRs stay associated even if +// closed. Returns true if the sync succeeded, false if we should fall +// back to local discovery (e.g. stack not found remotely, API error). +func syncStackPRsFromRemote(client github.ClientOps, s *stack.Stack) bool { + stacks, err := client.ListStacks() + if err != nil { + return false + } + + // Find our stack in the remote list. + var remotePRNumbers []int + for _, rs := range stacks { + if strconv.Itoa(rs.ID) == s.ID { + remotePRNumbers = rs.PullRequests + break + } + } + if remotePRNumbers == nil { + return false + } + + // Fetch each remote PR's details and index by head branch name. + prByBranch := make(map[string]*github.PullRequest, len(remotePRNumbers)) + for _, num := range remotePRNumbers { + pr, err := client.FindPRByNumber(num) if err != nil || pr == nil { continue } + prByBranch[pr.HeadRefName] = pr + } + // Match remote PRs to local branches. + for i := range s.Branches { + b := &s.Branches[i] + pr, ok := prByBranch[b.Branch] + if !ok { + continue + } b.PullRequest = &stack.PullRequestRef{ Number: pr.Number, ID: pr.ID, @@ -260,6 +360,8 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) { } b.Queued = pr.IsQueued() } + + return true } // updateBaseSHAs refreshes the Base and Head SHAs for all active branches diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 80901326..2f9c7b48 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -9,8 +9,10 @@ import ( "github.com/AlecAivazis/survey/v2/terminal" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsInterruptError_DirectMatch(t *testing.T) { @@ -244,6 +246,358 @@ func TestResolvePR_URLPrecedesNumber(t *testing.T) { assert.Equal(t, 99, br.PullRequest.Number) } +func TestSyncStackPRs_NoTrackedPR_OnlyAdoptsOpenPRs(t *testing.T) { + // A branch with no tracked PR should only adopt OPEN PRs, + // not stale merged/closed PRs from a previous branch name usage. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "reused-branch"}, // no PullRequest + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + // FindPRForBranch (OPEN only) returns nil — no open PR. + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return nil, nil + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + // Branch should still have no PR tracked. + assert.Nil(t, s.Branches[0].PullRequest) +} + +func TestSyncStackPRs_NoTrackedPR_AdoptsOpenPR(t *testing.T) { + // A branch with no tracked PR should adopt an OPEN PR it discovers. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feature"}, // no PullRequest + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: 99, + ID: "PR_99", + URL: "https://github.com/o/r/pull/99", + State: "OPEN", + }, nil + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + require.NotNil(t, s.Branches[0].PullRequest) + assert.Equal(t, 99, s.Branches[0].PullRequest.Number) + assert.False(t, s.Branches[0].PullRequest.Merged) +} + +func TestSyncStackPRs_TrackedPR_DetectsMerge(t *testing.T) { + // A branch with a tracked PR should detect when that PR gets merged. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + { + Branch: "feature", + PullRequest: &stack.PullRequestRef{ + Number: 42, + ID: "PR_42", + URL: "https://github.com/o/r/pull/42", + }, + }, + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: 42, + ID: "PR_42", + URL: "https://github.com/o/r/pull/42", + State: "MERGED", + Merged: true, + }, nil + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + require.NotNil(t, s.Branches[0].PullRequest) + assert.Equal(t, 42, s.Branches[0].PullRequest.Number) + assert.True(t, s.Branches[0].PullRequest.Merged) +} + +func TestSyncStackPRs_MergedBranch_StaysMerged(t *testing.T) { + // A merged branch should stay merged — no API calls, no changes. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + { + Branch: "merged-branch", + PullRequest: &stack.PullRequestRef{ + Number: 20, + ID: "PR_20", + URL: "https://github.com/o/r/pull/20", + Merged: true, + }, + }, + }, + } + + apiCalled := false + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + apiCalled = true + return nil, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + apiCalled = true + return nil, nil + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + require.NotNil(t, s.Branches[0].PullRequest) + assert.Equal(t, 20, s.Branches[0].PullRequest.Number) + assert.True(t, s.Branches[0].PullRequest.Merged) + assert.False(t, apiCalled, "no API calls should be made for merged branches") +} + +func TestSyncStackPRs_ClosedPR_ReplacedByOpenPR(t *testing.T) { + // A tracked PR that was closed (not merged) should be replaced + // by a new OPEN PR if one exists. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + { + Branch: "feature", + PullRequest: &stack.PullRequestRef{ + Number: 10, + ID: "PR_10", + URL: "https://github.com/o/r/pull/10", + }, + }, + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: 10, + State: "CLOSED", + Merged: false, + }, nil + }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: 15, + ID: "PR_15", + URL: "https://github.com/o/r/pull/15", + State: "OPEN", + }, nil + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + require.NotNil(t, s.Branches[0].PullRequest) + assert.Equal(t, 15, s.Branches[0].PullRequest.Number) + assert.False(t, s.Branches[0].PullRequest.Merged) +} + +func TestSyncStackPRs_TrackedOpenPR_UpdatesQueued(t *testing.T) { + // A tracked OPEN PR that enters a merge queue should have Queued set. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + { + Branch: "feature", + PullRequest: &stack.PullRequestRef{ + Number: 42, + ID: "PR_42", + URL: "https://github.com/o/r/pull/42", + }, + }, + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: 42, + State: "OPEN", + MergeQueueEntry: &github.MergeQueueEntry{ + ID: "MQ_1", + }, + }, nil + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + assert.True(t, s.Branches[0].Queued) +} + +func TestSyncStackPRs_ClosedPR_NoReplacement_ClearsPR(t *testing.T) { + // A tracked PR that was closed with no replacement OPEN PR should + // have its PR ref cleared so it doesn't appear as an active PR. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + { + Branch: "feature", + PullRequest: &stack.PullRequestRef{ + Number: 10, + ID: "PR_10", + URL: "https://github.com/o/r/pull/10", + }, + Queued: true, + }, + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: 10, + State: "CLOSED", + Merged: false, + }, nil + }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return nil, nil // no open replacement + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + assert.Nil(t, s.Branches[0].PullRequest) + assert.False(t, s.Branches[0].Queued) +} + +func TestSyncStackPRs_RemoteStack_UsesStackAPI(t *testing.T) { + // When the stack has a remote ID, sync should use the stack API + // as source of truth, matching PRs to branches by head ref name. + s := &stack.Stack{ + ID: "100", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 100, PullRequests: []int{10, 11}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + switch number { + case 10: + return &github.PullRequest{Number: 10, ID: "PR_10", URL: "https://github.com/o/r/pull/10", HeadRefName: "b1", State: "OPEN"}, nil + case 11: + return &github.PullRequest{Number: 11, ID: "PR_11", URL: "https://github.com/o/r/pull/11", HeadRefName: "b2", State: "MERGED", Merged: true}, nil + } + return nil, nil + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + // b1 should be tracked with open PR + require.NotNil(t, s.Branches[0].PullRequest) + assert.Equal(t, 10, s.Branches[0].PullRequest.Number) + assert.False(t, s.Branches[0].PullRequest.Merged) + + // b2 should be tracked with merged PR (stack API keeps closed/merged PRs) + require.NotNil(t, s.Branches[1].PullRequest) + assert.Equal(t, 11, s.Branches[1].PullRequest.Number) + assert.True(t, s.Branches[1].PullRequest.Merged) +} + +func TestSyncStackPRs_RemoteStack_ClosedPRStaysAssociated(t *testing.T) { + // When using the stack API, a closed (not merged) PR should remain + // associated — the stack API is the source of truth, not PR state. + s := &stack.Stack{ + ID: "200", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feature", PullRequest: &stack.PullRequestRef{Number: 5}}, + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 200, PullRequests: []int{5}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + return &github.PullRequest{Number: 5, ID: "PR_5", URL: "https://github.com/o/r/pull/5", HeadRefName: "feature", State: "CLOSED"}, nil + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + // PR should still be associated (not cleared), because the stack API says it's part of the stack. + require.NotNil(t, s.Branches[0].PullRequest) + assert.Equal(t, 5, s.Branches[0].PullRequest.Number) + assert.False(t, s.Branches[0].PullRequest.Merged) +} + +func TestSyncStackPRs_RemoteStack_FallsBackOnAPIError(t *testing.T) { + // If the stack API fails, fall back to local discovery. + s := &stack.Stack{ + ID: "300", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feature"}, + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return nil, fmt.Errorf("API error") + }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return &github.PullRequest{Number: 77, ID: "PR_77", URL: "https://github.com/o/r/pull/77", State: "OPEN"}, nil + }, + } + + syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + // Should have fallen back to local discovery and found the open PR. + require.NotNil(t, s.Branches[0].PullRequest) + assert.Equal(t, 77, s.Branches[0].PullRequest.Number) +} + func TestParsePRURL(t *testing.T) { tests := []struct { name string diff --git a/cmd/view_test.go b/cmd/view_test.go index 8d1c5078..44bdc5e4 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -311,18 +311,19 @@ func TestViewShort_QueuedStack(t *testing.T) { // Mock GitHub client to return b1 as queued (MergeQueueEntry set) cfg, outR, _ := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - FindAnyPRForBranchFn: func(branch string) (*github.PullRequest, error) { - switch branch { - case "b1": + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + switch number { + case 1: return &github.PullRequest{ Number: 1, ID: "PR_1", + State: "OPEN", MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_1"}, }, nil - case "b2": - return &github.PullRequest{Number: 2, ID: "PR_2"}, nil - case "b3": - return &github.PullRequest{Number: 3, ID: "PR_3"}, nil + case 2: + return &github.PullRequest{Number: 2, ID: "PR_2", State: "OPEN"}, nil + case 3: + return &github.PullRequest{Number: 3, ID: "PR_3", State: "OPEN"}, nil } return nil, nil }, @@ -372,16 +373,17 @@ func TestViewShort_MixedQueuedAndMerged(t *testing.T) { // b1 is merged (persisted), b2 is queued (from API) cfg, outR, _ := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - FindAnyPRForBranchFn: func(branch string) (*github.PullRequest, error) { - switch branch { - case "b2": + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + switch number { + case 2: return &github.PullRequest{ Number: 2, ID: "PR_2", + State: "OPEN", MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_2"}, }, nil - case "b3": - return &github.PullRequest{Number: 3, ID: "PR_3"}, nil + case 3: + return &github.PullRequest{Number: 3, ID: "PR_3", State: "OPEN"}, nil } return nil, nil }, diff --git a/internal/github/github.go b/internal/github/github.go index 4af97d18..cea2c8c7 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -347,6 +347,9 @@ func (c *Client) FindPRByNumber(number int) (*PullRequest, error) { } n := query.Repository.PullRequest + if n.Number == 0 && n.ID == "" { + return nil, nil + } return &PullRequest{ ID: n.ID, Number: n.Number, diff --git a/internal/tui/stackview/data.go b/internal/tui/stackview/data.go index ffd21e88..cd517060 100644 --- a/internal/tui/stackview/data.go +++ b/internal/tui/stackview/data.go @@ -68,10 +68,16 @@ func LoadBranchNodes(cfg *config.Config, s *stack.Stack, currentBranch string) [ } } - // Fetch enriched PR details + // Fetch enriched PR details. + // Only adopt the result if it matches our tracked PR or is OPEN. + // This prevents showing stale merged/closed PR details when a + // branch name was reused from a previously merged PR. if clientErr == nil { if pr, err := client.FindPRDetailsForBranch(b.Branch); err == nil && pr != nil { - node.PR = pr + tracked := b.PullRequest != nil && b.PullRequest.Number == pr.Number + if tracked || pr.State == "OPEN" { + node.PR = pr + } } } diff --git a/internal/tui/stackview/data_test.go b/internal/tui/stackview/data_test.go index f0ff1e2e..4faa563a 100644 --- a/internal/tui/stackview/data_test.go +++ b/internal/tui/stackview/data_test.go @@ -100,3 +100,129 @@ func TestLoadBranchNodes_LinearBranchStillUsesMergeBase(t *testing.T) { assert.Len(t, nodes[0].FilesChanged, 1) assert.True(t, nodes[0].IsLinear) } + +func TestLoadBranchNodes_IgnoresStaleMergedPRDetails(t *testing.T) { + // When FindPRDetailsForBranch returns a merged PR that doesn't match + // the branch's tracked PR, it should be ignored (stale from branch reuse). + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "reused-branch"}, // no tracked PR + }, + } + + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(a, b string) (bool, error) { return true, nil }, + MergeBaseFn: func(a, b string) (string, error) { return "abc", nil }, + LogRangeFn: func(a, b string) ([]git.CommitInfo, error) { return nil, nil }, + DiffStatFilesFn: func(a, b string) ([]git.FileDiffStat, error) { + return nil, nil + }, + }) + defer restore() + + cfg, outW, errW := config.NewTestConfig() + defer outW.Close() + defer errW.Close() + cfg.GitHubClientOverride = &ghapi.MockClient{ + FindPRDetailsForBranchFn: func(branch string) (*ghapi.PRDetails, error) { + return &ghapi.PRDetails{ + Number: 20, + Title: "Old merged PR", + State: "MERGED", + Merged: true, + }, nil + }, + } + + nodes := LoadBranchNodes(cfg, s, "other") + + require.Len(t, nodes, 1) + assert.Nil(t, nodes[0].PR, "stale merged PR should not be adopted") +} + +func TestLoadBranchNodes_ShowsTrackedMergedPRDetails(t *testing.T) { + // When FindPRDetailsForBranch returns a merged PR that matches the + // branch's tracked PR number, it should be shown (legitimately merged). + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + { + Branch: "merged-branch", + PullRequest: &stack.PullRequestRef{ + Number: 20, + Merged: true, + }, + }, + }, + } + + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(a, b string) (bool, error) { return true, nil }, + MergeBaseFn: func(a, b string) (string, error) { return "abc", nil }, + LogRangeFn: func(a, b string) ([]git.CommitInfo, error) { return nil, nil }, + DiffStatFilesFn: func(a, b string) ([]git.FileDiffStat, error) { + return nil, nil + }, + }) + defer restore() + + cfg, outW, errW := config.NewTestConfig() + defer outW.Close() + defer errW.Close() + cfg.GitHubClientOverride = &ghapi.MockClient{ + FindPRDetailsForBranchFn: func(branch string) (*ghapi.PRDetails, error) { + return &ghapi.PRDetails{ + Number: 20, + Title: "Legitimately merged PR", + State: "MERGED", + Merged: true, + }, nil + }, + } + + nodes := LoadBranchNodes(cfg, s, "other") + + require.Len(t, nodes, 1) + require.NotNil(t, nodes[0].PR, "tracked merged PR should be shown") + assert.Equal(t, 20, nodes[0].PR.Number) +} + +func TestLoadBranchNodes_ShowsOpenPRDetails(t *testing.T) { + // An OPEN PR should always be shown, even without a tracked PR. + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feature"}, // no tracked PR + }, + } + + restore := git.SetOps(&git.MockOps{ + IsAncestorFn: func(a, b string) (bool, error) { return true, nil }, + MergeBaseFn: func(a, b string) (string, error) { return "abc", nil }, + LogRangeFn: func(a, b string) ([]git.CommitInfo, error) { return nil, nil }, + DiffStatFilesFn: func(a, b string) ([]git.FileDiffStat, error) { + return nil, nil + }, + }) + defer restore() + + cfg, outW, errW := config.NewTestConfig() + defer outW.Close() + defer errW.Close() + cfg.GitHubClientOverride = &ghapi.MockClient{ + FindPRDetailsForBranchFn: func(branch string) (*ghapi.PRDetails, error) { + return &ghapi.PRDetails{ + Number: 50, + Title: "Active PR", + State: "OPEN", + }, nil + }, + } + + nodes := LoadBranchNodes(cfg, s, "other") + + require.Len(t, nodes, 1) + require.NotNil(t, nodes[0].PR, "OPEN PR should be shown") + assert.Equal(t, 50, nodes[0].PR.Number) +} From a00ff54996a05742e86015d6cbc96e42260c7937 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:05 -0400 Subject: [PATCH 12/13] link command for api-only operations (#50) * link cmd for api-only stack upserts * accept branch names too * push branches first * update docs with link cmd details * adding more logging --- README.md | 36 + cmd/link.go | 535 ++++++++++++ cmd/link_test.go | 1098 ++++++++++++++++++++++++ cmd/root.go | 1 + docs/src/content/docs/faq.md | 30 +- docs/src/content/docs/reference/cli.md | 36 + skills/gh-stack/SKILL.md | 51 +- 7 files changed, 1783 insertions(+), 4 deletions(-) create mode 100644 cmd/link.go create mode 100644 cmd/link_test.go diff --git a/README.md b/README.md index 1834eeec..0a226d1a 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,42 @@ gh stack submit --auto gh stack submit --draft ``` +### `gh stack link` + +Link PRs into a stack on GitHub without local tracking. + +``` +gh stack link [flags] [...] +``` + +Creates or updates a stack on GitHub from branch names or PR numbers. This command does not store or modify any `gh stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs. + +Arguments are provided in stack order (bottom to top). Branch arguments are automatically pushed to the remote before creating or looking up PRs. For branches that already have open PRs, those PRs are used. For branches without PRs, new PRs are created automatically with the correct base branch chaining. Existing PRs whose base branch doesn't match the expected chain are corrected automatically. + +If the PRs are not yet in a stack, a new stack is created. If some of the PRs are already in a stack, the existing stack is updated to include the new PRs. Existing PRs are never removed from a stack — the update is additive only. + +| Flag | Description | +|------|-------------| +| `--base ` | Base branch for the bottom of the stack (default: `main`) | +| `--draft` | Create new PRs as drafts | +| `--remote ` | Remote to push to (defaults to auto-detected remote) | + +**Examples:** + +```sh +# Link branches into a stack (pushes branches, creates PRs, creates stack) +gh stack link feature-auth feature-api feature-ui + +# Link existing PRs by number +gh stack link 10 20 30 + +# Add branches to an existing stack of PRs +gh stack link 42 43 feature-auth feature-ui + +# Use a different base branch and create PRs as drafts +gh stack link --base develop --draft feat-a feat-b feat-c +``` + ### `gh stack view` View the current stack. diff --git a/cmd/link.go b/cmd/link.go new file mode 100644 index 00000000..289d40ce --- /dev/null +++ b/cmd/link.go @@ -0,0 +1,535 @@ +package cmd + +import ( + "errors" + "fmt" + "strconv" + + "github.com/cli/go-gh/v2/pkg/api" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" + "github.com/spf13/cobra" +) + +type linkOptions struct { + base string + draft bool + remote string +} + +func LinkCmd(cfg *config.Config) *cobra.Command { + opts := &linkOptions{} + + cmd := &cobra.Command{ + Use: "link [...]", + Short: "Link PRs into a stack on GitHub without local tracking", + Long: `Create or update a stack on GitHub from branch names or PR numbers. + +This command does not rely on gh-stack local tracking state. It is +designed for users who manage branches with external tools (e.g. jj, +Sapling, ghstack, git-town, etc...) and want to use GitHub stacked +PRs without adopting local stack tracking. + +Arguments are provided in stack order (bottom to top). Each argument +can be a branch name or a PR number. For numeric arguments, the +command first checks if a PR with that number exists; if not, it +treats the argument as a branch name. + +Branch arguments are automatically pushed to the remote before +creating or looking up PRs. For branches that already have open PRs, +those PRs are used. For branches without PRs, new PRs are created +automatically with the correct base branch chaining. + +If the PRs are not yet in a stack, a new stack is created. If some of +the PRs are already in a stack, the existing stack is updated to include +the new PRs (existing PRs are never removed).`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runLink(cfg, opts, args) + }, + } + + cmd.Flags().StringVar(&opts.base, "base", "main", "Base branch for the bottom of the stack") + cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create new PRs as drafts") + cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to push to (defaults to auto-detected remote)") + + return cmd +} + +// resolvedArg holds the result of resolving a single CLI argument to a PR. +type resolvedArg struct { + branch string // head branch name + prNumber int // PR number + prURL string // PR URL (for display) + created bool // true if we created this PR (skip base-fix re-fetch) +} + +func runLink(cfg *config.Config, opts *linkOptions, args []string) error { + if err := validateArgs(args); err != nil { + cfg.Errorf("%s", err) + return ErrInvalidArgs + } + + client, err := cfg.GitHubClient() + if err != nil { + cfg.Errorf("failed to create GitHub client: %s", err) + return ErrAPIFailure + } + + // Phase 1: Push branch args to the remote so PRs can be found/created + if err := pushBranchArgs(cfg, opts, args); err != nil { + return err + } + + // Phase 2: Find existing PRs for all args (don't create yet) + cfg.Printf("Looking up PRs for %d %s...", len(args), plural(len(args), "branch", "branches")) + found, err := findExistingPRs(cfg, client, args) + if err != nil { + return err + } + + // Phase 3: Pre-validate the stack — check that adding these PRs won't + // conflict with existing stacks before creating any new PRs. + // Also fetches stacks for reuse in the upsert phase. + knownPRNumbers := make([]int, 0, len(found)) + for _, r := range found { + if r != nil { + knownPRNumbers = append(knownPRNumbers, r.prNumber) + } + } + cfg.Printf("Checking existing stacks...") + stacks, err := listStacksSafe(cfg, client) + if err != nil { + return err + } + if len(knownPRNumbers) > 0 { + if err := prevalidateStack(cfg, stacks, knownPRNumbers); err != nil { + return err + } + } + + // Phase 4: Create PRs for branches that don't have one yet + needsCreation := 0 + for _, r := range found { + if r == nil { + needsCreation++ + } + } + if needsCreation > 0 { + cfg.Printf("Creating %d %s...", needsCreation, plural(needsCreation, "PR", "PRs")) + } + resolved, err := createMissingPRs(cfg, client, opts, args, found) + if err != nil { + return err + } + + // Phase 5: Fix base branches for existing PRs with wrong bases + fixBaseBranches(cfg, client, opts, resolved) + + // Phase 6: Upsert the stack (reuse stacks from phase 3) + prNumbers := make([]int, len(resolved)) + for i, r := range resolved { + prNumbers[i] = r.prNumber + } + + return upsertStack(cfg, client, stacks, prNumbers) +} + +// pushBranchArgs pushes all arguments that correspond to local branches +// to the remote. This ensures branches exist on the server before we try +// to create or look up PRs. Args that are pure PR numbers (not local +// branch names) are skipped. +func pushBranchArgs(cfg *config.Config, opts *linkOptions, args []string) error { + var branches []string + for _, arg := range args { + if git.BranchExists(arg) { + branches = append(branches, arg) + } + } + + if len(branches) == 0 { + return nil + } + + // Resolve the remote using the first branch as context + remote, err := pickRemote(cfg, branches[0], opts.remote) + if err != nil { + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } + return ErrSilent + } + + cfg.Printf("Pushing %d %s to %s...", len(branches), plural(len(branches), "branch", "branches"), remote) + if err := git.Push(remote, branches, false, true); err != nil { + cfg.Errorf("failed to push branches: %s", err) + return ErrSilent + } + + return nil +} + +// validateArgs checks for duplicates in the arg list. +func validateArgs(args []string) error { + seen := make(map[string]bool, len(args)) + for _, arg := range args { + if seen[arg] { + return fmt.Errorf("duplicate argument: %q", arg) + } + seen[arg] = true + } + return nil +} + +// findExistingPRs looks up existing PRs for each arg without creating any. +// Returns a slice parallel to args where each entry is either a resolved PR +// or nil (meaning the branch has no PR yet and one needs to be created). +func findExistingPRs(cfg *config.Config, client github.ClientOps, args []string) ([]*resolvedArg, error) { + found := make([]*resolvedArg, len(args)) + + for i, arg := range args { + r, err := findExistingPR(cfg, client, arg) + if err != nil { + return nil, err + } + if r != nil { + // Check for duplicate PR numbers + for j := 0; j < i; j++ { + if found[j] != nil && found[j].prNumber == r.prNumber { + cfg.Errorf("arguments %q and %q resolve to the same PR #%d", found[j].branch, r.branch, r.prNumber) + return nil, ErrInvalidArgs + } + } + } + found[i] = r + } + + return found, nil +} + +// findExistingPR looks up an existing PR for a single arg. +// Returns nil if the arg is a branch with no open PR. +func findExistingPR(cfg *config.Config, client github.ClientOps, arg string) (*resolvedArg, error) { + // If numeric, try as PR number first + if n, err := strconv.Atoi(arg); err == nil && n > 0 { + pr, err := client.FindPRByNumber(n) + if err != nil { + cfg.Errorf("failed to look up PR #%d: %v", n, err) + return nil, ErrAPIFailure + } + if pr != nil { + return &resolvedArg{ + branch: pr.HeadRefName, + prNumber: pr.Number, + prURL: pr.URL, + }, nil + } + // PR doesn't exist — fall through to branch name lookup + } + + // Treat as branch name: look for an open PR + pr, err := client.FindPRForBranch(arg) + if err != nil { + cfg.Errorf("failed to look up PR for branch %s: %v", arg, err) + return nil, ErrAPIFailure + } + if pr != nil { + cfg.Printf("Found PR %s for branch %s", cfg.PRLink(pr.Number, pr.URL), arg) + return &resolvedArg{ + branch: arg, + prNumber: pr.Number, + prURL: pr.URL, + }, nil + } + + return nil, nil // needs PR creation +} + +// listStacksSafe fetches all stacks, handling the 404 "not enabled" case. +func listStacksSafe(cfg *config.Config, client github.ClientOps) ([]github.RemoteStack, error) { + stacks, err := client.ListStacks() + if err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { + cfg.Warningf("Stacked PRs are not enabled for this repository") + return nil, ErrStacksUnavailable + } + cfg.Errorf("failed to list stacks: %v", err) + return nil, ErrAPIFailure + } + return stacks, nil +} + +// prevalidateStack checks whether the known PRs would conflict with +// existing stacks. This runs before creating new PRs so we can fail +// early without leaving orphaned PRs. +func prevalidateStack(cfg *config.Config, stacks []github.RemoteStack, knownPRNumbers []int) error { + matchedStack, err := findMatchingStack(stacks, knownPRNumbers) + if err != nil { + cfg.Errorf("%s", err) + return ErrDisambiguate + } + + if matchedStack != nil { + // Check that we won't be removing PRs from the existing stack. + // At this point we only have the known PR numbers (existing PRs). + // New PRs will be created later and added. Since new PRs can't + // match existing stack PRs (they don't exist yet), we just need + // to check that all existing stack PRs are in the known set. + knownSet := make(map[int]bool, len(knownPRNumbers)) + for _, n := range knownPRNumbers { + knownSet[n] = true + } + + var dropped []int + for _, n := range matchedStack.PullRequests { + if !knownSet[n] { + dropped = append(dropped, n) + } + } + + if len(dropped) > 0 { + cfg.Errorf("Cannot update stack: this would remove %s from the stack", + formatPRList(dropped)) + cfg.Printf("Current stack: %s", formatPRList(matchedStack.PullRequests)) + cfg.Printf("Include all existing PRs in the command to update the stack") + return ErrInvalidArgs + } + } + + return nil +} + +// createMissingPRs creates PRs for branches that don't have one yet. +// Returns the fully resolved list with all branches mapped to PRs. +func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOptions, args []string, found []*resolvedArg) ([]resolvedArg, error) { + resolved := make([]resolvedArg, len(args)) + + for i, arg := range args { + if found[i] != nil { + resolved[i] = *found[i] + continue + } + + // Determine the base branch for this PR + baseBranch := opts.base + if i > 0 { + baseBranch = resolved[i-1].branch + } + + title := humanize(arg) + body := generatePRBody("") + + newPR, err := client.CreatePR(baseBranch, arg, title, body, opts.draft) + if err != nil { + cfg.Errorf("failed to create PR for branch %s: %v", arg, err) + return nil, ErrAPIFailure + } + + cfg.Successf("Created PR %s for %s (base: %s)", cfg.PRLink(newPR.Number, newPR.URL), arg, baseBranch) + resolved[i] = resolvedArg{ + branch: arg, + prNumber: newPR.Number, + prURL: newPR.URL, + created: true, + } + } + + return resolved, nil +} + +// fixBaseBranches updates the base branch of existing PRs to match the +// expected stack chain. The first PR should have base = opts.base, +// each subsequent PR should have base = previous PR's head branch. +// Newly created PRs (created=true) are skipped since they already have +// the correct base from creation. +func fixBaseBranches(cfg *config.Config, client github.ClientOps, opts *linkOptions, resolved []resolvedArg) { + for i, r := range resolved { + if r.created { + continue + } + + expectedBase := opts.base + if i > 0 { + expectedBase = resolved[i-1].branch + } + + // Look up the PR to check its current base + pr, err := client.FindPRByNumber(r.prNumber) + if err != nil { + cfg.Warningf("could not verify base branch for PR %s: %v", + cfg.PRLink(r.prNumber, r.prURL), err) + continue + } + if pr == nil { + continue + } + + if pr.BaseRefName != expectedBase { + if err := client.UpdatePRBase(r.prNumber, expectedBase); err != nil { + cfg.Warningf("failed to update base branch for PR %s to %s: %s", + cfg.PRLink(r.prNumber, r.prURL), expectedBase, formatAPIError(err)) + } else { + cfg.Successf("Updated base branch for PR %s to %s", + cfg.PRLink(r.prNumber, r.prURL), expectedBase) + } + } + } +} + +// formatAPIError extracts a clean error message from an API error. +// For HTTP errors, returns just the status and message without the raw URL. +func formatAPIError(err error) string { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + if httpErr.Message != "" { + return fmt.Sprintf("HTTP %d: %s", httpErr.StatusCode, httpErr.Message) + } + return fmt.Sprintf("HTTP %d", httpErr.StatusCode) + } + return err.Error() +} + +// upsertStack uses the pre-fetched stacks to create or update as needed. +func upsertStack(cfg *config.Config, client github.ClientOps, stacks []github.RemoteStack, prNumbers []int) error { + matchedStack, err := findMatchingStack(stacks, prNumbers) + if err != nil { + cfg.Errorf("%s", err) + return ErrDisambiguate + } + + if matchedStack == nil { + return createLink(cfg, client, prNumbers) + } + + return updateLink(cfg, client, matchedStack, prNumbers) +} + +// findMatchingStack finds a single stack that contains any of the given PR numbers. +// Returns nil if no stack matches. Returns an error if PRs span multiple stacks. +func findMatchingStack(stacks []github.RemoteStack, prNumbers []int) (*github.RemoteStack, error) { + prSet := make(map[int]bool, len(prNumbers)) + for _, n := range prNumbers { + prSet[n] = true + } + + var matched *github.RemoteStack + for i := range stacks { + for _, n := range stacks[i].PullRequests { + if prSet[n] { + if matched != nil && matched.ID != stacks[i].ID { + return nil, fmt.Errorf("PRs belong to multiple stacks — unstack them first, then re-link") + } + matched = &stacks[i] + break + } + } + } + + return matched, nil +} + +// createLink creates a new stack with the given PR numbers. +func createLink(cfg *config.Config, client github.ClientOps, prNumbers []int) error { + _, err := client.CreateStack(prNumbers) + if err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case 422: + cfg.Errorf("Cannot create stack: %s", httpErr.Message) + return ErrAPIFailure + case 404: + cfg.Warningf("Stacked PRs are not enabled for this repository") + return ErrStacksUnavailable + default: + cfg.Errorf("Failed to create stack (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) + return ErrAPIFailure + } + } + cfg.Errorf("Failed to create stack: %v", err) + return ErrAPIFailure + } + + cfg.Successf("Created stack with %d PRs", len(prNumbers)) + return nil +} + +// updateLink updates an existing stack with the given PR numbers. +// The update is additive-only: it errors if any existing PRs would be removed. +func updateLink(cfg *config.Config, client github.ClientOps, existing *github.RemoteStack, prNumbers []int) error { + // Check if the input exactly matches the existing stack. + if slicesEqual(existing.PullRequests, prNumbers) { + cfg.Successf("Stack with %d PRs is already up to date", len(prNumbers)) + return nil + } + + // Check that no existing PRs would be removed (additive-only). + newSet := make(map[int]bool, len(prNumbers)) + for _, n := range prNumbers { + newSet[n] = true + } + + var dropped []int + for _, n := range existing.PullRequests { + if !newSet[n] { + dropped = append(dropped, n) + } + } + + if len(dropped) > 0 { + cfg.Errorf("Cannot update stack: this would remove %s from the stack", + formatPRList(dropped)) + cfg.Printf("Current stack: %s", formatPRList(existing.PullRequests)) + cfg.Printf("Include all existing PRs in the command to update the stack") + return ErrInvalidArgs + } + + stackID := strconv.Itoa(existing.ID) + if err := client.UpdateStack(stackID, prNumbers); err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case 404: + // Stack was deleted between list and update — try creating instead. + cfg.Warningf("Stack was deleted — creating a new one") + return createLink(cfg, client, prNumbers) + case 422: + cfg.Errorf("Cannot update stack: %s", httpErr.Message) + return ErrAPIFailure + default: + cfg.Errorf("Failed to update stack (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) + return ErrAPIFailure + } + } + cfg.Errorf("Failed to update stack: %v", err) + return ErrAPIFailure + } + + cfg.Successf("Updated stack to %d PRs", len(prNumbers)) + return nil +} + +func slicesEqual(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func formatPRList(numbers []int) string { + if len(numbers) == 0 { + return "" + } + s := fmt.Sprintf("#%d", numbers[0]) + for _, n := range numbers[1:] { + s += fmt.Sprintf(", #%d", n) + } + return s +} diff --git a/cmd/link_test.go b/cmd/link_test.go new file mode 100644 index 00000000..48aaef94 --- /dev/null +++ b/cmd/link_test.go @@ -0,0 +1,1098 @@ +package cmd + +import ( + "fmt" + "io" + "testing" + + "github.com/cli/go-gh/v2/pkg/api" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newLinkGitMock creates a MockOps for link tests that involve branch args. +// BranchExists returns true for the given branches, Push is a no-op, +// and ResolveRemote returns "origin". +func newLinkGitMock(branches ...string) *git.MockOps { + branchSet := make(map[string]bool, len(branches)) + for _, b := range branches { + branchSet[b] = true + } + return &git.MockOps{ + BranchExistsFn: func(name string) bool { return branchSet[name] }, + PushFn: func(string, []string, bool, bool) error { return nil }, + ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, + } +} + +// --- PR-number tests --- + +func TestLink_PRNumbers_CreateNewStack(t *testing.T) { + var createdPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + createdPRs = prNumbers + return 42, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20", "30"}) + 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.Equal(t, []int{10, 20, 30}, createdPRs) + assert.Contains(t, output, "Created stack with 3 PRs") +} + +func TestLink_PRNumbers_UpdateExistingStack(t *testing.T) { + var updatedID string + var updatedPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 7, PullRequests: []int{10, 20}}, + }, nil + }, + UpdateStackFn: func(stackID string, prNumbers []int) error { + updatedID = stackID + updatedPRs = prNumbers + return nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20", "30"}) + 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.Equal(t, "7", updatedID) + assert.Equal(t, []int{10, 20, 30}, updatedPRs) + assert.Contains(t, output, "Updated stack to 3 PRs") +} + +func TestLink_PRNumbers_ExactMatch_NoOp(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 7, PullRequests: []int{10, 20, 30}}, + }, nil + }, + UpdateStackFn: func(string, []int) error { + t.Fatal("UpdateStack should not be called for exact match") + return nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20", "30"}) + 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, "already up to date") +} + +func TestLink_PRNumbers_WouldRemovePRs(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 7, PullRequests: []int{10, 20, 30}}, + }, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"20", "30"}) + 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, ErrInvalidArgs) + assert.Contains(t, output, "would remove") + assert.Contains(t, output, "#10") +} + +func TestLink_PRNumbers_MultipleStacks(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 1, PullRequests: []int{10, 20}}, + {ID: 2, PullRequests: []int{30, 40}}, + }, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "30"}) + 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, ErrDisambiguate) + assert.Contains(t, output, "multiple stacks") +} + +func TestLink_TooFewArgs(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + // cobra enforces MinimumNArgs(2) before RunE is called + assert.Error(t, err) +} + +func TestLink_DuplicateArgs(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feature-a", "feature-a"}) + 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, ErrInvalidArgs) + assert.Contains(t, output, "duplicate argument") +} + +func TestLink_StacksUnavailable(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{Number: n, HeadRefName: "b", BaseRefName: "main"}, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20"}) + 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, "not enabled") +} + +func TestLink_Create422(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{Number: n, HeadRefName: "b", BaseRefName: "main"}, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + return 0, &api.HTTPError{ + StatusCode: 422, + Message: "Pull requests must form a stack", + } + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20"}) + 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, ErrAPIFailure) + assert.Contains(t, output, "must form a stack") +} + +// --- Branch name tests --- + +func TestLink_BranchNames_AllHavePRs(t *testing.T) { + restore := git.SetOps(newLinkGitMock("feature-a", "feature-b")) + defer restore() + + var stackedPRs []int + prMap := map[string]*github.PullRequest{ + "feature-a": {Number: 10, HeadRefName: "feature-a", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"}, + "feature-b": {Number: 20, HeadRefName: "feature-b", BaseRefName: "feature-a", URL: "https://github.com/o/r/pull/20"}, + } + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return prMap[branch], nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + for _, pr := range prMap { + if pr.Number == n { + return pr, nil + } + } + return nil, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + stackedPRs = prNumbers + return 42, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feature-a", "feature-b"}) + 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.Equal(t, []int{10, 20}, stackedPRs) + assert.Contains(t, output, "Created stack with 2 PRs") +} + +func TestLink_BranchNames_CreatesMissingPRs(t *testing.T) { + restore := git.SetOps(newLinkGitMock("feature-a", "feature-b")) + defer restore() + + var createdPRs []struct{ base, head string } + var stackedPRs []int + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if branch == "feature-a" { + return &github.PullRequest{ + Number: 10, HeadRefName: "feature-a", BaseRefName: "main", + URL: "https://github.com/o/r/pull/10", + }, nil + } + return nil, nil // feature-b has no PR + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + if n == 10 { + return &github.PullRequest{ + Number: 10, HeadRefName: "feature-a", BaseRefName: "main", + URL: "https://github.com/o/r/pull/10", + }, nil + } + if n == 20 { + return &github.PullRequest{ + Number: 20, HeadRefName: "feature-b", BaseRefName: "feature-a", + URL: "https://github.com/o/r/pull/20", + }, nil + } + return nil, nil + }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + createdPRs = append(createdPRs, struct{ base, head string }{base, head}) + return &github.PullRequest{ + Number: 20, HeadRefName: head, BaseRefName: base, + URL: "https://github.com/o/r/pull/20", + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + stackedPRs = prNumbers + return 42, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feature-a", "feature-b"}) + 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) + require.Len(t, createdPRs, 1) + assert.Equal(t, "feature-a", createdPRs[0].base) // base should chain to previous branch + assert.Equal(t, "feature-b", createdPRs[0].head) + assert.Equal(t, []int{10, 20}, stackedPRs) + assert.Contains(t, output, "Created PR") + assert.Contains(t, output, "Created stack with 2 PRs") +} + +func TestLink_BranchNames_AllNeedPRs(t *testing.T) { + restore := git.SetOps(newLinkGitMock("feat-a", "feat-b", "feat-c")) + defer restore() + + prCounter := 0 + var createdPRs []struct{ base, head string } + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return nil, nil // no open PRs for any branch + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + bases := map[int]string{1: "main", 2: "feat-a", 3: "feat-b"} + heads := map[int]string{1: "feat-a", 2: "feat-b", 3: "feat-c"} + if h, ok := heads[n]; ok { + return &github.PullRequest{ + Number: n, HeadRefName: h, BaseRefName: bases[n], + }, nil + } + return nil, nil + }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + prCounter++ + createdPRs = append(createdPRs, struct{ base, head string }{base, head}) + return &github.PullRequest{ + Number: prCounter, + HeadRefName: head, + BaseRefName: base, + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", prCounter), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + return 42, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"--base", "develop", "feat-a", "feat-b", "feat-c"}) + 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) + require.Len(t, createdPRs, 3) + // First PR base should be the --base flag value + assert.Equal(t, "develop", createdPRs[0].base) + assert.Equal(t, "feat-a", createdPRs[0].head) + // Second PR base should be previous branch + assert.Equal(t, "feat-a", createdPRs[1].base) + assert.Equal(t, "feat-b", createdPRs[1].head) + // Third PR base should be previous branch + assert.Equal(t, "feat-b", createdPRs[2].base) + assert.Equal(t, "feat-c", createdPRs[2].head) + assert.Contains(t, output, "Created stack with 3 PRs") +} + +func TestLink_BranchNames_DraftFlag(t *testing.T) { + restore := git.SetOps(newLinkGitMock("feat-a", "feat-b")) + defer restore() + + var createdDraft bool + prCounter := 0 + + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return nil, nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + heads := map[int]string{1: "feat-a", 2: "feat-b"} + bases := map[int]string{1: "main", 2: "feat-a"} + if h, ok := heads[n]; ok { + return &github.PullRequest{Number: n, HeadRefName: h, BaseRefName: bases[n]}, nil + } + return nil, nil + }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + createdDraft = draft + prCounter++ + return &github.PullRequest{ + Number: prCounter, HeadRefName: head, BaseRefName: base, + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", prCounter), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func([]int) (int, error) { return 1, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"--draft", "feat-a", "feat-b"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.True(t, createdDraft, "PRs should be created as drafts when --draft is set") +} + +func TestLink_MixedArgs_PRNumberAndBranch(t *testing.T) { + restore := git.SetOps(newLinkGitMock("new-feature")) + defer restore() + + var stackedPRs []int + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + if n == 42 { + return &github.PullRequest{ + Number: 42, HeadRefName: "existing-branch", BaseRefName: "main", + URL: "https://github.com/o/r/pull/42", + }, nil + } + return nil, nil + }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if branch == "new-feature" { + return &github.PullRequest{ + Number: 99, HeadRefName: "new-feature", BaseRefName: "existing-branch", + URL: "https://github.com/o/r/pull/99", + }, nil + } + return nil, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + stackedPRs = prNumbers + return 42, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"42", "new-feature"}) + 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.Equal(t, []int{42, 99}, stackedPRs) + assert.Contains(t, output, "Created stack with 2 PRs") +} + +func TestLink_NumericArg_PRNotFound_TreatedAsBranch(t *testing.T) { + // Numeric branches "123" and "456" exist locally + restore := git.SetOps(newLinkGitMock("123", "456")) + defer restore() + + var stackedPRs []int + + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return nil, nil // PR not found + }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + // Treat "123" as a branch name + if branch == "123" { + return &github.PullRequest{ + Number: 50, HeadRefName: "123", BaseRefName: "main", + URL: "https://github.com/o/r/pull/50", + }, nil + } + if branch == "456" { + return &github.PullRequest{ + Number: 51, HeadRefName: "456", BaseRefName: "123", + URL: "https://github.com/o/r/pull/51", + }, nil + } + return nil, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func(prNumbers []int) (int, error) { + stackedPRs = prNumbers + return 42, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"123", "456"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []int{50, 51}, stackedPRs) +} + +func TestLink_FixesBaseBranches(t *testing.T) { + restore := git.SetOps(newLinkGitMock("feat-a", "feat-b")) + defer restore() + + var baseUpdates []struct { + number int + base string + } + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + switch branch { + case "feat-a": + return &github.PullRequest{ + Number: 10, HeadRefName: "feat-a", BaseRefName: "main", + URL: "https://github.com/o/r/pull/10", + }, nil + case "feat-b": + // This PR has the wrong base — should be feat-a, not main + return &github.PullRequest{ + Number: 20, HeadRefName: "feat-b", BaseRefName: "main", + URL: "https://github.com/o/r/pull/20", + }, nil + } + return nil, nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + switch n { + case 10: + return &github.PullRequest{ + Number: 10, HeadRefName: "feat-a", BaseRefName: "main", + }, nil + case 20: + return &github.PullRequest{ + Number: 20, HeadRefName: "feat-b", BaseRefName: "main", + }, nil + } + return nil, nil + }, + UpdatePRBaseFn: func(number int, base string) error { + baseUpdates = append(baseUpdates, struct { + number int + base string + }{number, base}) + return nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func([]int) (int, error) { return 42, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feat-a", "feat-b"}) + 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) + // PR #20's base should be updated from "main" to "feat-a" + require.Len(t, baseUpdates, 1) + assert.Equal(t, 20, baseUpdates[0].number) + assert.Equal(t, "feat-a", baseUpdates[0].base) + assert.Contains(t, output, "Updated base branch") +} + +func TestLink_DuplicateBranchResolvesToSamePR(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: 10, HeadRefName: branch, BaseRefName: "main", + }, nil + }, + } + + // Different args that resolve to the same PR + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feat-a", "feat-a"}) + 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, ErrInvalidArgs) + assert.Contains(t, output, "duplicate argument") +} + +func TestLink_UpdateDeletedStack_FallsBackToCreate(t *testing.T) { + var created bool + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{Number: n, HeadRefName: "b", BaseRefName: "main"}, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 7, PullRequests: []int{10}}, + }, nil + }, + UpdateStackFn: func(string, []int) error { + return &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + CreateStackFn: func(prNumbers []int) (int, error) { + created = true + return 99, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20"}) + 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.True(t, created) + assert.Contains(t, output, "Created stack with 2 PRs") +} + +func TestLink_PushesBranchesBeforeResolution(t *testing.T) { + var pushedBranches []string + var pushedRemote string + + restore := git.SetOps(&git.MockOps{ + BranchExistsFn: func(name string) bool { return name == "feat-a" || name == "feat-b" }, + ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, + PushFn: func(remote string, branches []string, force, atomic bool) error { + pushedRemote = remote + pushedBranches = branches + return nil + }, + }) + defer restore() + + prCounter := 0 + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + prCounter++ + return &github.PullRequest{ + Number: prCounter, HeadRefName: branch, BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", prCounter), + }, nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{Number: n, HeadRefName: fmt.Sprintf("b%d", n), BaseRefName: "main"}, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, + CreateStackFn: func([]int) (int, error) { return 1, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feat-a", "feat-b"}) + 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.Equal(t, "origin", pushedRemote) + assert.Equal(t, []string{"feat-a", "feat-b"}, pushedBranches) + assert.Contains(t, output, "Pushing 2 branches") +} + +func TestLink_RemoteFlag(t *testing.T) { + var pushedRemote string + + restore := git.SetOps(&git.MockOps{ + BranchExistsFn: func(string) bool { return true }, + PushFn: func(remote string, branches []string, force, atomic bool) error { + pushedRemote = remote + return nil + }, + }) + defer restore() + + prCounter := 0 + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + prCounter++ + return &github.PullRequest{ + Number: prCounter, HeadRefName: branch, BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", prCounter), + }, nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{Number: n, HeadRefName: fmt.Sprintf("b%d", n), BaseRefName: "main"}, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, + CreateStackFn: func([]int) (int, error) { return 1, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"--remote", "upstream", "feat-a", "feat-b"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, "upstream", pushedRemote) +} + +func TestLink_SkipsPushForPRNumbersOnly(t *testing.T) { + pushCalled := false + + restore := git.SetOps(&git.MockOps{ + BranchExistsFn: func(string) bool { return false }, // PR numbers aren't local branches + PushFn: func(string, []string, bool, bool) error { + pushCalled = true + return nil + }, + }) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{Number: n, HeadRefName: "b", BaseRefName: "main"}, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, + CreateStackFn: func([]int) (int, error) { return 1, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.False(t, pushCalled, "push should not be called when all args are PR numbers") +} + +func TestLink_PrevalidatesBeforeCreatingPRs(t *testing.T) { + // Scenario: branch feat-b has an existing PR #106 in a stack with [104, 105, 106]. + // User runs: gh stack link feat-a feat-b + // feat-a has no PR yet, but the stack pre-validation should catch that + // #104 and #105 would be dropped — and fail BEFORE creating a PR for feat-a. + restore := git.SetOps(newLinkGitMock("feat-a", "feat-b")) + defer restore() + + prCreated := false + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if branch == "feat-b" { + return &github.PullRequest{ + Number: 106, HeadRefName: "feat-b", BaseRefName: "main", + URL: "https://github.com/o/r/pull/106", + }, nil + } + return nil, nil // feat-a has no PR + }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + prCreated = true + return &github.PullRequest{Number: 200, HeadRefName: head}, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 7, PullRequests: []int{104, 105, 106}}, + }, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feat-a", "feat-b"}) + 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, ErrInvalidArgs) + assert.False(t, prCreated, "should NOT create PRs before validating stack") + assert.Contains(t, output, "would remove") + assert.Contains(t, output, "#104") + assert.Contains(t, output, "#105") +} + +// --- Unit tests for helpers --- + +func TestFindMatchingStack(t *testing.T) { + tests := []struct { + name string + stacks []github.RemoteStack + prNumbers []int + wantID int + wantNil bool + wantErr bool + }{ + { + name: "no stacks", + stacks: []github.RemoteStack{}, + prNumbers: []int{10, 20}, + wantNil: true, + }, + { + name: "no match", + stacks: []github.RemoteStack{ + {ID: 1, PullRequests: []int{30, 40}}, + }, + prNumbers: []int{10, 20}, + wantNil: true, + }, + { + name: "single match", + stacks: []github.RemoteStack{ + {ID: 5, PullRequests: []int{10, 20}}, + }, + prNumbers: []int{10, 30}, + wantID: 5, + }, + { + name: "multiple matches", + stacks: []github.RemoteStack{ + {ID: 1, PullRequests: []int{10}}, + {ID: 2, PullRequests: []int{20}}, + }, + prNumbers: []int{10, 20}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := findMatchingStack(tt.stacks, tt.prNumbers) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if tt.wantNil { + assert.Nil(t, got) + } else { + assert.Equal(t, tt.wantID, got.ID) + } + } + }) + } +} + +func TestFormatPRList(t *testing.T) { + assert.Equal(t, "#10", formatPRList([]int{10})) + assert.Equal(t, "#10, #20, #30", formatPRList([]int{10, 20, 30})) + assert.Equal(t, "", formatPRList([]int{})) +} + +func TestSlicesEqual(t *testing.T) { + assert.True(t, slicesEqual([]int{1, 2, 3}, []int{1, 2, 3})) + assert.False(t, slicesEqual([]int{1, 2, 3}, []int{1, 2})) + assert.False(t, slicesEqual([]int{1, 2}, []int{1, 3})) + assert.True(t, slicesEqual([]int{}, []int{})) +} + +func TestValidateArgs(t *testing.T) { + assert.NoError(t, validateArgs([]string{"a", "b", "c"})) + assert.NoError(t, validateArgs([]string{"10", "20"})) + assert.Error(t, validateArgs([]string{"a", "a"})) + assert.Error(t, validateArgs([]string{"10", "10"})) +} + +func TestFormatAPIError(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + { + name: "HTTP error with message", + err: &api.HTTPError{StatusCode: 422, Message: "Validation Failed"}, + want: "HTTP 422: Validation Failed", + }, + { + name: "HTTP error without message", + err: &api.HTTPError{StatusCode: 500}, + want: "HTTP 500", + }, + { + name: "non-HTTP error", + err: fmt.Errorf("network timeout"), + want: "network timeout", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, formatAPIError(tt.err)) + }) + } +} + +func TestLink_FindPRByNumber_ErrorIsFatal(t *testing.T) { + // When FindPRByNumber returns an error (not just nil), it should NOT + // silently fall through to branch-name lookup. + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return nil, fmt.Errorf("network error") + }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + t.Fatal("FindPRForBranch should NOT be called when FindPRByNumber errors") + return nil, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"42", "43"}) + 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, ErrAPIFailure) + assert.Contains(t, output, "failed to look up PR #42") +} + +func TestLink_SkipsBaseFix_ForNewlyCreatedPRs(t *testing.T) { + // When PRs are created by the command, fixBaseBranches should skip them + // (no re-fetch needed since they already have the correct base). + restore := git.SetOps(newLinkGitMock("feat-a", "feat-b")) + defer restore() + + findByNumberCalls := 0 + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return nil, nil // no existing PRs + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + findByNumberCalls++ + return nil, nil + }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: 100, HeadRefName: head, BaseRefName: base, + URL: "https://github.com/o/r/pull/100", + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, + CreateStackFn: func([]int) (int, error) { return 1, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feat-a", "feat-b"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + // FindPRByNumber is called during findExistingPRs (phase 2) for numeric + // args only, but NOT during fixBaseBranches for newly created PRs. + // Since "feat-a" and "feat-b" are not numeric, FindPRByNumber should + // not be called at all. + assert.Equal(t, 0, findByNumberCalls, "FindPRByNumber should not be called for newly created PRs") +} + +// Silence "imported and not used" for fmt in case test helpers use it. +var _ = fmt.Sprintf diff --git a/cmd/root.go b/cmd/root.go index 704fe1e6..b29a23d9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -37,6 +37,7 @@ func RootCmd() *cobra.Command { root.AddCommand(SyncCmd(cfg)) root.AddCommand(UnstackCmd(cfg)) root.AddCommand(MergeCmd(cfg)) + root.AddCommand(LinkCmd(cfg)) // Helper commands root.AddCommand(ViewCmd(cfg)) diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index 38d7381d..aa315e53 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -188,11 +188,12 @@ No. Stacked PRs are built on standard git branches and regular pull requests. Yo ### Will this work with a different tool for stacking? -Yes, you can continue to use your tool of choice (e.g., jj, Sapling, ghstack, git-town, etc.) to manage stacks locally and push up your branches to GitHub. +Yes, you can continue to use your tool of choice (e.g., jj, Sapling, git-town, etc.) to manage stacks locally and push up your branches to GitHub. Stacked PRs on GitHub are based on the standard pull request model — any tool that creates PRs with the correct base branches can work with them. The `gh stack` CLI is purpose-built for the GitHub experience, but other tools that manage branch chains should be compatible. -You can also use the GitHub CLI in conjunction with other tools to open your PRs as a stack: +You can also use the `gh stack link` command in conjunction with other tools to open your PRs as a stack: + ```bash # Create a stack of branches locally using jj @@ -208,7 +209,30 @@ jj new -m "third change" jj bookmark create change3 --revision @ # ... -# Use gh stack to submit a stack of PRs +# Push branches and link them as a stack on GitHub +# (creates PRs automatically if they don't exist) +gh stack link change1 change2 change3 +``` + +This doesn't create any local tracking and only hits the APIs to create Stacked PRs. + +If the provided branches already have open PRs, `link` will use them. If not, it creates PRs with the correct base branch chaining. + +To add more to the stack, run `link` again, but be sure to include the full list of PRs/branches in the stack: + +```bash +gh stack link 123 124 125 change4 change5 +``` + +You can also use `--base` to specify a different trunk branch and `--draft` to create PRs as drafts: + +```bash +gh stack link --base develop --draft change1 change2 change3 +``` + +Alternatively, if you want full local stack tracking (for commands like `rebase`, `sync`, and navigation), you can adopt existing branches to local tracking with `gh stack`: + +```bash gh stack init --adopt change1 change2 change3 gh stack submit ``` diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 2a90504f..f54dee95 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -312,6 +312,42 @@ gh stack unstack --local gh stack unstack feature-auth ``` +### `gh stack link` + +Link PRs into a stack on GitHub without local tracking. + +```sh +gh stack link [flags] [...] +``` + +Creates or updates a stack on GitHub from branch names or PR numbers. This command does not create or modify any `gh-stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs. + +Arguments are provided in stack order (bottom to top). Branch arguments are automatically pushed to the remote before creating or looking up PRs. For branches that already have open PRs, those PRs are used. For branches without PRs, new PRs are created automatically with the correct base branch chaining. Existing PRs whose base branch doesn't match the expected chain are corrected automatically. + +If the PRs are not yet in a stack, a new stack is created. If some of the PRs are already in a stack, the existing stack is updated to include the new PRs. Existing PRs are never removed from a stack — the update is additive only. + +| Flag | Description | +|------|-------------| +| `--base ` | Base branch for the bottom of the stack (default: `main`) | +| `--draft` | Create new PRs as drafts | +| `--remote ` | Remote to push to (defaults to auto-detected remote) | + +**Examples:** + +```sh +# Link branches into a stack (pushes, creates PRs, creates stack) +gh stack link feature-auth feature-api feature-ui + +# Link existing PRs by number +gh stack link 10 20 30 + +# Add branches to an existing stack of PRs +gh stack link 42 43 feature-auth feature-ui + +# Use a different base branch and create PRs as drafts +gh stack link --base develop --draft feat-a feat-b feat-c +``` + --- ## Navigation diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index ad928a4d..0bff246a 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -56,11 +56,12 @@ git config remote.pushDefault origin # if multiple remotes exist (skips remo 2. **When a prefix is set, pass only the suffix to `add`.** `gh stack add auth` with prefix `feat` → `feat/auth`. Passing `feat/auth` creates `feat/feat/auth`. 3. **Always use `--auto` with `gh stack submit`** to auto-generate PR titles. Without `--auto`, `submit` prompts for a title for each new PR. 4. **Always use `--json` with `gh stack view`.** Without `--json`, the command launches an interactive TUI that cannot be operated by agents. There is no other appropriate flag — always pass `--json`. -5. **Use `--remote ` when multiple remotes are configured**, or pre-configure `git config remote.pushDefault origin`. Without this, `push`, `submit`, `sync`, and `checkout` trigger an interactive remote picker. +5. **Use `--remote ` when multiple remotes are configured**, or pre-configure `git config remote.pushDefault origin`. Without this, `push`, `submit`, `sync`, `link`, and `checkout` trigger an interactive remote picker. 6. **Avoid branches shared across multiple stacks.** If a branch belongs to multiple stacks, commands exit with code 6. Check out a non-shared branch first. 7. **Plan your stack layers by dependency order before writing code.** Foundational changes (models, APIs, shared utilities) go in lower branches; dependent changes (UI, consumers) go in higher branches. Think through the dependency chain before running `gh stack init`. 8. **Use standard `git add` and `git commit` for staging and committing.** This gives you full control over which changes go into each branch. The `-Am` shortcut is available but should not be the default approach—stacked PRs are most effective when each branch contains a deliberate, logical set of changes. 9. **Navigate down the stack when you need to change a lower layer.** If you're working on a frontend branch and realize you need API changes, don't hack around it at the current layer. Navigate to the appropriate branch (`gh stack down`, `gh stack checkout`, or `gh stack bottom`), make and commit the changes there, run `gh stack rebase --upstack`, then navigate back up to continue. +10. **Use `gh stack link` for external tool workflows.** When branches are managed by an external tool (jj, Sapling, etc.), use `gh stack link branch-a branch-b`. `link` does not rely on local tracking state and is intended for API-driven PR and stack management. Always provide at least 2 branch names or PR numbers. **Never do any of the following — each triggers an interactive prompt or TUI that will hang:** - ❌ `gh stack view` or `gh stack view --short` — always use `gh stack view --json` @@ -555,6 +556,54 @@ gh stack submit --auto --draft --- +### Link branches as a stack (no local tracking) — `gh stack link` + +Link PRs into a stack on GitHub without creating any local tracking state. This is the recommended approach if you are managing stacked branches with other tools (jj, Sapling, git-town) and want to simply create GitHub Stacked PRs via an API. + +``` +gh stack link [flags] [...] +``` + +```bash +# Link branches into a stack (pushes, creates PRs, creates stack) +gh stack link branch-a branch-b branch-c + +# Use a different base branch and create PRs as drafts +gh stack link --base develop --draft branch-a branch-b branch-c + +# Link existing PRs by number +gh stack link 10 20 30 + +# Add branches to an existing stack of PRs +gh stack link 42 43 feature-auth feature-ui +``` + +| Flag | Description | +|------|---------| +| `--base ` | Base branch for the bottom of the stack (default: `main`) | +| `--draft` | Create new PRs as drafts | +| `--remote ` | Remote to push to (use if multiple remotes exist) | + +**Behavior:** + +- Arguments are provided in stack order (bottom to top) +- Each argument can be a branch name or a PR number. Numeric arguments are tried as PR numbers first; if no PR with that number exists, the argument is treated as a branch name +- Branch arguments are pushed to the remote automatically (non-force, atomic) +- For branches without open PRs, new PRs are created with auto-generated titles and the correct base branch chaining (first branch uses `--base`, subsequent branches use the previous branch) +- Existing PRs whose base branch doesn't match the expected chain are corrected automatically +- If the PRs are not yet in a stack, a new stack is created. If some PRs are already in a stack, the stack is updated (additive only — existing PRs are never removed) +- Does **not** create or modify any local state + +**Output (stderr):** + +- `Pushing N branches to ...` +- `Found PR #N for branch ` for branches with existing PRs +- `Created PR #N for (base: )` for newly created PRs +- `Updated base branch for PR #N to ` when base branches are corrected +- `Created stack with N PRs` or `Updated stack to N PRs` + +--- + ### Sync the stack — `gh stack sync` Fetch, rebase, push, and sync PR state in a single command. This is the recommended command for routine synchronization. From 7af17b55dc256856b6581f45275d360c77f03356 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 20 Apr 2026 10:17:06 -0400 Subject: [PATCH 13/13] switch command (#51) * switch cmd to interactively switch to another branch in the stack * add switch to docs * addressing review comments * bump skill file version * default to current branch --- README.md | 2 + cmd/root.go | 1 + cmd/switch.go | 99 +++++++++ cmd/switch_test.go | 273 +++++++++++++++++++++++++ docs/src/content/docs/reference/cli.md | 24 +++ internal/config/config.go | 4 + skills/gh-stack/SKILL.md | 2 +- 7 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 cmd/switch.go create mode 100644 cmd/switch_test.go diff --git a/README.md b/README.md index 0a226d1a..445f69cc 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,7 @@ gh stack up [n] # Move up n branches (default 1) gh stack down [n] # Move down n branches (default 1) gh stack top # Jump to the top of the stack gh stack bottom # Jump to the bottom of the stack +gh stack switch # Interactively pick a branch to switch to ``` Navigation commands clamp to the bounds of the stack — moving up from the top or down from the bottom is a no-op with a message. If you're on the trunk branch, `up` moves to the first stack branch. @@ -430,6 +431,7 @@ gh stack up 3 # move up three layers gh stack down gh stack top gh stack bottom +gh stack switch # shows an interactive picker ``` ### `gh stack feedback` diff --git a/cmd/root.go b/cmd/root.go index b29a23d9..6e17b7c8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -48,6 +48,7 @@ func RootCmd() *cobra.Command { root.AddCommand(DownCmd(cfg)) root.AddCommand(TopCmd(cfg)) root.AddCommand(BottomCmd(cfg)) + root.AddCommand(SwitchCmd(cfg)) // Alias root.AddCommand(AliasCmd(cfg)) diff --git a/cmd/switch.go b/cmd/switch.go new file mode 100644 index 00000000..3e7cbcdf --- /dev/null +++ b/cmd/switch.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + + "github.com/cli/go-gh/v2/pkg/prompter" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/spf13/cobra" +) + +func SwitchCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "switch", + Short: "Interactively switch to another branch in the stack", + Long: `Show an interactive picker listing all branches in the current +stack and switch to the selected one. + +Branches are displayed from top (furthest from trunk) to bottom +(closest to trunk) with their position number.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runSwitch(cfg) + }, + } +} + +func runSwitch(cfg *config.Config) error { + result, err := loadStack(cfg, "") + if err != nil { + return ErrNotInStack + } + s := result.Stack + + if len(s.Branches) == 0 { + cfg.Errorf("stack has no branches") + return ErrNotInStack + } + + if !cfg.IsInteractive() { + cfg.Errorf("switch requires an interactive terminal") + return ErrSilent + } + + // Build options in reverse order (top of stack first) with 1-based numbering. + n := len(s.Branches) + options := make([]string, n) + currentBranch := result.CurrentBranch + var defaultOpt string + for i := 0; i < n; i++ { + branchIdx := n - 1 - i + options[i] = fmt.Sprintf("%d. %s", branchIdx+1, s.Branches[branchIdx].Branch) + if s.Branches[branchIdx].Branch == currentBranch { + defaultOpt = options[i] + } + } + + var selectFn func(prompt, def string, opts []string) (int, error) + if cfg.SelectFn != nil { + selectFn = cfg.SelectFn + } else { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + selectFn = func(prompt, def string, opts []string) (int, error) { + return p.Select(prompt, def, opts) + } + } + + selected, err := selectFn("Select a branch in the stack to switch to:", defaultOpt, options) + if err != nil { + if isInterruptError(err) { + clearSelectPrompt(cfg, len(options)) + printInterrupt(cfg) + return errInterrupt + } + cfg.Errorf("failed to select branch: %v", err) + return ErrSilent + } + + if selected < 0 || selected >= n { + cfg.Errorf("invalid selection") + return ErrSilent + } + + // Map selection back: index 0 in options = branch at n-1, etc. + branchIdx := n - 1 - selected + targetBranch := s.Branches[branchIdx].Branch + if targetBranch == currentBranch { + cfg.Infof("Already on %s", targetBranch) + return nil + } + + if err := git.CheckoutBranch(targetBranch); err != nil { + cfg.Errorf("failed to checkout %s: %v", targetBranch, err) + return ErrSilent + } + + cfg.Successf("Switched to %s", targetBranch) + return nil +} diff --git a/cmd/switch_test.go b/cmd/switch_test.go new file mode 100644 index 00000000..3b97588d --- /dev/null +++ b/cmd/switch_test.go @@ -0,0 +1,273 @@ +package cmd + +import ( + "io" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSwitch_SwitchesToSelectedBranch(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + + // Simulate selecting the first option (index 0) which is "3. b3" (top of stack) + cfg.SelectFn = func(prompt, def string, options []string) (int, error) { + // Verify prompt text + assert.Equal(t, "Select a branch in the stack to switch to:", prompt) + // Verify options are in reverse order with numbering + assert.Equal(t, []string{"3. b3", "2. b2", "1. b1"}, options) + return 0, nil // select "3. b3" + } + + err := runSwitch(cfg) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "b3", checkedOut) + assert.Contains(t, output, "Switched to b3") +} + +func TestSwitch_SelectMiddleBranch(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + + // Select index 1 which is "2. b2" + cfg.SelectFn = func(prompt, def string, options []string) (int, error) { + return 1, nil // select "2. b2" + } + + err := runSwitch(cfg) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "b2", checkedOut) + assert.Contains(t, output, "Switched to b2") +} + +func TestSwitch_AlreadyOnSelectedBranch(t *testing.T) { + gitDir := t.TempDir() + checkoutCalled := false + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b2", nil }, + CheckoutBranchFn: func(name string) error { + checkoutCalled = true + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + + // Select "2. b2" which is option index 1 — the branch we're already on + cfg.SelectFn = func(prompt, def string, options []string) (int, error) { + return 1, nil // select "2. b2" + } + + err := runSwitch(cfg) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.False(t, checkoutCalled, "CheckoutBranch should not be called when already on target") + assert.Contains(t, output, "Already on b2") +} + +func TestSwitch_NotInStack(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "orphan", nil }, + }) + defer restore() + + // Write a stack that doesn't contain "orphan" + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + + err := runSwitch(cfg) + assert.ErrorIs(t, err, ErrNotInStack) +} + +func TestSwitch_NonInteractive(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + // ForceInteractive not set — non-interactive mode + + err := runSwitch(cfg) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrSilent) + assert.Contains(t, output, "switch requires an interactive terminal") +} + +func TestSwitch_DisplayOrder(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "first", nil }, + CheckoutBranchFn: func(name string) error { + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "first"}, + {Branch: "second"}, + {Branch: "third"}, + {Branch: "fourth"}, + {Branch: "fifth"}, + }, + }) + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + + var capturedOptions []string + cfg.SelectFn = func(prompt, def string, options []string) (int, error) { + capturedOptions = options + return 0, nil // select top + } + + err := runSwitch(cfg) + require.NoError(t, err) + + expected := []string{ + "5. fifth", + "4. fourth", + "3. third", + "2. second", + "1. first", + } + assert.Equal(t, expected, capturedOptions) +} + +func TestSwitch_NoBranches(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{}, + }) + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + + err := runSwitch(cfg) + assert.ErrorIs(t, err, ErrNotInStack) +} + +func TestSwitch_CmdIntegration(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + }) + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.SelectFn = func(prompt, def string, options []string) (int, error) { + return 0, nil // select top + } + + cmd := SwitchCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + assert.NoError(t, err) +} diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index f54dee95..ba213f56 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -410,6 +410,30 @@ gh stack bottom Checks out the branch closest to the trunk. +### `gh stack switch` + +Interactively switch to another branch in the stack. + +```sh +gh stack switch +``` + +Shows an interactive picker listing all branches in the current stack, ordered from top (furthest from trunk) to bottom (closest to trunk) with their position number. Select a branch to check it out. + +Requires an interactive terminal. + +**Examples:** + +```sh +gh stack switch +# → Select a branch in the stack to switch to +# 5. frontend +# 4. api-endpoints +# 3. auth-layer +# 2. db-schema +# 1. config-setup +``` + --- ## Utilities diff --git a/internal/config/config.go b/internal/config/config.go index f254e568..e706dd06 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,10 @@ type Config struct { // ForceInteractive, when true, makes IsInteractive() return true // regardless of the terminal state. Used in tests. ForceInteractive bool + + // SelectFn, when non-nil, is called instead of prompting via the + // terminal. Used in tests to simulate interactive selection. + SelectFn func(prompt, defaultValue string, options []string) (int, error) } // New creates a new Config with terminal-aware output and color support. diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 0bff246a..0717ddea 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -7,7 +7,7 @@ description: > branch chains, or incremental code review workflows. metadata: author: github - version: "0.0.1" + version: "0.0.2" --- # gh-stack