From 05de7e19b3896b3e5d69b4ddb96ecb5a88f4d840 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 17:24:06 -0400 Subject: [PATCH 1/7] Bump astro in /docs in the npm_and_yarn group across 1 directory (#86) Bumps the npm_and_yarn group with 1 update in the /docs directory: [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro). Updates `astro` from 6.1.8 to 6.3.1 - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/astro@6.3.1/packages/astro) --- updated-dependencies: - dependency-name: astro dependency-version: 6.3.1 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 143 ++++++++++++++++++++++------------------- docs/package.json | 2 +- 2 files changed, 79 insertions(+), 66 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 1ad426fd..64e7efb5 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,14 +9,14 @@ "version": "0.0.1", "dependencies": { "@astrojs/starlight": "^0.38.2", - "astro": "^6.1.8", + "astro": "^6.3.1", "sharp": "^0.34.5" } }, "node_modules/@astrojs/compiler": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-3.0.1.tgz", - "integrity": "sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-4.0.0.tgz", + "integrity": "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==", "license": "MIT" }, "node_modules/@astrojs/internal-helpers": { @@ -146,13 +146,12 @@ } }, "node_modules/@astrojs/telemetry": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.1.tgz", - "integrity": "sha512-7fcIxXS9J4ls5tr8b3ww9rbAIz2+HrhNJYZdkAhhB4za/I5IZ/60g+Bs8q7zwG0tOIZfNB4JWhVJ1Qkl/OrNCw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz", + "integrity": "sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==", "license": "MIT", "dependencies": { "ci-info": "^4.4.0", - "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^4.0.0", "is-wsl": "^3.1.1", @@ -2042,15 +2041,15 @@ } }, "node_modules/astro": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/astro/-/astro-6.1.8.tgz", - "integrity": "sha512-6fT9M12U3fpi13DiPavNKDIoBflASTSxmKTEe+zXhWtlebQuOqfOnIrMWyRmlXp+mgDsojmw+fVFG9LUTzKSog==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.3.1.tgz", + "integrity": "sha512-atz6dmkE3Gu24bDgb7g2RE/BYnKqPYIHd6hTUM1UXvu/i7qNZOKLAqEHvgYpv9PQVcgWsXpk4/OOXZ0E/FzvSQ==", "license": "MIT", "dependencies": { - "@astrojs/compiler": "^3.0.1", - "@astrojs/internal-helpers": "0.8.0", - "@astrojs/markdown-remark": "7.1.0", - "@astrojs/telemetry": "3.3.1", + "@astrojs/compiler": "^4.0.0", + "@astrojs/internal-helpers": "0.9.0", + "@astrojs/markdown-remark": "7.1.1", + "@astrojs/telemetry": "3.3.2", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", @@ -2068,10 +2067,12 @@ "esbuild": "^0.27.3", "flattie": "^1.1.1", "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", @@ -2081,7 +2082,7 @@ "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", - "picomatch": "^4.0.3", + "picomatch": "^4.0.4", "rehype": "^13.0.2", "semver": "^7.7.4", "shiki": "^4.0.2", @@ -2090,13 +2091,12 @@ "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", - "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unist-util-visit": "^5.1.0", - "unstorage": "^1.17.4", + "unstorage": "^1.17.5", "vfile": "^6.0.3", - "vite": "^7.3.1", + "vite": "^7.3.2", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", @@ -2130,13 +2130,22 @@ "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta" } }, + "node_modules/astro/node_modules/@astrojs/internal-helpers": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.9.0.tgz", + "integrity": "sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg==", + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.4" + } + }, "node_modules/astro/node_modules/@astrojs/markdown-remark": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.0.tgz", - "integrity": "sha512-P+HnCsu2js3BoTc8kFmu+E9gOcFeMdPris75g+Zl4sY8+bBRbSQV6xzcBDbZ27eE7yBGEGQoqjpChx+KJYIPYQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.1.tgz", + "integrity": "sha512-C6e9BnLGlbdv6bV8MYGeHpHxsUHrCrB4OuRLqi5LI7oiBVcBcqfUN06zpwFQdHgV48QCCrMmLpyqBr7VqC+swA==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.8.0", + "@astrojs/internal-helpers": "0.9.0", "@astrojs/prism": "4.0.1", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", @@ -2350,9 +2359,9 @@ } }, "node_modules/cookie-es": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", - "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, "node_modules/crossws": { @@ -2567,12 +2576,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2928,6 +2931,21 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -2935,14 +2953,14 @@ "license": "ISC" }, "node_modules/h3": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.10.tgz", - "integrity": "sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", "license": "MIT", "dependencies": { - "cookie-es": "^1.2.2", + "cookie-es": "^1.2.3", "crossws": "^0.3.5", - "defu": "^6.1.4", + "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", @@ -3517,6 +3535,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/klona": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", @@ -3537,9 +3561,9 @@ } }, "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -5334,6 +5358,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/retext": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", @@ -5711,26 +5744,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/tsconfck": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", - "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", - "license": "MIT", - "bin": { - "tsconfck": "bin/tsconfck.js" - }, - "engines": { - "node": "^18 || >=20" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5929,16 +5942,16 @@ } }, "node_modules/unstorage": { - "version": "1.17.4", - "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.4.tgz", - "integrity": "sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==", + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", "license": "MIT", "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", - "h3": "^1.15.5", - "lru-cache": "^11.2.0", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" diff --git a/docs/package.json b/docs/package.json index 3fe467b6..48879de9 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.38.2", - "astro": "^6.1.8", + "astro": "^6.3.1", "sharp": "^0.34.5" } } From d159b80057c602ffbf62d5026281ec78e9fbb1b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 18:54:24 -0400 Subject: [PATCH 2/7] Bump devalue in /docs in the npm_and_yarn group across 1 directory (#90) Bumps the npm_and_yarn group with 1 update in the /docs directory: [devalue](https://github.com/sveltejs/devalue). Updates `devalue` from 5.6.4 to 5.8.1 - [Release notes](https://github.com/sveltejs/devalue/releases) - [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md) - [Commits](https://github.com/sveltejs/devalue/compare/v5.6.4...v5.8.1) --- updated-dependencies: - dependency-name: devalue dependency-version: 5.8.1 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 64e7efb5..35da788b 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -2536,9 +2536,9 @@ } }, "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, "node_modules/devlop": { From 8a9f1c86f84750d266b7fc0e7d3086f72866d2d8 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 14:01:02 -0400 Subject: [PATCH 3/7] commands help text (#88) * more help text for commands * improved root help * fix typo Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * updated examples for add cmd Co-authored-by: Sameen Karim --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cmd/add.go | 11 ++ cmd/alias.go | 8 ++ cmd/checkout.go | 10 ++ cmd/feedback.go | 5 + cmd/link.go | 8 ++ cmd/modify.go | 14 ++- cmd/navigate.go | 26 ++++- cmd/push.go | 12 +++ cmd/rebase.go | 14 +++ cmd/root.go | 140 ++++++++++++++++++++----- cmd/root_test.go | 20 ++++ cmd/submit.go | 18 ++++ cmd/switch.go | 14 ++- cmd/unstack.go | 7 +- cmd/view.go | 18 ++++ docs/src/content/docs/reference/cli.md | 104 +++++++++--------- 16 files changed, 339 insertions(+), 90 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index 0fdd1ab9..2ced4896 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -30,6 +30,17 @@ When -m is omitted but -A or -u is used, your editor opens for the commit message. When -m is provided without an explicit branch name, the branch name is auto-generated based on the commit message and stack prefix.`, + Example: ` # Add a new named branch to the stack + $ gh stack add my-feature + + # Add a branch and commit staged changes + $ gh stack add -Am "Add user authentication" my-feature + + # Auto-generate branch name from the commit message + $ gh stack add -m "Fix login bug" + + # Add a branch and open editor to write commit message + $ gh stack add -A my-feature`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runAdd(cfg, opts, args) diff --git a/cmd/alias.go b/cmd/alias.go index a3ecf8f8..cb82765c 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -32,6 +32,14 @@ func AliasCmd(cfg *config.Config) *cobra.Command { This installs a small wrapper script into ~/.local/bin/ that forwards all arguments to "gh stack". The default alias name is "gs", but you can choose any name by passing it as an argument.`, + Example: ` # Create the default 'gs' alias + $ gh stack alias + + # Create a custom alias + $ gh stack alias gst + + # Remove alias + $ gh stack alias --remove`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := defaultAliasName diff --git a/cmd/checkout.go b/cmd/checkout.go index d66087c1..31537ac2 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -38,6 +38,14 @@ locally tracked stacks only. When run without arguments, shows a menu of all locally available stacks to choose from.`, + Example: ` # Check out a stack by PR number + $ gh stack checkout 42 + + # Check out a stack by branch name + $ gh stack checkout feat/api-routes + + # Show a menu of all locally tracked stacks + $ gh stack checkout`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -114,6 +122,8 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { cfg.Successf("Switched to %s", targetBranch) cfg.Printf("Stack: %s", s.DisplayChain()) + cfg.Printf("Run `%s` to see the full stack", + cfg.ColorCyan("gh stack view")) return nil } diff --git a/cmd/feedback.go b/cmd/feedback.go index f1e88b3f..b55c056d 100644 --- a/cmd/feedback.go +++ b/cmd/feedback.go @@ -19,6 +19,11 @@ func FeedbackCmd(cfg *config.Config) *cobra.Command { Use: "feedback [title]", Short: "Submit feedback for gh-stack", Long: "Opens a GitHub Discussion in the gh-stack repository to submit feedback. Optionally provide a title for the discussion post.", + Example: ` # Open the feedback form in your browser + $ gh stack feedback + + # Open with a pre-filled title + $ gh stack feedback "My feature request"`, RunE: func(cmd *cobra.Command, args []string) error { return runFeedback(cfg, args) }, diff --git a/cmd/link.go b/cmd/link.go index 93bb7498..1367e516 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -45,6 +45,14 @@ 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).`, + Example: ` # Link branches into a stack (bottom to top) + $ gh stack link auth-layer api-routes ui-components + + # Link existing PRs by number + $ gh stack link 41 42 43 + + # Specify a custom base branch for stack + $ gh stack link --base develop auth-layer api-routes`, Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { return runLink(cfg, opts, args) diff --git a/cmd/modify.go b/cmd/modify.go index 6c25c966..4e1456c5 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -34,6 +34,14 @@ Operations available: All changes are staged in the TUI and applied together when you press Ctrl+S. After applying, run 'gh stack submit' to push changes and recreate the stack on GitHub.`, + Example: ` # Open the interactive TUI to restructure the stack + $ gh stack modify + + # Abort a modify session and restore the stack + $ gh stack modify --abort + + # Continue after resolving conflicts from a modify + $ gh stack modify --continue`, RunE: func(cmd *cobra.Command, args []string) error { if opts.abort { return runModifyAbort(cfg) @@ -202,9 +210,9 @@ func runModifyAbort(cfg *config.Config) error { if err := modify.UnwindFromStateFile(cfg, gitDir); err != nil { cfg.Errorf("recovery failed: %s", err) cfg.Printf("The stack may be in an inconsistent state.") - cfg.Printf("Try `%s` to fix, or `%s` + `%s` to recreate.", - cfg.ColorCyan("gh stack rebase"), cfg.ColorCyan("gh stack unstack --local"), - cfg.ColorCyan("gh stack init --adopt")) + cfg.Printf("Try `%s` to fix, or `%s` + `%s` to recreate.", + cfg.ColorCyan("gh stack rebase"), cfg.ColorCyan("gh stack unstack --local"), + cfg.ColorCyan("gh stack init --adopt")) return ErrSilent } cfg.Successf("Stack restored successfully") diff --git a/cmd/navigate.go b/cmd/navigate.go index aad9c7bb..fe0fe535 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -12,7 +12,14 @@ func UpCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "up [n]", Short: "Check out a branch further up in the stack (further from the trunk)", - Args: cobra.MaximumNArgs(1), + Long: `Check out a branch further up in the stack (further from the trunk). +Merged branches are automatically skipped.`, + Example: ` # Move one branch up + $ gh stack up + + # Move three branches up + $ gh stack up 3`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { n := 1 if len(args) > 0 { @@ -32,7 +39,14 @@ func DownCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "down [n]", Short: "Check out a branch further down in the stack (closer to the trunk)", - Args: cobra.MaximumNArgs(1), + Long: `Check out a branch further down in the stack (closer to the trunk). +Merged branches are automatically skipped.`, + Example: ` # Move one branch down + $ gh stack down + + # Move two branches down + $ gh stack down 2`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { n := 1 if len(args) > 0 { @@ -52,6 +66,10 @@ func TopCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "top", Short: "Check out the top branch of the stack (furthest from the trunk)", + Long: `Check out the top branch of the stack (furthest from the trunk). +Merged branches are automatically skipped.`, + Example: ` # Jump to the top of the stack + $ gh stack top`, RunE: func(cmd *cobra.Command, args []string) error { return runNavigateToEnd(cfg, true) }, @@ -62,6 +80,10 @@ func BottomCmd(cfg *config.Config) *cobra.Command { return &cobra.Command{ Use: "bottom", Short: "Check out the bottom branch of the stack (closest to the trunk)", + Long: `Check out the bottom branch of the stack (closest to the trunk). +Merged branches are automatically skipped.`, + Example: ` # Jump to the bottom of the stack + $ gh stack bottom`, RunE: func(cmd *cobra.Command, args []string) error { return runNavigateToEnd(cfg, false) }, diff --git a/cmd/push.go b/cmd/push.go index 40c63e7a..2d6d088e 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -22,6 +22,16 @@ func PushCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "push", Short: "Push all branches in the current stack to the remote", + Long: `Push all branches in the current stack to the remote. + +Uses --force-with-lease and --atomic to ensure safe, all-or-nothing pushes. +Merged and queued branches are automatically skipped. This command is safe to +run repeatedly — it will only update branches that have changed.`, + Example: ` # Push all stack branches to the default remote + $ gh stack push + + # Push to a specific remote + $ gh stack push --remote upstream`, RunE: func(cmd *cobra.Command, args []string) error { return runPush(cfg, opts) }, @@ -123,6 +133,8 @@ func runPush(cfg *config.Config, opts *pushOptions) error { if hasBranchWithoutPR { cfg.Printf("To create PRs for this stack, run `%s`", cfg.ColorCyan("gh stack submit")) + } else { + cfg.Printf("Run `%s` to see your stack of PRs", cfg.ColorCyan("gh stack view")) } return nil } diff --git a/cmd/rebase.go b/cmd/rebase.go index 4e16158c..b6bcb9e0 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -46,6 +46,20 @@ func RebaseCmd(cfg *config.Config) *cobra.Command { Ensures that each branch in the stack has the tip of the previous layer in its commit history, rebasing if necessary.`, + Example: ` # Rebase the entire stack + $ gh stack rebase + + # Only rebase from trunk to the current branch + $ gh stack rebase --downstack + + # Only rebase from current branch to the top + $ gh stack rebase --upstack + + # Continue after resolving conflicts + $ gh stack rebase --continue + + # Abort and restore all branches + $ gh stack rebase --abort`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { diff --git a/cmd/root.go b/cmd/root.go index c566aba9..217016dc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,9 +13,25 @@ func RootCmd() *cobra.Command { cfg := config.New() root := &cobra.Command{ - Use: "stack ", - Short: "Manage stacked branches and pull requests", - Long: "Create, navigate, and manage stacks of branches and pull requests.", + Use: "stack ", + Short: "Manage stacked branches and pull requests", + Long: `Stacked PRs let you break a large change into a chain of pull requests +that build on each other. Use ` + "`gh stack`" + ` to create and manage your stack +locally, then push to GitHub to create your stack of PRs.`, + Example: ` # Start a new stack targeting your default branch + $ gh stack init + + # Or turn an existing set of branches into a stack + $ gh stack init --adopt branch1 branch2 branch3 + + # Make changes and commit, then add a branch to the stack + $ gh stack add branch4 + + # Push all branches and create/update PRs on GitHub + $ gh stack submit + + # Keep your local in sync with remote + $ gh stack sync`, Version: Version, SilenceUsage: true, SilenceErrors: true, @@ -26,36 +42,104 @@ func RootCmd() *cobra.Command { root.SetOut(cfg.Out) root.SetErr(cfg.Err) - // Local operations - root.AddCommand(InitCmd(cfg)) - root.AddCommand(AddCmd(cfg)) + root.AddGroup( + &cobra.Group{ID: "stack", Title: "Stack management:"}, + &cobra.Group{ID: "remote", Title: "Remote operations:"}, + &cobra.Group{ID: "nav", Title: "Navigation:"}, + &cobra.Group{ID: "utils", Title: "Utilities:"}, + ) + + defaultHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + defaultHelp(cmd, args) + if cmd.Name() == "stack" { + out := cmd.OutOrStderr() + fmt.Fprintln(out) + fmt.Fprintln(out, "Learn more:") + fmt.Fprintln(out, " Documentation: https://gh.io/stacks") + fmt.Fprintln(out, " Feedback: https://gh.io/stacks-feedback") + } + }) - // Remote operations - root.AddCommand(CheckoutCmd(cfg)) - root.AddCommand(PushCmd(cfg)) - root.AddCommand(SubmitCmd(cfg)) - root.AddCommand(SyncCmd(cfg)) - root.AddCommand(UnstackCmd(cfg)) - root.AddCommand(MergeCmd(cfg)) - root.AddCommand(LinkCmd(cfg)) + // Stack management commands + initCmd := InitCmd(cfg) + initCmd.GroupID = "stack" + root.AddCommand(initCmd) - // Helper commands - root.AddCommand(ViewCmd(cfg)) - root.AddCommand(RebaseCmd(cfg)) - root.AddCommand(ModifyCmd(cfg)) + addCmd := AddCmd(cfg) + addCmd.GroupID = "stack" + root.AddCommand(addCmd) - // Navigation commands - root.AddCommand(UpCmd(cfg)) - root.AddCommand(DownCmd(cfg)) - root.AddCommand(TopCmd(cfg)) - root.AddCommand(BottomCmd(cfg)) - root.AddCommand(SwitchCmd(cfg)) + viewCmd := ViewCmd(cfg) + viewCmd.GroupID = "stack" + root.AddCommand(viewCmd) + + checkoutCmd := CheckoutCmd(cfg) + checkoutCmd.GroupID = "stack" + root.AddCommand(checkoutCmd) + + modifyCmd := ModifyCmd(cfg) + modifyCmd.GroupID = "stack" + root.AddCommand(modifyCmd) - // Alias - root.AddCommand(AliasCmd(cfg)) + unstackCmd := UnstackCmd(cfg) + unstackCmd.GroupID = "stack" + root.AddCommand(unstackCmd) - // Feedback - root.AddCommand(FeedbackCmd(cfg)) + // Remote operations commands + submitCmd := SubmitCmd(cfg) + submitCmd.GroupID = "remote" + root.AddCommand(submitCmd) + + syncCmd := SyncCmd(cfg) + syncCmd.GroupID = "remote" + root.AddCommand(syncCmd) + + rebaseCmd := RebaseCmd(cfg) + rebaseCmd.GroupID = "remote" + root.AddCommand(rebaseCmd) + + pushCmd := PushCmd(cfg) + pushCmd.GroupID = "remote" + root.AddCommand(pushCmd) + + linkCmd := LinkCmd(cfg) + linkCmd.GroupID = "remote" + root.AddCommand(linkCmd) + + mergeCmd := MergeCmd(cfg) + mergeCmd.GroupID = "remote" + root.AddCommand(mergeCmd) + + // Navigation commands + switchCmd := SwitchCmd(cfg) + switchCmd.GroupID = "nav" + root.AddCommand(switchCmd) + + upCmd := UpCmd(cfg) + upCmd.GroupID = "nav" + root.AddCommand(upCmd) + + downCmd := DownCmd(cfg) + downCmd.GroupID = "nav" + root.AddCommand(downCmd) + + topCmd := TopCmd(cfg) + topCmd.GroupID = "nav" + root.AddCommand(topCmd) + + bottomCmd := BottomCmd(cfg) + bottomCmd.GroupID = "nav" + root.AddCommand(bottomCmd) + + // Utility commands + aliasCmd := AliasCmd(cfg) + aliasCmd.GroupID = "utils" + root.AddCommand(aliasCmd) + + feedbackCmd := FeedbackCmd(cfg) + feedbackCmd.GroupID = "utils" + root.AddCommand(feedbackCmd) return root } diff --git a/cmd/root_test.go b/cmd/root_test.go index b90409fd..25a68a70 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,9 +1,11 @@ package cmd import ( + "bytes" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRootCmd_SubcommandRegistration(t *testing.T) { @@ -19,3 +21,21 @@ func TestRootCmd_SubcommandRegistration(t *testing.T) { assert.True(t, registered[name], "expected subcommand %q to be registered", name) } } + +func TestRootCmd_HelpOutput(t *testing.T) { + root := RootCmd() + var stdout bytes.Buffer + var stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"--help"}) + + err := root.Execute() + require.NoError(t, err) + + output := stdout.String() + stderr.String() + assert.Contains(t, output, "Stacked PRs") + assert.Contains(t, output, "Stack management:") + assert.Contains(t, output, "Learn more:") + assert.Contains(t, output, "https://gh.io/stacks") +} diff --git a/cmd/submit.go b/cmd/submit.go index 3c8397e3..86e02edb 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -29,6 +29,24 @@ func SubmitCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "submit", Short: "Create a stack of PRs on GitHub", + Long: `Push all branches and create or update a stack of PRs on GitHub. + +This command performs several steps: + 1. Pushes all branches to the remote + 2. Creates new PRs for branches that don't have one + 3. Updates base branches for existing PRs + 4. Creates or updates the stack on GitHub + +New PRs are created as drafts by default. Use --open to mark them as ready +for review.`, + Example: ` # Push and create/update PRs (prompts for PR titles) + $ gh stack submit + + # Use auto-generated PR titles without prompting + $ gh stack submit --auto + + # Mark all PRs as ready for review + $ gh stack submit --open`, RunE: func(cmd *cobra.Command, args []string) error { return runSubmit(cfg, opts) }, diff --git a/cmd/switch.go b/cmd/switch.go index 3e7cbcdf..4e3c9c15 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -13,11 +13,17 @@ 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. + 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.`, +Branches are displayed from top (furthest from trunk) to bottom (closest to +trunk) with their position number. Use the arrow keys to navigate and Enter +to select. + +To move one branch up or down without an interactive picker, use +'gh stack up' or 'gh stack down' instead.`, + Example: ` # Open the branch picker for the current stack + $ gh stack switch`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runSwitch(cfg) diff --git a/cmd/unstack.go b/cmd/unstack.go index 67762fcc..3bdf40e4 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -22,7 +22,12 @@ func UnstackCmd(cfg *config.Config) *cobra.Command { Aliases: []string{"delete"}, Short: "Delete a stack locally and on GitHub", Long: "Remove the current active stack from local tracking and delete it on GitHub. Use --local to only remove local tracking.", - Args: cobra.NoArgs, + Example: ` # Delete the stack locally and on GitHub + $ gh stack unstack + + # Only remove local tracking (keep the stack on GitHub) + $ gh stack unstack --local`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runUnstack(cfg, opts) }, diff --git a/cmd/view.go b/cmd/view.go index 5cfe8f8e..2613f05d 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -29,6 +29,24 @@ func ViewCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "view", Short: "View the current stack", + Long: `View the current stack as a list showing branches and PR status. + +Status icons: + ✓ PR merged + ◎ PR queued + ○ PR open + ⚠ Needs rebase + +The current branch is highlighted. Use --short for a compact one-line-per-branch +view, or --json for machine-readable output.`, + Example: ` # Show the stack (default interactive view) + $ gh stack view + + # Show compact output + $ gh stack view --short + + # Output as JSON + $ gh stack view --json`, RunE: func(cmd *cobra.Command, args []string) error { return runView(cfg, opts) }, diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index d3d40b77..f2bac852 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -223,6 +223,34 @@ gh stack modify --continue gh stack modify --abort ``` +### `gh stack unstack` + +Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. + +```sh +gh stack unstack [flags] +``` + +You must have a branch from the stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. + +Deletes the stack on GitHub first, if it exists, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking. + +This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init --adopt` to re-create the stack with the desired structure. + +| Flag | Description | +|------|-------------| +| `--local` | Only delete the stack locally (keep it on GitHub) | + +**Examples:** + +```sh +# Delete the stack on GitHub and remove local tracking +gh stack unstack + +# Only remove local tracking +gh stack unstack --local +``` + --- ## Remote Operations @@ -345,34 +373,6 @@ gh stack push gh stack push --remote upstream ``` -### `gh stack unstack` - -Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. - -```sh -gh stack unstack [flags] -``` - -You must have a branch from the stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. - -Deletes the stack on GitHub first, if it exists, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking. - -This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init --adopt` to re-create the stack with the desired structure. - -| Flag | Description | -|------|-------------| -| `--local` | Only delete the stack locally (keep it on GitHub) | - -**Examples:** - -```sh -# Delete the stack on GitHub and remove local tracking -gh stack unstack - -# Only remove local tracking -gh stack unstack --local -``` - ### `gh stack link` Link PRs into a stack on GitHub without local tracking. @@ -417,6 +417,30 @@ Move between branches in the current stack without having to remember branch nam All 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. +### `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 +``` + ### `gh stack up` Move up toward the top of the stack (away from trunk). @@ -471,30 +495,6 @@ 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 From 00a9589feb76dacae065afdd5460102dc97015ca Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 14:01:03 -0400 Subject: [PATCH 4/7] rm merge command (#89) --- README.md | 12 -- cmd/merge.go | 113 --------------- cmd/merge_test.go | 354 ---------------------------------------------- cmd/root.go | 4 - cmd/root_test.go | 2 +- 5 files changed, 1 insertion(+), 484 deletions(-) delete mode 100644 cmd/merge.go delete mode 100644 cmd/merge_test.go diff --git a/README.md b/README.md index d1eb3648..af1f849a 100644 --- a/README.md +++ b/README.md @@ -454,18 +454,6 @@ gh stack unstack gh stack unstack --local ``` -### `gh stack merge` - -Merge a stack of PRs. - -``` -gh stack merge -``` - -Merges the specified PR and all PRs below it in the stack. - -> **Note:** This command is not yet implemented. Running it prints a notice. - ### Navigation Move between branches in the current stack without having to remember branch names. diff --git a/cmd/merge.go b/cmd/merge.go deleted file mode 100644 index dc314ce2..00000000 --- a/cmd/merge.go +++ /dev/null @@ -1,113 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/cli/go-gh/v2/pkg/browser" - "github.com/cli/go-gh/v2/pkg/prompter" - "github.com/github/gh-stack/internal/config" - "github.com/github/gh-stack/internal/stack" - "github.com/spf13/cobra" -) - -func MergeCmd(cfg *config.Config) *cobra.Command { - cmd := &cobra.Command{ - Use: "merge []", - Short: "Merge a stack of PRs", - Long: `Merges the specified PR and all PRs below it in the stack. - -Accepts a PR URL, PR number, or branch name. When run without -arguments, operates on the current branch's PR.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var target string - if len(args) > 0 { - target = args[0] - } - return runMerge(cfg, target) - }, - } - - return cmd -} - -func runMerge(cfg *config.Config, target string) error { - // Standard stack loading and validation. - result, err := loadStack(cfg, "") - if err != nil { - return ErrNotInStack - } - s := result.Stack - currentBranch := result.CurrentBranch - - // Sync PR state from GitHub so merge status is up to date. - _ = syncStackPRs(cfg, s) - - // Persist the refreshed PR state. - stack.SaveNonBlocking(result.GitDir, result.StackFile) - - // Resolve which branch to operate on. - var br *stack.BranchRef - if target != "" { - _, br, err = resolvePR(cfg, result.StackFile, target) - if err != nil { - cfg.Errorf("%s", err) - return ErrNotInStack - } - } else { - idx := s.IndexOf(currentBranch) - if idx < 0 { - if s.IsFullyMerged() { - cfg.Successf("All PRs in this stack have already been merged") - return nil - } - cfg.Errorf("current branch %q is not a stack branch (it may be the trunk)", currentBranch) - return ErrNotInStack - } - br = &s.Branches[idx] - } - - if br.PullRequest == nil { - cfg.Errorf("no pull request found for branch %q", br.Branch) - cfg.Printf(" Run %s to create PRs for this stack.", cfg.ColorCyan("gh stack submit")) - return ErrSilent - } - - if br.IsMerged() { - cfg.Successf("PR %s has already been merged", cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) - cfg.Printf(" %s", br.PullRequest.URL) - return nil - } - - prURL := br.PullRequest.URL - prLink := cfg.PRLink(br.PullRequest.Number, prURL) - - cfg.Warningf("Merging stacked PRs from the CLI is not yet supported") - - if cfg.IsInteractive() { - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - openWeb, promptErr := p.Confirm( - fmt.Sprintf("Open %s in your browser?", prLink), true) - if promptErr != nil { - if isInterruptError(promptErr) { - printInterrupt(cfg) - return nil - } - cfg.Errorf("prompt failed: %s", promptErr) - return nil - } - - if openWeb { - b := browser.New("", cfg.Out, cfg.Err) - if err := b.Browse(prURL); err != nil { - cfg.Warningf("failed to open browser: %s", err) - } else { - cfg.Successf("Opened %s in your browser", prLink) - return nil - } - } - } - - cfg.Printf(" You can merge this PR at: %s", prURL) - return nil -} diff --git a/cmd/merge_test.go b/cmd/merge_test.go deleted file mode 100644 index 3a5e2824..00000000 --- a/cmd/merge_test.go +++ /dev/null @@ -1,354 +0,0 @@ -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/github" - "github.com/github/gh-stack/internal/stack" - "github.com/stretchr/testify/assert" -) - -func newMergeMock(tmpDir, currentBranch string) *git.MockOps { - return &git.MockOps{ - GitDirFn: func() (string, error) { return tmpDir, nil }, - CurrentBranchFn: func() (string, error) { return currentBranch, nil }, - } -} - -func TestMerge_NoPullRequest(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "feat-1"}, - }, - } - - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) - defer restore() - - cfg, _, errR := config.NewTestConfig() - cfg.GitHubClientOverride = &github.MockClient{} - cmd := MergeCmd(cfg) - 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, ErrSilent) - assert.Contains(t, output, "no pull request found") - assert.Contains(t, output, "gh stack submit") -} - -func TestMerge_AlreadyMerged(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ - Number: 42, - URL: "https://github.com/owner/repo/pull/42", - Merged: true, - }}, - }, - } - - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) - defer restore() - - cfg, _, errR := config.NewTestConfig() - cfg.GitHubClientOverride = &github.MockClient{} - cmd := MergeCmd(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, "already been merged") - assert.Contains(t, output, "https://github.com/owner/repo/pull/42") -} - -func TestMerge_FullyMergedStack(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ - Number: 10, - URL: "https://github.com/owner/repo/pull/10", - Merged: true, - }}, - {Branch: "feat-2", PullRequest: &stack.PullRequestRef{ - Number: 11, - URL: "https://github.com/owner/repo/pull/11", - Merged: true, - }}, - }, - } - - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - // On trunk with all PRs merged → fully merged message. - restore := git.SetOps(newMergeMock(tmpDir, "main")) - defer restore() - - cfg, _, errR := config.NewTestConfig() - cfg.GitHubClientOverride = &github.MockClient{} - cmd := MergeCmd(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, "All PRs in this stack have already been merged") -} - -func TestMerge_OnTrunk(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ - Number: 42, - URL: "https://github.com/owner/repo/pull/42", - }}, - }, - } - - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - // Current branch is trunk, not a stack branch. - restore := git.SetOps(newMergeMock(tmpDir, "main")) - defer restore() - - cfg, _, errR := config.NewTestConfig() - cfg.GitHubClientOverride = &github.MockClient{} - cmd := MergeCmd(cfg) - 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, ErrNotInStack) - assert.Contains(t, output, "not a stack branch") -} - -func TestMerge_NonInteractive_PrintsURL(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ - Number: 42, - URL: "https://github.com/owner/repo/pull/42", - }}, - }, - } - - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) - defer restore() - - // 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) - err := cmd.Execute() - - cfg.Err.Close() - errOut, _ := io.ReadAll(errR) - output := string(errOut) - - assert.NoError(t, err) - assert.Contains(t, output, "https://github.com/owner/repo/pull/42") -} - -func TestMerge_NoArgs(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "feat-1"}, - }, - } - - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) - defer restore() - - cfg, _, _ := config.NewTestConfig() - cfg.GitHubClientOverride = &github.MockClient{} - cmd := MergeCmd(cfg) - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - cmd.SetArgs([]string{"extra-arg", "another"}) - err := cmd.Execute() - - // MaximumNArgs(1) should reject two positional arguments. - assert.Error(t, err) -} - -func TestMerge_ByPRNumber(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ - Number: 42, - URL: "https://github.com/owner/repo/pull/42", - }}, - {Branch: "feat-2", PullRequest: &stack.PullRequestRef{ - Number: 43, - URL: "https://github.com/owner/repo/pull/43", - }}, - }, - } - - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - // Current branch is feat-2, but we target PR #42 (feat-1) via arg. - restore := git.SetOps(newMergeMock(tmpDir, "feat-2")) - 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) - cmd.SetArgs([]string{"42"}) - err := cmd.Execute() - - cfg.Err.Close() - errOut, _ := io.ReadAll(errR) - output := string(errOut) - - assert.NoError(t, err) - assert.Contains(t, output, "https://github.com/owner/repo/pull/42") -} - -func TestMerge_ByPRURL(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ - Number: 42, - URL: "https://github.com/owner/repo/pull/42", - }}, - }, - } - - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) - 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) - cmd.SetArgs([]string{"https://github.com/owner/repo/pull/42"}) - err := cmd.Execute() - - cfg.Err.Close() - errOut, _ := io.ReadAll(errR) - output := string(errOut) - - assert.NoError(t, err) - assert.Contains(t, output, "https://github.com/owner/repo/pull/42") -} - -func TestMerge_ByBranchName(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ - Number: 42, - URL: "https://github.com/owner/repo/pull/42", - }}, - }, - } - - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - restore := git.SetOps(newMergeMock(tmpDir, "main")) - 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) - cmd.SetArgs([]string{"feat-1"}) - err := cmd.Execute() - - cfg.Err.Close() - errOut, _ := io.ReadAll(errR) - output := string(errOut) - - assert.NoError(t, err) - assert.Contains(t, output, "https://github.com/owner/repo/pull/42") -} diff --git a/cmd/root.go b/cmd/root.go index 217016dc..65bfe71f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -107,10 +107,6 @@ locally, then push to GitHub to create your stack of PRs.`, linkCmd.GroupID = "remote" root.AddCommand(linkCmd) - mergeCmd := MergeCmd(cfg) - mergeCmd.GroupID = "remote" - root.AddCommand(mergeCmd) - // Navigation commands switchCmd := SwitchCmd(cfg) switchCmd.GroupID = "nav" diff --git a/cmd/root_test.go b/cmd/root_test.go index 25a68a70..8138c7a7 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -10,7 +10,7 @@ import ( func TestRootCmd_SubcommandRegistration(t *testing.T) { root := RootCmd() - expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "merge", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit"} + expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "view", "rebase", "up", "down", "top", "bottom", "alias", "feedback", "submit"} registered := make(map[string]bool) for _, cmd := range root.Commands() { From d115ca0570e54a81416c332009bda907784a1e25 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 14:01:03 -0400 Subject: [PATCH 5/7] support multiple branches during init (#91) * implicitly adopt branches in init * deprecate adopt flag * update docs * address review comments --- README.md | 7 +- cmd/init.go | 544 ++++++++++++++-------- cmd/init_test.go | 493 ++++++++++++++++++-- docs/src/content/docs/faq.md | 10 +- docs/src/content/docs/guides/workflows.md | 28 ++ docs/src/content/docs/reference/cli.md | 15 +- internal/git/mock_ops.go | 82 ++-- skills/gh-stack/SKILL.md | 14 +- 8 files changed, 914 insertions(+), 279 deletions(-) diff --git a/README.md b/README.md index af1f849a..e447b172 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,9 @@ Initialize a new stack in the current repository. gh stack init [flags] [branches...] ``` -Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. In interactive mode, you'll also be prompted to set an optional branch prefix (unless adopting existing branches). When a prefix is set, branch names you enter are automatically prefixed. When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`. +Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer. If a branch name contains slashes (e.g., `feat/api`), prompts if you would like to use a prefix (e.g., `feat/`) for all branches in the stack. + +When explicit branch names are given, existing branches are adopted automatically and any missing branches are created. The trunk defaults to the repository's default branch unless overridden with `--base`. Use `--numbered` with `--prefix` to enable auto-incrementing numbered branch names (`prefix/01`, `prefix/02`, …). Without `--numbered`, you'll always be prompted to provide a meaningful branch name. @@ -85,7 +87,6 @@ Enables `git rerere` automatically so that conflict resolutions are remembered a | Flag | Description | |------|-------------| | `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | -| `-a, --adopt` | Adopt existing branches into a stack instead of creating new ones | | `-p, --prefix ` | Set a branch name prefix for the stack | | `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) | @@ -102,7 +103,7 @@ gh stack init feature-auth feature-api feature-ui gh stack init --base develop feature-auth # Adopt existing branches into a stack -gh stack init --adopt feature-auth feature-api +gh stack init feature-auth feature-api # Set a prefix — you'll be prompted for a branch name gh stack init -p feat diff --git a/cmd/init.go b/cmd/init.go index 3db867ab..f89025ef 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -16,9 +16,9 @@ import ( type initOptions struct { branches []string base string - adopt bool prefix string numbered bool + adopt bool // deprecated, kept for backward compat } func InitCmd(cfg *config.Config) *cobra.Command { @@ -27,10 +27,28 @@ func InitCmd(cfg *config.Config) *cobra.Command { cmd := &cobra.Command{ Use: "init [branches...]", Short: "Initialize a new stack", - Long: `Initialize a stack object in the local repo. + Long: `Initialize a new stack of branches in the current repository. -Unless specified, prompts user to create/select branch for first layer of the stack. -Trunk defaults to default branch, unless specified otherwise.`, +You can pass multiple branch names to create a multi-layer stack in one +command. Existing branches are adopted automatically; missing branches are +created. By default, the first branch is based on the default branch, and +each subsequent branch is based on the previous one. + +Use --base to specify a different trunk branch.`, + Example: ` # Create a stack with a new branch + $ gh stack init my-feature + + # Create a multi-layer stack at once + $ gh stack init auth-layer api-routes ui-components + + # Adopt existing branches into a stack (bottom to top) + $ gh stack init feat/auth feat/api feat/ui + + # Create a stack with auto-numbered branches (feat/01, feat/02, etc.) + $ gh stack init --prefix feat --numbered + + # Specify a different trunk branch + $ gh stack init --base develop my-feature`, RunE: func(cmd *cobra.Command, args []string) error { opts.branches = args return runInit(cfg, opts) @@ -38,9 +56,10 @@ Trunk defaults to default branch, unless specified otherwise.`, } cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)") - cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Track existing branches as part of a stack") cmd.Flags().StringVarP(&opts.prefix, "prefix", "p", "", "Branch name prefix for the stack") cmd.Flags().BoolVarP(&opts.numbered, "numbered", "n", false, "Use auto-incrementing numbered branch names (requires --prefix)") + cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Deprecated: existing branches are now adopted automatically") + _ = cmd.Flags().MarkHidden("adopt") return cmd } @@ -95,27 +114,52 @@ func runInit(cfg *config.Config, opts *initOptions) error { } } - var branches []string + // --- Flag validation --- - // --adopt takes existing branches as-is; --prefix and --numbered don't apply. - if opts.adopt && (opts.prefix != "" || opts.numbered) { - cfg.Errorf("--adopt cannot be combined with --prefix or --numbered") - return ErrInvalidArgs + // --adopt is deprecated; print a notice and continue normally. + if opts.adopt { + cfg.Warningf("The --adopt flag is deprecated. Existing branches are now adopted automatically.") + cfg.Printf("You can simply run: %s", + cfg.ColorCyan("gh stack init ...")) } - // Validate --numbered requires a prefix (either from flag or interactive input, - // but for non-interactive paths we can check early). + // --numbered requires a prefix (either from flag or interactive input). if opts.numbered && opts.prefix == "" && !cfg.IsInteractive() { cfg.Errorf("--numbered requires --prefix") return ErrInvalidArgs } - // Prompt for prefix interactively if not provided via flag and we're - // in interactive mode (not adopt, not explicit branches). - if opts.prefix == "" && !opts.adopt && len(opts.branches) == 0 && cfg.IsInteractive() { - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - if opts.numbered { - // --numbered requires a prefix; prompt specifically for one + // Validate explicit --prefix before branch creation. + if opts.prefix != "" { + if err := git.ValidateRefName(opts.prefix); err != nil { + cfg.Errorf("invalid prefix %q: must be a valid git ref component", opts.prefix) + return ErrInvalidArgs + } + } + + // --- Branch collection --- + + var branches []string + adopted := make(map[string]bool) // tracks which branches were adopted (existed already) + + if len(opts.branches) > 0 { + // === ARGS PATH === + branches, adopted, err = resolveArgBranches(cfg, opts, sf, trunk) + if err != nil { + return err + } + + // Prefix detection (only when --prefix not explicitly set) + if opts.prefix == "" { + if detected := detectPrefix(branches); detected != "" { + opts.prefix = detected + } + } + + } else if opts.numbered { + // === NUMBERED PATH (unchanged) === + if opts.prefix == "" && cfg.IsInteractive() { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) prefixInput, err := p.Input("Enter a branch prefix (required for --numbered)", "") if err != nil { if isInterruptError(err) { @@ -130,153 +174,41 @@ func runInit(cfg *config.Config, opts *initOptions) error { cfg.Errorf("--numbered requires a prefix") return ErrInvalidArgs } - } else { - prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "") - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return ErrSilent - } - cfg.Errorf("failed to read prefix: %s", err) - return ErrSilent - } - opts.prefix = strings.TrimSpace(prefixInput) } - } - - // Validate prefix, after it has been determined (from flag or prompt), - // before any branch creation. - if opts.prefix != "" { - if err := git.ValidateRefName(opts.prefix); err != nil { - cfg.Errorf("invalid prefix %q: must be a valid git ref component", opts.prefix) - return ErrInvalidArgs - } - } - - if opts.adopt { - // Adopt mode: validate all specified branches exist - if len(opts.branches) == 0 { - cfg.Errorf("--adopt requires at least one branch name") + branchName := branch.NextNumberedName(opts.prefix, nil) + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { + cfg.Errorf("branch %q already exists in a stack", branchName) return ErrInvalidArgs } - for _, b := range opts.branches { - if !git.BranchExists(b) { - cfg.Errorf("branch %q does not exist", b) - return ErrInvalidArgs - } - if err := sf.ValidateNoDuplicateBranch(b); err != nil { - cfg.Errorf("branch %q already exists in a stack", b) - return ErrInvalidArgs - } - } - branches = opts.branches - } else if len(opts.branches) > 0 { - // Explicit branch names provided — apply prefix and create them - prefixed := make([]string, 0, len(opts.branches)) - for _, b := range opts.branches { - if opts.prefix != "" { - b = opts.prefix + "/" + b - } - if err := sf.ValidateNoDuplicateBranch(b); err != nil { - cfg.Errorf("branch %q already exists in a stack", b) - return ErrInvalidArgs - } - if !git.BranchExists(b) { - if err := git.CreateBranch(b, trunk); err != nil { - cfg.Errorf("creating branch %s: %s", b, err) - return ErrSilent - } + if git.BranchExists(branchName) { + adopted[branchName] = true + } else { + if err := git.CreateBranch(branchName, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", branchName, err) + return ErrSilent } - prefixed = append(prefixed, b) } - branches = prefixed + branches = []string{branchName} + } else { - // Interactive mode — prefix was already prompted for above + // === INTERACTIVE PATH === if !cfg.IsInteractive() { - cfg.Errorf("interactive input required; provide branch names or use --adopt") + cfg.Errorf("interactive input required; provide branch names as arguments") return ErrInvalidArgs } - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - if opts.numbered { - // Auto-generate numbered branch name - branchName := branch.NextNumberedName(opts.prefix, nil) - if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { - cfg.Errorf("branch %q already exists in a stack", branchName) - return ErrInvalidArgs - } - if !git.BranchExists(branchName) { - if err := git.CreateBranch(branchName, trunk); err != nil { - cfg.Errorf("creating branch %s: %s", branchName, err) - return ErrSilent - } - } - branches = []string{branchName} - } else { - if currentBranch != "" && currentBranch != trunk { - // Already on a non-trunk branch — offer to use it - useCurrentBranch, err := p.Confirm( - fmt.Sprintf("Would you like to use %s as the first layer of your stack?", currentBranch), - true, - ) - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return ErrSilent - } - cfg.Errorf("failed to confirm branch selection: %s", err) - return ErrSilent - } - if useCurrentBranch { - if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { - cfg.Errorf("branch %q already exists in the stack", currentBranch) - return ErrInvalidArgs - } - branches = []string{currentBranch} - } - } - - if len(branches) == 0 { - prompt := "What branch would you like to use as the first layer of your stack?" - if opts.prefix != "" { - prompt = fmt.Sprintf("Enter a name for the first branch (will be prefixed with %s/)", opts.prefix) - } - branchName, err := p.Input(prompt, "") - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return ErrSilent - } - cfg.Errorf("failed to read branch name: %s", err) - return ErrSilent - } - branchName = strings.TrimSpace(branchName) - - if branchName == "" { - cfg.Errorf("branch name cannot be empty") - return ErrInvalidArgs - } - - if opts.prefix != "" { - branchName = opts.prefix + "/" + branchName - } - - if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { - cfg.Errorf("branch %q already exists in a stack", branchName) - return ErrInvalidArgs - } - if !git.BranchExists(branchName) { - if err := git.CreateBranch(branchName, trunk); err != nil { - cfg.Errorf("creating branch %s: %s", branchName, err) - return ErrSilent - } - } - branches = []string{branchName} - } + var interactiveAdopted bool + branches, interactiveAdopted, err = runInteractiveInit(cfg, sf, trunk, currentBranch, opts) + if err != nil { + return err + } + if interactiveAdopted { + adopted[branches[0]] = true } } - // Build stack + // --- Build stack --- + trunkSHA, _ := git.RevParse(trunk) branchRefs := make([]stack.BranchRef, len(branches)) for i, b := range branches { @@ -300,55 +232,291 @@ func runInit(cfg *config.Config, opts *initOptions) error { sf.AddStack(newStack) - // Discover existing PRs for the new stack's branches. - // For adopt, only record open/draft PRs (ignore closed/merged). - // For non-adopt, use the standard sync which also detects merges. + // --- PR detection --- + // Use FindPRForBranch for all branches. For adopted branches this + // finds existing PRs; for created branches it harmlessly returns nil. latestStack := &sf.Stacks[len(sf.Stacks)-1] - if opts.adopt { - if client, clientErr := cfg.GitHubClient(); clientErr == nil { - for i := range latestStack.Branches { - b := &latestStack.Branches[i] - 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, - } + prCount := 0 + if client, clientErr := cfg.GitHubClient(); clientErr == nil { + for i := range latestStack.Branches { + b := &latestStack.Branches[i] + 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, + } + prCount++ } - } else { - _ = syncStackPRs(cfg, latestStack) } if err := stack.Save(gitDir, sf); err != nil { return handleSaveError(cfg, err) } - // Print result - if opts.adopt { - cfg.Printf("Adopting stack with trunk %s and %d branches", trunk, len(branches)) - cfg.Printf("Initializing stack: %s", newStack.DisplayChain()) - cfg.Printf("You can continue working on %s", branches[len(branches)-1]) - } else { - cfg.Successf("Creating stack with trunk %s and branch %s", trunk, branches[len(branches)-1]) - // Switch to last branch if not already there - lastBranch := branches[len(branches)-1] - if currentBranch != lastBranch { - if err := git.CheckoutBranch(lastBranch); err != nil { - cfg.Errorf("switching to branch %s: %s", lastBranch, err) - return ErrSilent + // --- Output: switch to top branch + "What's next" --- + + lastBranch := branches[len(branches)-1] + if currentBranch != lastBranch { + if err := git.CheckoutBranch(lastBranch); err != nil { + cfg.Errorf("switching to branch %s: %s", lastBranch, err) + return ErrSilent + } + } + + hasAdopted := len(adopted) > 0 + + printWhatsNext(cfg, &newStack, branches, hasAdopted, prCount) + + return nil +} + +// resolveArgBranches handles the args path: classifies each branch as +// adopted (exists) or created (missing), validates all before creating any. +func resolveArgBranches(cfg *config.Config, opts *initOptions, sf *stack.StackFile, trunk string) ([]string, map[string]bool, error) { + adopted := make(map[string]bool) + + // Phase 1: resolve final names, classify, validate + type branchInfo struct { + name string + exists bool + } + resolved := make([]branchInfo, 0, len(opts.branches)) + + for _, b := range opts.branches { + // Apply explicit --prefix (not detected prefix) + if opts.prefix != "" { + b = opts.prefix + "/" + b + } + + // Validate ref name before checking existence or creating + if err := git.ValidateRefName(b); err != nil { + cfg.Errorf("invalid branch name %q: must be a valid git ref", b) + return nil, nil, ErrInvalidArgs + } + + exists := git.BranchExists(b) + + if err := sf.ValidateNoDuplicateBranch(b); err != nil { + cfg.Errorf("branch %q already exists in a stack", b) + return nil, nil, ErrInvalidArgs + } + + resolved = append(resolved, branchInfo{name: b, exists: exists}) + } + + // Phase 2: create missing branches + branches := make([]string, 0, len(resolved)) + for i, bi := range resolved { + if bi.exists { + adopted[bi.name] = true + } else { + parent := trunk + if i > 0 { + parent = resolved[i-1].name + } + if err := git.CreateBranch(bi.name, parent); err != nil { + cfg.Errorf("creating branch %s: %s", bi.name, err) + return nil, nil, ErrSilent + } + } + branches = append(branches, bi.name) + } + + return branches, adopted, nil +} + +// runInteractiveInit runs the interactive init flow: prints hint about +// multi-branch args, offers current branch or new branch, then runs +// prefix detection. Returns the branches and whether the branch was adopted +// (already existed). +func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentBranch string, opts *initOptions) ([]string, bool, error) { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + + cfg.Printf("Initializing a stack from %s.", trunk) + cfg.Printf("Have multiple branches already? Run: %s", + cfg.ColorCyan("gh stack init ...")) + cfg.Printf("") + + var branchName string + + if currentBranch != "" && currentBranch != trunk { + // On a non-trunk branch — offer select + options := []string{ + fmt.Sprintf("Use current branch (%s) as the first layer", currentBranch), + "Create a new branch", + } + selectFn := func(prompt, def string, opts []string) (int, error) { + if cfg.SelectFn != nil { + return cfg.SelectFn(prompt, def, opts) } - cfg.Printf("Switched to branch %s", lastBranch) + return p.Select(prompt, def, opts) + } + selected, err := selectFn("What do you want to start with?", "", options) + if err != nil { + if isInterruptError(err) { + if cfg.SelectFn == nil { + clearSelectPrompt(cfg, len(options)) + } + printInterrupt(cfg) + return nil, false, ErrSilent + } + cfg.Errorf("failed to read selection: %s", err) + return nil, false, ErrSilent + } + + if selected == 0 { + // Use current branch + if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { + cfg.Errorf("branch %q already exists in a stack", currentBranch) + return nil, false, ErrInvalidArgs + } + branchName = currentBranch } else { - cfg.Printf("You can continue working on %s", lastBranch) + // Create a new branch — fall through to input prompt + name, err := promptBranchName(cfg, p, opts.prefix) + if err != nil { + return nil, false, err + } + branchName = name + } + } else { + // On trunk or detached HEAD — prompt for name directly + name, err := promptBranchName(cfg, p, opts.prefix) + if err != nil { + return nil, false, err } + branchName = name } - cfg.Printf("To add a new layer to your stack, run `%s`", cfg.ColorCyan("gh stack add")) - cfg.Printf("When you're ready to push to GitHub and open a stack of PRs, run `%s`", cfg.ColorCyan("gh stack submit")) + // Validate and create branch (track whether it was adopted) + wasAdopted := false + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { + cfg.Errorf("branch %q already exists in a stack", branchName) + return nil, false, ErrInvalidArgs + } + if git.BranchExists(branchName) { + wasAdopted = true + } else { + if err := git.CreateBranch(branchName, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", branchName, err) + return nil, false, ErrSilent + } + } - return nil + // Prefix detection (interactive path, no --prefix flag) + if opts.prefix == "" { + if lastSlash := strings.LastIndex(branchName, "/"); lastSlash > 0 { + detected := branchName[:lastSlash] + usePrefix, err := p.Confirm( + fmt.Sprintf("Use %q as a prefix for new branches in this stack?", detected+"/"), + true, + ) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil, false, ErrSilent + } + // Not fatal — just skip prefix + } else if usePrefix { + opts.prefix = detected + } + } + } + + return []string{branchName}, wasAdopted, nil +} + +// promptBranchName prompts the user for a branch name, applying the +// explicit --prefix if set. +func promptBranchName(cfg *config.Config, p *prompter.Prompter, prefix string) (string, error) { + prompt := "What's the name of the first branch?" + if prefix != "" { + prompt = fmt.Sprintf("Enter a name for the first branch (will be prefixed with %s/)", prefix) + } + branchName, err := p.Input(prompt, "") + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return "", ErrSilent + } + cfg.Errorf("failed to read branch name: %s", err) + return "", ErrSilent + } + branchName = strings.TrimSpace(branchName) + if branchName == "" { + cfg.Errorf("branch name cannot be empty") + return "", ErrInvalidArgs + } + if prefix != "" { + branchName = prefix + "/" + branchName + } + return branchName, nil +} + +// detectPrefix finds a common prefix across branches by splitting each +// at its last slash. Returns the prefix (without trailing slash) if all +// branches share the same one, or "" otherwise. +func detectPrefix(branches []string) string { + if len(branches) == 0 { + return "" + } + var common string + for i, b := range branches { + lastSlash := strings.LastIndex(b, "/") + if lastSlash <= 0 { + return "" // no slash or leading slash — no prefix + } + prefix := b[:lastSlash] + if i == 0 { + common = prefix + } else if prefix != common { + return "" // different prefixes + } + } + return common +} + +// printWhatsNext prints the scenario-aware "What's next" block after init. +func printWhatsNext(cfg *config.Config, s *stack.Stack, branches []string, hasAdopted bool, prCount int) { + lastBranch := branches[len(branches)-1] + + // Build the chain: main → branch1 → branch2 + parts := []string{s.Trunk.Branch} + for _, b := range s.Branches { + parts = append(parts, b.Branch) + } + chain := strings.Join(parts, " → ") + + // Success line + if hasAdopted { + cfg.Successf("Adopted %d %s: %s", + len(branches), plural(len(branches), "branch", "branches"), chain) + } else { + cfg.Successf("Created stack: %s", chain) + } + + // Position + cfg.Printf(" You're on %s (top of stack).", lastBranch) + + // PR summary (only when adopting and at least one PR found) + if hasAdopted && prCount > 0 { + cfg.Printf(" Found PRs for %d of %d %s.", + prCount, len(branches), plural(len(branches), "branch", "branches")) + } + + cfg.Printf("") + cfg.Printf("What's next:") + if hasAdopted { + cfg.Printf(" • see the full stack: %s", cfg.ColorCyan("gh stack view")) + cfg.Printf(" • move between branches: %s", cfg.ColorCyan("gh stack switch")) + cfg.Printf(" • link these PRs into a Stack on GitHub: %s", cfg.ColorCyan("gh stack submit")) + } else { + cfg.Printf(" • commit your work as usual, then add a layer: %s", cfg.ColorCyan("gh stack add")) + cfg.Printf(" • see your stack any time: %s", cfg.ColorCyan("gh stack view")) + cfg.Printf(" • when ready to open PRs: %s", cfg.ColorCyan("gh stack submit")) + } } diff --git a/cmd/init_test.go b/cmd/init_test.go index 3ed70bce..8b92b03d 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -166,47 +166,31 @@ func TestInit_InvalidPrefixRejectedBeforeBranchCreation(t *testing.T) { assert.Empty(t, created, "no branches should be created when prefix is invalid") } -func TestInit_AdoptRejectsPrefix(t *testing.T) { - gitDir := t.TempDir() - restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - DefaultBranchFn: func() (string, error) { return "main", nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, - }) - defer restore() - - cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{adopt: true, branches: []string{"b1"}, prefix: "feat"}) - output := collectOutput(cfg, outR, errR) - - assert.ErrorIs(t, err, ErrInvalidArgs) - assert.Contains(t, output, "--adopt cannot be combined with --prefix or --numbered") -} - -func TestInit_AdoptRejectsNumbered(t *testing.T) { +func TestInit_AdoptFlagShowsDeprecationWarning(t *testing.T) { gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, DefaultBranchFn: func() (string, error) { return "main", nil }, CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(string) bool { return true }, }) defer restore() cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{adopt: true, branches: []string{"b1"}, numbered: true}) + err := runInit(cfg, &initOptions{adopt: true, branches: []string{"b1"}}) output := collectOutput(cfg, outR, errR) - assert.ErrorIs(t, err, ErrInvalidArgs) - assert.Contains(t, output, "--adopt cannot be combined with --prefix or --numbered") + require.NoError(t, err) + assert.Contains(t, output, "--adopt flag is deprecated") } func TestInit_RerereAlreadyEnabled(t *testing.T) { gitDir := t.TempDir() enableRerereCalled := false restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - DefaultBranchFn: func() (string, error) { return "main", nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, IsRerereEnabledFn: func() (bool, error) { return true, nil }, EnableRerereFn: func() error { enableRerereCalled = true @@ -249,21 +233,29 @@ func TestInit_RefuseIfBranchAlreadyInStack(t *testing.T) { assert.Contains(t, output, "already part of a stack") } -func TestInit_AdoptNonexistentBranch(t *testing.T) { +func TestInit_AdoptNonexistentBranch_CreatesIt(t *testing.T) { + // --adopt with missing branch now creates it (no error, just a deprecation warning) gitDir := t.TempDir() + var created []string restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, DefaultBranchFn: func() (string, error) { return "main", nil }, CurrentBranchFn: func() (string, error) { return "main", nil }, BranchExistsFn: func(string) bool { return false }, + CreateBranchFn: func(name, base string) error { + created = append(created, name) + return nil + }, }) defer restore() cfg, outR, errR := config.NewTestConfig() - runInit(cfg, &initOptions{branches: []string{"nonexistent"}, adopt: true}) + err := runInit(cfg, &initOptions{branches: []string{"nonexistent"}, adopt: true}) output := collectOutput(cfg, outR, errR) - assert.Contains(t, output, "does not exist") + require.NoError(t, err) + assert.Contains(t, output, "--adopt flag is deprecated") + assert.Equal(t, []string{"nonexistent"}, created) } func TestInit_MultipleBranches_CreatesAll(t *testing.T) { @@ -379,3 +371,450 @@ func TestInit_AdoptIgnoresClosedAndMergedPRs(t *testing.T) { assert.Nil(t, b.PullRequest, "closed/merged PRs should not be recorded for branch %s", b.Branch) } } + +// --- Tests for spec scenarios --- + +func TestInit_ImplicitAdopt_AllExist(t *testing.T) { + // Scenario 8: all branches exist → implicit adopt, PR detection runs + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(string) bool { return true }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if branch == "b1" { + return &github.PullRequest{Number: 10, ID: "PR_10", URL: "https://example.com/10"}, nil + } + return nil, nil + }, + } + + err := runInit(cfg, &initOptions{branches: []string{"b1", "b2", "b3"}}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717") + assert.Contains(t, output, "Adopted") + assert.Contains(t, output, "Found PRs for 1 of 3") + + sf, _ := stack.Load(gitDir) + assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames()) + assert.NotNil(t, sf.Stacks[0].Branches[0].PullRequest) +} + +func TestInit_ImplicitAdopt_AllMissing(t *testing.T) { + // Scenario 7: all branches missing → create all + gitDir := t.TempDir() + var created []string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { + created = append(created, name) + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"b1", "b2", "b3"}}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717") + assert.Contains(t, output, "Created stack") + assert.NotContains(t, output, "Adopted") + assert.Equal(t, []string{"b1", "b2", "b3"}, created) +} + +func TestInit_ImplicitAdopt_Mixed(t *testing.T) { + // Scenario 11: mixed → adopts existing, creates missing + gitDir := t.TempDir() + existing := map[string]bool{"existing1": true, "existing2": true} + var created []string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return existing[name] }, + CreateBranchFn: func(name, base string) error { + created = append(created, name) + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"existing1", "new1", "existing2"}}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + require.NotContains(t, output, "\u2717") + assert.Contains(t, output, "Adopted") + assert.Equal(t, []string{"new1"}, created) + + sf, _ := stack.Load(gitDir) + assert.Equal(t, []string{"existing1", "new1", "existing2"}, sf.Stacks[0].BranchNames()) +} + +func TestInit_PrefixDetection_ArgsCommonPrefix(t *testing.T) { + // Scenario 9: args all share prefix → set silently + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"feat/a", "feat/b", "feat/c"}}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "feat", sf.Stacks[0].Prefix) +} + +func TestInit_PrefixDetection_ArgsMixedPrefix(t *testing.T) { + // Scenario 10: args mixed prefixes → no prefix + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"feat/a", "bug/b"}}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "", sf.Stacks[0].Prefix) +} + +func TestInit_PrefixDetection_ArgsNoSlash(t *testing.T) { + // No slashes → no prefix + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"auth", "api", "ui"}}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "", sf.Stacks[0].Prefix) +} + +func TestInit_PrefixDetection_NestedPrefix(t *testing.T) { + // Scenario 6: sameen/feat/x → prefix "sameen/feat" + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"sameen/feat/a", "sameen/feat/b"}}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "sameen/feat", sf.Stacks[0].Prefix) +} + +func TestInit_ExplicitPrefixSkipsDetection(t *testing.T) { + // Scenario 14: --prefix with args → explicit wins + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"b1", "b2"}, prefix: "foo"}) + collectOutput(cfg, outR, errR) + + require.NoError(t, err) + sf, _ := stack.Load(gitDir) + assert.Equal(t, "foo", sf.Stacks[0].Prefix) + assert.Equal(t, []string{"foo/b1", "foo/b2"}, sf.Stacks[0].BranchNames()) +} + +func TestInit_WhatsNext_Fresh(t *testing.T) { + // Scenario 17: fresh single-branch → fresh format + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"my-feature"}}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "Created stack") + assert.Contains(t, output, "main → my-feature") + assert.Contains(t, output, "top of stack") + assert.Contains(t, output, "What's next:") + assert.Contains(t, output, "gh stack add") + assert.Contains(t, output, "gh stack view") + assert.Contains(t, output, "gh stack submit") + assert.NotContains(t, output, "Adopted") +} + +func TestInit_WhatsNext_AdoptedWithPRs(t *testing.T) { + // Scenario 18: adopted multi-branch, 2 of 3 PRs → adopt format with PR count + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(string) bool { return true }, + }) + defer restore() + + prBranches := map[string]bool{"b1": true, "b3": true} + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if prBranches[branch] { + return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://example.com/1"}, nil + } + return nil, nil + }, + } + + runInit(cfg, &initOptions{branches: []string{"b1", "b2", "b3"}}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "Adopted 3 branches") + assert.Contains(t, output, "Found PRs for 2 of 3") + assert.Contains(t, output, "What's next:") + assert.Contains(t, output, "gh stack view") + assert.Contains(t, output, "gh stack switch") + assert.Contains(t, output, "gh stack submit") +} + +func TestInit_WhatsNext_AdoptedNoPRs(t *testing.T) { + // Scenario 19: adopted, 0 PRs → no PR summary line + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(string) bool { return true }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + return nil, nil + }, + } + + runInit(cfg, &initOptions{branches: []string{"b1", "b2"}}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "Adopted 2 branches") + assert.NotContains(t, output, "Found PRs") +} + +func TestInit_WhatsNext_MixedWithPR(t *testing.T) { + // Scenario 20: mixed (1 adopted, 1 created), 1 PR → adopt format + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return name == "existing" }, + CreateBranchFn: func(name, base string) error { return nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if branch == "existing" { + return &github.PullRequest{Number: 5, ID: "PR_5", URL: "https://example.com/5"}, nil + } + return nil, nil + }, + } + + runInit(cfg, &initOptions{branches: []string{"existing", "new-branch"}}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "Adopted") + assert.Contains(t, output, "Found PRs for 1 of 2") +} + +func TestInit_Interactive_OnTrunk(t *testing.T) { + // Scenario 1: on trunk → shows hint about multi-branch args + // Note: full interactive Input() prompt requires TTY; test via args path instead. + // Here we verify that the hint line appears and non-interactive errors correctly. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + // Not interactive → should error with guidance + err := runInit(cfg, &initOptions{}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "interactive input required") +} + +func TestInit_Interactive_OnFeatureBranch_UseCurrent(t *testing.T) { + // Scenario 2: on feature branch → select, choose "use current" + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "feat/auth", nil }, + BranchExistsFn: func(name string) bool { return name == "feat/auth" }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + // Select option 0 = "Use current branch" + cfg.SelectFn = func(prompt, def string, options []string) (int, error) { + return 0, nil + } + + err := runInit(cfg, &initOptions{}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Initializing a stack from main") + // Branch already exists → should be treated as adopted + assert.Contains(t, output, "Adopted") + + sf, _ := stack.Load(gitDir) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"feat/auth"}, sf.Stacks[0].BranchNames()) + // Prefix detection Y/n prompt fails gracefully without a TTY, + // so prefix is not set. The args-path prefix detection is tested separately. +} + +func TestInit_TwoPassValidation_NoBranchCreatedOnError(t *testing.T) { + // Verify that if arg 3 fails validation, args 1 and 2 are NOT created + gitDir := t.TempDir() + + // Pre-create a stack with "dup" branch + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "dup"}}, + }}, + } + require.NoError(t, stack.Save(gitDir, sf)) + + var created []string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { + created = append(created, name) + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"new1", "new2", "dup"}}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "already exists in a stack") + assert.Empty(t, created, "no branches should be created when later arg fails validation") +} + +func TestInit_TwoPassValidation_InvalidRefName(t *testing.T) { + // Verify that an invalid ref name in the args list prevents any branch creation + gitDir := t.TempDir() + var created []string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + ValidateRefNameFn: func(name string) error { + if name == "invalid..name" { + return fmt.Errorf("invalid ref name: %s", name) + } + return nil + }, + CreateBranchFn: func(name, base string) error { + created = append(created, name) + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runInit(cfg, &initOptions{branches: []string{"valid-branch", "invalid..name", "another-branch"}}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "invalid branch name") + assert.Empty(t, created, "no branches should be created when an arg has an invalid ref name") +} + +func TestDetectPrefix(t *testing.T) { + tests := []struct { + name string + branches []string + want string + }{ + {"common prefix", []string{"feat/a", "feat/b", "feat/c"}, "feat"}, + {"nested prefix", []string{"sameen/feat/a", "sameen/feat/b"}, "sameen/feat"}, + {"mixed prefixes", []string{"feat/a", "bug/b"}, ""}, + {"no slashes", []string{"auth", "api", "ui"}, ""}, + {"empty list", []string{}, ""}, + {"single branch with slash", []string{"feat/x"}, "feat"}, + {"single branch no slash", []string{"auth"}, ""}, + {"leading slash only", []string{"/x"}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectPrefix(tt.branches) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index 0365e8f0..6a587bd5 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -35,17 +35,17 @@ You can also add PRs to an existing stack from the GitHub UI. See [Adding to an Use `gh stack modify` to restructure a stack. It opens an interactive terminal UI where you can reorder, drop, fold (combine), and rename branches — then applies all changes at once. See the [Restructuring Stacks](/gh-stack/guides/modify/) guide for a full walkthrough. -Alternatively, you can manually tear down and re-create the stack with `gh stack unstack` and `gh stack init --adopt`: +Alternatively, you can manually tear down and re-create the stack with `gh stack unstack` and `gh stack init`: ```sh # 1. Remove the stack gh stack unstack # 2. Make structural changes (reorder, rename, delete branches) -git branch -m old-name new-name +git branch -m api-roots api-routes # 3. Re-create the stack with the new structure -gh stack init --adopt branch-1 branch-2 branch-3 +gh stack init db-migrations api-routes frontend ``` ### How do I delete my stack? @@ -263,9 +263,9 @@ You can also use `--base` to specify a different trunk branch and `--open` to ma gh stack link --base develop --open 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`: +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 init`: ```bash -gh stack init --adopt change1 change2 change3 +gh stack init change1 change2 change3 gh stack submit ``` diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 12378747..d4a14701 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -137,6 +137,34 @@ This command: If a conflict is detected during the rebase, all branches are restored to their original state, and you're advised to run `gh stack rebase` to resolve conflicts interactively. +## Existing Branches into a Stack + +If you already have a set of branches that form a logical chain, you can organize them into a stack by passing them to `gh stack init`. Existing branches are adopted automatically — no special flags needed. + +```sh +# Adopt three existing branches into a stack (bottom to top) +gh stack init feat/auth feat/api feat/ui +``` + +The order matters: branches are listed from bottom (closest to trunk) to top (furthest from trunk). Any PRs already open for these branches are detected and linked to the stack. + +You can also mix existing and new branches in one command: + +```sh +# feat/auth exists, feat/api-v2 will be created +gh stack init feat/auth feat/api-v2 +``` + +After organizing branches into a stack, run `gh stack submit` to create a Stack on GitHub and link the PRs together. + +```sh +# View the new stack +gh stack view + +# Create/update PRs and link them as a Stack on GitHub +gh stack submit +``` + ## Structuring Your Stack Think of a stack from the reviewer's perspective: the PRs should tell a **cohesive story**. A reviewer reading the PRs in sequence should understand the progression of changes. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index f2bac852..9cab10b1 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -27,9 +27,9 @@ Initialize a new stack in the current repository. gh stack init [flags] [branches...] ``` -Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. You'll also be prompted to set an optional branch prefix. When a prefix is set, branch names you enter are automatically prefixed. +Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer. If a branch name contains slashes (e.g., `feat/api`), prompts if you would like to use a prefix (e.g., `feat/`) for all branches in the stack. -When explicit branch names are given, creates any that don't already exist (branching from the trunk). The trunk defaults to the repository's default branch unless overridden with `--base`. +When explicit branch names are given, existing branches are adopted automatically and any missing branches are created. The trunk defaults to the repository's default branch unless overridden with `--base`. Use `--numbered` with `--prefix` to enable auto-incrementing branch names (`prefix/01`, `prefix/02`, …). @@ -38,7 +38,6 @@ Enables `git rerere` automatically so that conflict resolutions are remembered a | Flag | Description | |------|-------------| | `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | -| `-a, --adopt` | Adopt existing branches into a stack instead of creating new ones | | `-p, --prefix ` | Set a branch name prefix for the stack | | `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) | @@ -48,14 +47,14 @@ Enables `git rerere` automatically so that conflict resolutions are remembered a # Interactive — prompts for branch names gh stack init -# Non-interactive — specify branches upfront -gh stack init feature-auth feature-api feature-ui +# Non-interactive — specify first branch upfront +gh stack init feature-auth # Use a different trunk branch gh stack init --base develop feature-auth -# Adopt existing branches into a stack -gh stack init --adopt feature-auth feature-api +# Adopt or create multiple branches at once +gh stack init feature-auth feature-api feature-ui # Set a prefix — prompts for a branch name suffix gh stack init -p feat @@ -235,7 +234,7 @@ You must have a branch from the stack checked out locally. The command targets t Deletes the stack on GitHub first, if it exists, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking. -This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init --adopt` to re-create the stack with the desired structure. +This is useful when you need to restructure a stack — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init` to re-create the stack with the desired structure — existing branches are adopted automatically. | Flag | Description | |------|-------------| diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 05b8c516..ed123c52 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -4,47 +4,47 @@ package git // Each field is an optional function that, when set, handles the corresponding // Ops method call. When nil, a reasonable default is returned. type MockOps struct { - GitDirFn func() (string, error) - RootDirFn func() (string, error) - CurrentBranchFn func() (string, error) - BranchExistsFn func(string) bool - CheckoutBranchFn func(string) error - FetchFn func(string) error - FetchBranchesFn func(string, []string) error - DefaultBranchFn func() (string, error) - CreateBranchFn func(string, string) error - PushFn func(string, []string, bool, bool) error - ResolveRemoteFn func(string) (string, error) - RebaseFn func(string) error - EnableRerereFn func() error - IsRerereEnabledFn func() (bool, error) - IsRerereDeclinedFn func() (bool, error) - SaveRerereDeclinedFn func() error - RebaseOntoFn func(string, string, string) error - RebaseContinueFn func() error - RebaseAbortFn func() error - IsRebaseInProgressFn func() bool - ConflictedFilesFn func() ([]string, error) - FindConflictMarkersFn func(string) (*ConflictMarkerInfo, error) - IsAncestorFn func(string, string) (bool, error) - RevParseFn func(string) (string, error) - RevParseMultiFn func([]string) ([]string, error) - MergeBaseFn func(string, string) (string, error) - LogFn func(string, int) ([]CommitInfo, error) - LogRangeFn func(string, string) ([]CommitInfo, error) - DiffStatRangeFn func(string, string) (int, int, error) - DiffStatFilesFn func(string, string) ([]FileDiffStat, error) - DeleteBranchFn func(string, bool) error - DeleteRemoteBranchFn func(string, string) error - ResetHardFn func(string) error - SetUpstreamTrackingFn func(string, string) error - MergeFFFn func(string) error - UpdateBranchRefFn func(string, string) error - StageAllFn func() error - StageTrackedFn func() error - HasStagedChangesFn func() bool - CommitFn func(string) (string, error) - CommitInteractiveFn func() (string, error) + GitDirFn func() (string, error) + RootDirFn func() (string, error) + CurrentBranchFn func() (string, error) + BranchExistsFn func(string) bool + CheckoutBranchFn func(string) error + FetchFn func(string) error + FetchBranchesFn func(string, []string) error + DefaultBranchFn func() (string, error) + CreateBranchFn func(string, string) error + PushFn func(string, []string, bool, bool) error + ResolveRemoteFn func(string) (string, error) + RebaseFn func(string) error + EnableRerereFn func() error + IsRerereEnabledFn func() (bool, error) + IsRerereDeclinedFn func() (bool, error) + SaveRerereDeclinedFn func() error + RebaseOntoFn func(string, string, string) error + RebaseContinueFn func() error + RebaseAbortFn func() error + IsRebaseInProgressFn func() bool + ConflictedFilesFn func() ([]string, error) + FindConflictMarkersFn func(string) (*ConflictMarkerInfo, error) + IsAncestorFn func(string, string) (bool, error) + RevParseFn func(string) (string, error) + RevParseMultiFn func([]string) ([]string, error) + MergeBaseFn func(string, string) (string, error) + LogFn func(string, int) ([]CommitInfo, error) + LogRangeFn func(string, string) ([]CommitInfo, error) + DiffStatRangeFn func(string, string) (int, int, error) + DiffStatFilesFn func(string, string) ([]FileDiffStat, error) + DeleteBranchFn func(string, bool) error + DeleteRemoteBranchFn func(string, string) error + ResetHardFn func(string) error + SetUpstreamTrackingFn func(string, string) error + MergeFFFn func(string) error + UpdateBranchRefFn func(string, string) error + StageAllFn func() error + StageTrackedFn func() error + HasStagedChangesFn func() bool + CommitFn func(string) (string, error) + CommitInteractiveFn func() (string, error) ValidateRefNameFn func(string) error RenameBranchFn func(string, string) error CherryPickFn func([]string) error diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 8b57a5e7..7f7105e5 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -141,7 +141,8 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current |------|---------| | Create a stack (recommended) | `gh stack init -p feat auth` | | Create a stack without prefix | `gh stack init auth` | -| Adopt existing branches | `gh stack init --adopt branch-a branch-b` | +| Create a stack of multiple branches | `gh stack init auth api frontend` | +| Adopt existing branches | `gh stack init existing-branch-a existing-branch-b` | | Set custom trunk | `gh stack init --base develop branch-a` | | Add a branch to stack (suffix only if prefix set) | `gh stack add api-routes` | | Add branch + stage all + commit | `gh stack add -Am "message" api-routes` | @@ -387,7 +388,7 @@ gh stack unstack git branch -m old-branch-1 new-branch-1 # 3. Re-create the stack with the new structure -gh stack init --base main --adopt new-branch-1 new-branch-2 new-branch-3 +gh stack init --base main new-branch-1 new-branch-2 new-branch-3 ``` --- @@ -417,21 +418,20 @@ gh stack init branch-a branch-b branch-c # Use a different trunk branch gh stack init --base develop branch-a branch-b -# Adopt existing branches into a stack -gh stack init --adopt branch-a branch-b branch-c +# Adopt existing branches into a stack (handled automatically if the branches exist) +gh stack init branch-a branch-b branch-c ``` | Flag | Description | |------|-------------| | `-b, --base ` | Trunk branch (defaults to the repo's default branch) | -| `-a, --adopt` | Adopt existing branches instead of creating new ones | | `-p, --prefix ` | Branch name prefix. Subsequent `add` calls only need the suffix (e.g., with `-p feat`, `gh stack add auth` creates `feat/auth`) | **Behavior:** - Using `-p` is recommended — it simplifies branch naming for subsequent `add` calls - Creates any branches that don't already exist (branching from the trunk branch) -- In `--adopt` mode: validates all branches exist, rejects if any is already in a stack or has an existing PR +- Existing branches are adopted automatically; missing branches are created from the trunk - Checks out the last branch in the list - Enables `git rerere` so conflict resolutions are remembered across rebases. On first run in a repo, this may trigger a confirmation prompt — pre-configure with `git config rerere.enabled true` to avoid it @@ -797,7 +797,7 @@ gh stack unstack [flags] ```bash # Tear down the stack (locally and on GitHub), then rebuild gh stack unstack -gh stack init --base main --adopt branch-2 branch-1 branch-3 # reordered +gh stack init --base main branch-2 branch-1 branch-3 # reordered # Only remove local tracking (keep the stack on GitHub) gh stack unstack --local From 650a94621d35649c2a22767ef284e4e99debcd68 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 14:01:04 -0400 Subject: [PATCH 6/7] docs on rebase workflow (#93) * docs on rebase workflow * highlight commit signing callout --- docs/src/content/docs/guides/workflows.md | 102 ++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index d4a14701..62ea8be4 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -137,6 +137,108 @@ This command: If a conflict is detected during the rebase, all branches are restored to their original state, and you're advised to run `gh stack rebase` to resolve conflicts interactively. +## Rebasing Your Stack + +Stacked PRs rely on rebasing rather than merge commits to keep each branch's diff clean and reviewable. If you're coming from a merge-commit workflow, the key difference is: instead of merging upstream changes into your branch (which creates a merge commit with multiple parents), you replay your commits on top of the latest base. The result is a linear history where each PR shows only its specific changes. + +### How rebasing works with stacks + +When you run `gh stack rebase`, it performs a **cascading rebase**: each branch in the stack is rebased onto the tip of the branch below it, starting from the trunk. This ensures every branch has the latest changes from all lower layers. + +```sh +# Rebase the entire stack (all branches, trunk to top) +gh stack rebase + +# Only rebase from trunk up to the current branch +gh stack rebase --downstack + +# Only rebase from the current branch up to the top +gh stack rebase --upstack +``` + +After rebasing, push the updated branches: + +```sh +gh stack push +``` + +`gh stack push` uses `--force-with-lease` to safely update the rebased branches. This is a safe form of force push — it ensures you don't overwrite changes that someone else pushed since your last fetch. If the remote has unexpected changes, the push is rejected and you can investigate. + +### Rebase from the CLI vs. the web UI + +You can rebase stack branches from either the CLI or the GitHub web UI, but they behave differently: + +| | CLI (`gh stack rebase`) | Web UI ("Rebase Stack" button) | +|---|---|---| +| **Runs where** | Locally, using your Git installation | On GitHub's servers | +| **Commit signing** | Commits are signed with your local Git committer config (GPG/SSH signing, if configured) | Commits retain the original author but the committer is set to whoever clicked the button — commits are **not** signed | +| **Conflict resolution** | Interactive — you resolve conflicts in your editor, then `gh stack rebase --continue` | Not available if there are conflicts — you must rebase locally | + +:::note +If commit signing matters for your project (e.g., branch protection rules require signed commits), use the CLI for rebases. +::: + +### Resolving conflicts + +When a rebase encounters a conflict, `gh stack rebase` stops and tells you which files are conflicted: + +```sh +gh stack rebase +# ✗ Conflict detected rebasing feat/api onto feat/auth +# C api/routes.go (lines 12–18) +# +# Resolve conflicts on feat/api, then run: gh stack rebase --continue +# Or abort this operation with: gh stack rebase --abort +``` + +To resolve: + +```sh +# 1. Open the conflicted files and resolve the markers +# (<<<<<<< / ======= / >>>>>>>) +# Use your editor of choice + +# 2. Stage the resolved files +git add api/routes.go + +# 3. Continue the rebase — remaining branches are rebased automatically +gh stack rebase --continue +``` + +If the conflict is too complex or you want to start over: + +```sh +# Abort and restore all branches to their pre-rebase state +gh stack rebase --abort +``` + +### The rebase + force-push cycle + +The typical cycle when updating a stack after making changes looks like this: + +```sh +# 1. Make changes on a mid-stack branch +gh stack checkout feat/auth +git add . +git commit -m "Fix token validation" + +# 2. Rebase everything above to incorporate the change +gh stack rebase --upstack + +# 3. Push all updated branches (safe force push) +gh stack push +``` + +This is equivalent but distinct from updating your branch using a merge commit. The key difference is that after changing a lower branch, rebase maintains a linear commit history so the unique set of commits on each branch have clean diffs. + +`gh stack push` then handles the force push safely via `--force-with-lease --atomic`, ensuring either all branches update or none do. + +For a simpler all-in-one flow, `gh stack sync` combines fetch, rebase, and push into a single command — useful when you just need to pull in the latest upstream changes: + +```sh +gh stack sync +``` + ## Existing Branches into a Stack If you already have a set of branches that form a logical chain, you can organize them into a stack by passing them to `gh stack init`. Existing branches are adopted automatically — no special flags needed. From d3be1b577ecd52ae7f6b01e1da31c7a8d999c999 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 15 May 2026 14:01:04 -0400 Subject: [PATCH 7/7] prune merged branches (#94) * prune merged branches * interactively prompt for prune * delete remote tracking ref too * disable selecting merged branches in TUIs * include full list (including merged PRs) in PUT request to stacks API * add prune to docs * addressing review comments * increment skill file version --- README.md | 6 + cmd/submit.go | 7 +- cmd/submit_test.go | 4 +- cmd/sync.go | 109 ++++- cmd/sync_test.go | 449 ++++++++++++++++++++ docs/src/content/docs/guides/stacked-prs.md | 2 +- docs/src/content/docs/guides/workflows.md | 1 + docs/src/content/docs/reference/cli.md | 5 + internal/config/config.go | 4 + internal/git/git.go | 5 + internal/git/gitops.go | 5 + internal/git/mock_ops.go | 8 + internal/tui/modifyview/model.go | 17 +- internal/tui/modifyview/model_test.go | 18 + internal/tui/stackview/model.go | 44 +- internal/tui/stackview/model_test.go | 47 ++ skills/gh-stack/SKILL.md | 11 +- 17 files changed, 717 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e447b172..f8a5fd3d 100644 --- a/README.md +++ b/README.md @@ -310,15 +310,20 @@ Performs a safe, non-interactive synchronization of the entire stack: 3. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state and you are advised to run `gh stack rebase` to resolve conflicts interactively 4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred) 5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR +6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically | Flag | Description | |------|-------------| | `--remote ` | Remote to fetch from and push to (defaults to auto-detected remote) | +| `--prune` | Delete local branches for merged PRs | **Examples:** ```sh gh stack sync + +# Sync and automatically prune merged branches +gh stack sync --prune ``` ### `gh stack push` @@ -556,6 +561,7 @@ gh stack push # 8. When the first PR is merged, sync the stack gh stack sync +# → prompts to prune merged branches (or use --prune to prune automatically and avoid the prompt) ``` ## Abbreviated workflow diff --git a/cmd/submit.go b/cmd/submit.go index 86e02edb..a57fe066 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -426,12 +426,11 @@ func clearPendingModifyState(cfg *config.Config, gitDir string) { // This is a best-effort operation: failures are reported as warnings but do // not cause the submit command to fail (the PRs are already created). func syncStack(cfg *config.Config, client github.ClientOps, s *stack.Stack) { - // Collect PR numbers in stack order (bottom to top). + // Collect PR numbers in stack order (bottom to top), including merged PRs. + // The API expects the full list — omitting merged PRs causes a + // "Stack contents have changed" rejection. var prNumbers []int for _, b := range s.Branches { - if b.IsMerged() { - continue - } if b.PullRequest != nil { prNumbers = append(prNumbers, b.PullRequest.Number) } diff --git a/cmd/submit_test.go b/cmd/submit_test.go index 1d86ed86..51649934 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -730,7 +730,7 @@ func TestSyncStack_SkippedForSinglePR(t *testing.T) { assert.False(t, updateCalled, "UpdateStack should not be called with fewer than 2 PRs") } -func TestSyncStack_SkipsMergedBranches(t *testing.T) { +func TestSyncStack_IncludesMergedBranches(t *testing.T) { s := &stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ @@ -752,7 +752,7 @@ func TestSyncStack_SkipsMergedBranches(t *testing.T) { syncStack(cfg, mock, s) cfg.Err.Close() - assert.Equal(t, []int{11, 12}, gotNumbers, "should only include non-merged PRs") + assert.Equal(t, []int{10, 11, 12}, gotNumbers, "should include merged PRs to keep API in sync") } func TestSyncStack_SkipsBranchesWithoutPR(t *testing.T) { diff --git a/cmd/sync.go b/cmd/sync.go index b0031143..447f7be7 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "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/modify" @@ -14,6 +15,7 @@ import ( type syncOptions struct { remote string + prune bool } func SyncCmd(cfg *config.Config) *cobra.Command { @@ -34,13 +36,19 @@ This command performs a safe, non-interactive synchronization: If a rebase conflict is detected, all branches are restored to their original state and you are advised to run "gh stack rebase" to resolve -conflicts interactively.`, +conflicts interactively. + +Use --prune to delete local branches for merged PRs. Stack metadata is +preserved so that rebase and display logic continue to work correctly. +If you are on a branch that would be pruned, your checkout is moved to +the first active branch in the stack, or the trunk if all are merged.`, RunE: func(cmd *cobra.Command, args []string) error { return runSync(cfg, opts) }, } cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from and push to (defaults to auto-detected remote)") + cmd.Flags().BoolVar(&opts.prune, "prune", false, "Delete local branches for merged PRs") return cmd } @@ -341,7 +349,95 @@ func runSync(cfg *config.Config, opts *syncOptions) error { cfg.Printf("Merged: %s", strings.Join(names, ", ")) } - // --- Step 6: Update base SHAs and save --- + // --- Step 6: Prune merged branches (optional) --- + doPrune := opts.prune + if !doPrune { + // --prune was not provided. If interactive, prompt. + merged := s.MergedBranches() + var prunableCount int + for _, b := range merged { + if git.BranchExists(b.Branch) { + prunableCount++ + } + } + if prunableCount > 0 && cfg.IsInteractive() { + prompt := fmt.Sprintf("Prune %d merged %s?", + prunableCount, plural(prunableCount, "branch", "branches")) + confirmed, err := confirmPrune(cfg, prompt, true) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + // Save state before exiting so PR sync isn't lost. + _ = stack.Save(gitDir, sf) + return ErrSilent + } + // On any other prompt error, skip pruning silently. + } else { + doPrune = confirmed + } + } + } + + if doPrune { + merged := s.MergedBranches() + var prunable []string + for _, b := range merged { + if git.BranchExists(b.Branch) { + prunable = append(prunable, b.Branch) + } + } + + if len(prunable) > 0 { + // If the current branch is being pruned, switch away first. + needsSwitch := false + for _, name := range prunable { + if name == currentBranch { + needsSwitch = true + break + } + } + if needsSwitch { + switchTarget := trunk + for _, b := range s.Branches { + if !b.IsSkipped() { + switchTarget = b.Branch + break + } + } + if err := git.CheckoutBranch(switchTarget); err != nil { + cfg.Warningf("Failed to switch from %s to %s: %v", currentBranch, switchTarget, err) + } else { + currentBranch = switchTarget + } + } + + cfg.Printf("") + pruned := 0 + for _, name := range prunable { + if err := git.DeleteBranch(name, true); err != nil { + cfg.Warningf("Failed to delete %s: %v", name, err) + } else { + cfg.Successf("Pruned %s (merged)", name) + pruned++ + } + } + if pruned > 0 { + cfg.Successf("Pruned %d merged %s", pruned, plural(pruned, "branch", "branches")) + } + } else if opts.prune { + cfg.Printf("") + cfg.Printf("No merged branches to prune") + } + + // Clean up remote-tracking refs for all merged branches, even if + // the local branch was already deleted. This prevents + // `git checkout ` from resurrecting the branch. + for _, b := range merged { + _ = git.DeleteTrackingRef(remote, b.Branch) + } + } + + // --- Step 7: Update base SHAs and save --- updateBaseSHAs(s) if err := stack.Save(gitDir, sf); err != nil { @@ -392,3 +488,12 @@ func short(sha string) string { } return sha } + +// confirmPrune asks the user to confirm pruning via ConfirmFn or a terminal prompt. +func confirmPrune(cfg *config.Config, prompt string, defaultValue bool) (bool, error) { + if cfg.ConfirmFn != nil { + return cfg.ConfirmFn(prompt, defaultValue) + } + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + return p.Confirm(prompt, defaultValue) +} diff --git a/cmd/sync_test.go b/cmd/sync_test.go index e3dc5328..6431ece9 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -1019,3 +1019,452 @@ func TestSync_MergedBranchDeletedFromRemote(t *testing.T) { assert.Equal(t, "main", rebaseOntoCalls[0].newBase) assert.Equal(t, "b1-stored-head-sha", rebaseOntoCalls[0].oldBase) } + +// TestSync_Prune_DeletesMergedBranches verifies that --prune deletes local +// branches for merged PRs while keeping them in the stack metadata. +func TestSync_Prune_DeletesMergedBranches(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 deletedBranches []string + var deletedTrackingRefs []string + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + assert.True(t, force, "should force-delete merged branch") + return nil + } + mock.DeleteTrackingRefFn = func(remote, branch string) error { + deletedTrackingRefs = append(deletedTrackingRefs, remote+"/"+branch) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + 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, []string{"b1"}, deletedBranches) + assert.Equal(t, []string{"origin/b1"}, deletedTrackingRefs, "should delete remote-tracking ref for pruned branch") + assert.Contains(t, output, "Pruned b1 (merged)") + assert.Contains(t, output, "Pruned 1 merged branch") +} + +// TestSync_Prune_SkipsNonExistentBranches verifies that --prune does not +// attempt to delete branches that have already been removed locally. +func TestSync_Prune_SkipsNonExistentBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", Head: "sha-b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { + return name != "b1" // b1 already deleted + } + mock.DeleteBranchFn = func(string, bool) error { + t.Fatal("DeleteBranch should not be called for non-existent branches") + return nil + } + + var deletedTrackingRefs []string + mock.DeleteTrackingRefFn = func(remote, branch string) error { + deletedTrackingRefs = append(deletedTrackingRefs, remote+"/"+branch) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + 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, "No merged branches to prune") + // Tracking ref should still be cleaned up even though local branch is gone + assert.Equal(t, []string{"origin/b1"}, deletedTrackingRefs, "should delete tracking ref even when local branch is already gone") +} + +// TestSync_Prune_SwitchesToLowestUnmergedBranch verifies that when the user is +// on a merged branch being pruned, checkout moves to the lowest active branch. +func TestSync_Prune_SwitchesToLowestUnmergedBranch(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 deletedBranches []string + var checkoutTarget string + + mock := newSyncMock(tmpDir, "b1") // currently on merged branch + mock.BranchExistsFn = func(name string) bool { return true } + mock.CheckoutBranchFn = func(name string) error { + checkoutTarget = name + return nil + } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + 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, []string{"b1"}, deletedBranches) + // Should have switched to b2 (first active branch), not trunk + assert.Equal(t, "b2", checkoutTarget) + assert.Contains(t, output, "Pruned b1 (merged)") +} + +// TestSync_Prune_SwitchesToTrunkWhenAllMerged verifies that when all branches +// are merged, checkout moves to the trunk. +func TestSync_Prune_SwitchesToTrunkWhenAllMerged(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var deletedBranches []string + var checkoutTarget string + + mock := newSyncMock(tmpDir, "b1") // currently on merged branch + mock.BranchExistsFn = func(name string) bool { return true } + mock.CheckoutBranchFn = func(name string) error { + checkoutTarget = name + return nil + } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + 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, []string{"b1", "b2"}, deletedBranches) + // Should have switched to trunk since all branches are merged + assert.Equal(t, "main", checkoutTarget) + assert.Contains(t, output, "Pruned 2 merged branches") +} + +// TestSync_NoPrune_DoesNotDeleteBranches verifies that without --prune, +// merged branches are not deleted (default behavior is unchanged). +func TestSync_NoPrune_DoesNotDeleteBranches(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) + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(string, bool) error { + t.Fatal("DeleteBranch should not be called without --prune") + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + // No --prune flag + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) +} + +// TestSync_Prune_DeleteFailureContinues verifies that a failed branch deletion +// logs a warning and does not abort the sync. +func TestSync_Prune_DeleteFailureContinues(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var deletedBranches []string + + mock := newSyncMock(tmpDir, "b3") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(name string, force bool) error { + if name == "b1" { + return fmt.Errorf("permission denied") + } + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + 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 failed, b2 succeeded + assert.Equal(t, []string{"b2"}, deletedBranches) + assert.Contains(t, output, "Failed to delete b1") + assert.Contains(t, output, "Pruned b2 (merged)") + assert.Contains(t, output, "Pruned 1 merged branch") +} + +// TestSync_InteractivePrune_PromptsAndPrunes verifies that when running in an +// interactive terminal without --prune, the user is prompted and merged branches +// are pruned when they confirm. +func TestSync_InteractivePrune_PromptsAndPrunes(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 deletedBranches []string + var promptShown string + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(prompt string, defaultValue bool) (bool, error) { + promptShown = prompt + assert.True(t, defaultValue, "default should be yes") + return true, nil // user confirms + } + + cmd := SyncCmd(cfg) + // No --prune flag + 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, promptShown, "Prune 1 merged branch") + assert.Equal(t, []string{"b1"}, deletedBranches) + assert.Contains(t, output, "Pruned b1 (merged)") +} + +// TestSync_InteractivePrune_UserDeclines verifies that when the user declines +// the prune prompt, no branches are deleted. +func TestSync_InteractivePrune_UserDeclines(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) + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(string, bool) error { + t.Fatal("DeleteBranch should not be called when user declines") + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { + return false, nil // user declines + } + + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) +} + +// TestSync_NonInteractive_NoPrunePrompt verifies that when the terminal is not +// interactive and --prune is not set, no prompt is shown and no branches are deleted. +func TestSync_NonInteractive_NoPrunePrompt(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) + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(string, bool) error { + t.Fatal("DeleteBranch should not be called in non-interactive mode without --prune") + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + // ForceInteractive is false by default — simulates non-interactive/CI/agent + + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) +} + +// TestSync_ExplicitPrune_SkipsPrompt verifies that --prune flag bypasses the +// interactive prompt and prunes directly. +func TestSync_ExplicitPrune_SkipsPrompt(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 deletedBranches []string + + mock := newSyncMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.DeleteBranchFn = func(name string, force bool) error { + deletedBranches = append(deletedBranches, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { + t.Fatal("ConfirmFn should not be called when --prune is explicit") + return false, nil + } + + cmd := SyncCmd(cfg) + cmd.SetArgs([]string{"--prune"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b1"}, deletedBranches) +} diff --git a/docs/src/content/docs/guides/stacked-prs.md b/docs/src/content/docs/guides/stacked-prs.md index 5be37ce8..1f924931 100644 --- a/docs/src/content/docs/guides/stacked-prs.md +++ b/docs/src/content/docs/guides/stacked-prs.md @@ -49,4 +49,4 @@ gh stack sync - **`gh stack push`** pushes branches only (uses `--force-with-lease` for safety). It does not create or update PRs. - **`gh stack submit`** pushes branches and creates or updates PRs, linking them as a Stack on GitHub. -- **`gh stack sync`** is the all-in-one command: fetch, rebase, push, and sync PR state. +- **`gh stack sync`** is the all-in-one command: fetch, rebase, push, sync PR state, and optionally prune local branches for merged PRs. diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 62ea8be4..601f4a95 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -134,6 +134,7 @@ This command: 3. Rebases all remaining stack branches onto the updated trunk 4. Pushes the updated branches 5. Syncs PR state from GitHub +6. Prompts to prune local branches for merged PRs (use `--prune` to prune automatically) If a conflict is detected during the rebase, all branches are restored to their original state, and you're advised to run `gh stack rebase` to resolve conflicts interactively. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 9cab10b1..5d08fc30 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -295,15 +295,20 @@ Performs a safe, non-interactive synchronization of the entire stack: 3. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state, and you are advised to run `gh stack rebase` to resolve conflicts interactively. 4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred). 5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR. +6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically. | Flag | Description | |------|-------------| | `--remote ` | Remote to fetch from and push to (defaults to auto-detected remote) | +| `--prune` | Delete local branches for merged PRs | **Examples:** ```sh gh stack sync + +# Sync and automatically prune merged branches +gh stack sync --prune ``` ### `gh stack rebase` diff --git a/internal/config/config.go b/internal/config/config.go index e706dd06..8b99f9a2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,10 @@ type Config struct { // 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) + + // ConfirmFn, when non-nil, is called instead of prompting via the + // terminal. Used in tests to simulate yes/no confirmation prompts. + ConfirmFn func(prompt string, defaultValue bool) (bool, error) } // New creates a new Config with terminal-aware output and color support. diff --git a/internal/git/git.go b/internal/git/git.go index 7be063ec..5a2ae52a 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -312,6 +312,11 @@ func DeleteRemoteBranch(remote, branch string) error { return ops.DeleteRemoteBranch(remote, branch) } +// DeleteTrackingRef deletes a local remote-tracking ref (e.g. refs/remotes/origin/branch). +func DeleteTrackingRef(remote, branch string) error { + return ops.DeleteTrackingRef(remote, branch) +} + // ResetHard resets the current branch to the given ref. func ResetHard(ref string) error { return ops.ResetHard(ref) diff --git a/internal/git/gitops.go b/internal/git/gitops.go index a00deac8..9402feed 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -48,6 +48,7 @@ type Ops interface { DiffStatFiles(base, head string) ([]FileDiffStat, error) DeleteBranch(name string, force bool) error DeleteRemoteBranch(remote, branch string) error + DeleteTrackingRef(remote, branch string) error ResetHard(ref string) error SetUpstreamTracking(branch, remote string) error MergeFF(target string) error @@ -485,6 +486,10 @@ func (d *defaultOps) DeleteRemoteBranch(remote, branch string) error { return runSilent("push", remote, "--delete", branch) } +func (d *defaultOps) DeleteTrackingRef(remote, branch string) error { + return runSilent("branch", "-dr", remote+"/"+branch) +} + func (d *defaultOps) ResetHard(ref string) error { return runSilent("reset", "--hard", ref) } diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index ed123c52..c19a0018 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -36,6 +36,7 @@ type MockOps struct { DiffStatFilesFn func(string, string) ([]FileDiffStat, error) DeleteBranchFn func(string, bool) error DeleteRemoteBranchFn func(string, string) error + DeleteTrackingRefFn func(string, string) error ResetHardFn func(string) error SetUpstreamTrackingFn func(string, string) error MergeFFFn func(string) error @@ -287,6 +288,13 @@ func (m *MockOps) DeleteRemoteBranch(remote, branch string) error { return nil } +func (m *MockOps) DeleteTrackingRef(remote, branch string) error { + if m.DeleteTrackingRefFn != nil { + return m.DeleteTrackingRefFn(remote, branch) + } + return nil +} + func (m *MockOps) ResetHard(ref string) error { if m.ResetHardFn != nil { return m.ResetHardFn(ref) diff --git a/internal/tui/modifyview/model.go b/internal/tui/modifyview/model.go index 75cf852f..eff9e795 100644 --- a/internal/tui/modifyview/model.go +++ b/internal/tui/modifyview/model.go @@ -454,12 +454,16 @@ func (m *Model) currentMode() actionMode { return modeNone } -// moveCursor moves the cursor by delta to the next node. +// moveCursor moves the cursor by delta, skipping merged branches. func (m *Model) moveCursor(delta int) { next := m.cursor + delta - if next >= 0 && next < len(m.nodes) { - m.cursor = next - m.ensureVisible() + for next >= 0 && next < len(m.nodes) { + if !m.nodes[next].Ref.IsMerged() { + m.cursor = next + m.ensureVisible() + return + } + next += delta } } @@ -866,6 +870,11 @@ func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { return m, nil } + // Don't allow selecting merged branches. + if m.nodes[result.NodeIndex].Ref.IsMerged() { + return m, nil + } + m.cursor = result.NodeIndex if result.OpenURL != "" { diff --git a/internal/tui/modifyview/model_test.go b/internal/tui/modifyview/model_test.go index 348515ec..6afc13d7 100644 --- a/internal/tui/modifyview/model_test.go +++ b/internal/tui/modifyview/model_test.go @@ -756,3 +756,21 @@ func TestUndoRename_DoesNotAffectOtherRenames(t *testing.T) { require.NotNil(t, m.nodes[0].PendingAction, "first rename should still be intact") assert.Equal(t, "a-renamed", m.nodes[0].PendingAction.NewName, "first rename new name should be unchanged") } + +func TestCursorNavigation_SkipsMergedBranches(t *testing.T) { + nodes := []ModifyBranchNode{ + makeNode("a", true, 0), + makeMergedNode("merged", 1), + makeNode("c", false, 2), + } + m := New(nodes, testTrunk, "1.0.0") + require.Equal(t, 0, m.cursor, "cursor should start on first non-merged") + + // Move down should skip merged and land on c + m = sendKey(t, m, runeKey('j')) + assert.Equal(t, 2, m.cursor, "down should skip merged branch") + + // Move up should skip merged and land back on a + m = sendKey(t, m, runeKey('k')) + assert.Equal(t, 0, m.cursor, "up should skip merged branch") +} diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index 7c7159ce..49cd46f7 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -83,14 +83,24 @@ func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model { h := help.New() h.ShowAll = true - // Cursor starts at the current branch, or top of stack + // Cursor starts at the current branch, or first non-merged branch cursor := 0 + found := false for i, n := range nodes { - if n.IsCurrent { + if n.IsCurrent && !n.Ref.IsMerged() { cursor = i + found = true break } } + if !found { + for i, n := range nodes { + if !n.Ref.IsMerged() { + cursor = i + break + } + } + } return Model{ nodes: nodes, @@ -124,17 +134,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, keys.Up): - if m.cursor > 0 { - m.cursor-- - m.ensureVisible() - } + m.moveCursor(-1) return m, nil case key.Matches(msg, keys.Down): - if m.cursor < len(m.nodes)-1 { - m.cursor++ - m.ensureVisible() - } + m.moveCursor(1) return m, nil case key.Matches(msg, keys.ToggleCommits): @@ -165,7 +169,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Checkout): if m.cursor >= 0 && m.cursor < len(m.nodes) { node := m.nodes[m.cursor] - if !node.IsCurrent { + if !node.IsCurrent && !node.Ref.IsMerged() { m.checkoutBranch = node.Ref.Branch return m, tea.Quit } @@ -226,6 +230,11 @@ func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { return m, nil } + // Don't allow selecting merged branches. + if m.nodes[result.NodeIndex].Ref.IsMerged() { + return m, nil + } + m.cursor = result.NodeIndex if result.OpenURL != "" { @@ -248,6 +257,19 @@ func (m Model) nodeLineCount(idx int) int { return shared.NodeLineCount(toBranchNodeData(m.nodes[idx])) } +// moveCursor moves the cursor by delta, skipping merged branches. +func (m *Model) moveCursor(delta int) { + next := m.cursor + delta + for next >= 0 && next < len(m.nodes) { + if !m.nodes[next].Ref.IsMerged() { + m.cursor = next + m.ensureVisible() + return + } + next += delta + } +} + // ensureVisible adjusts scroll offset so the cursor is visible. func (m *Model) ensureVisible() { if m.height == 0 { diff --git a/internal/tui/stackview/model_test.go b/internal/tui/stackview/model_test.go index fb1774e3..b351d09f 100644 --- a/internal/tui/stackview/model_test.go +++ b/internal/tui/stackview/model_test.go @@ -341,3 +341,50 @@ func TestScrollClamp_CannotScrollPastContent(t *testing.T) { view := m.View() assert.Contains(t, view, "b1", "content should still be visible after excessive scrolling") } + +func TestUpdate_CursorSkipsMergedBranches(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + nodes[1].Ref.PullRequest = &stack.PullRequestRef{Number: 2, Merged: true} + m := New(nodes, testTrunk, "0.0.1") + assert.Equal(t, 0, m.cursor, "cursor should start on first non-merged branch") + + // Down should skip b2 (merged) and land on b3 + updated, _ := m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 2, m.cursor, "down should skip merged b2 and land on b3") + + // Up should skip b2 (merged) and land back on b1 + updated, _ = m.Update(keyMsg("up")) + m = updated.(Model) + assert.Equal(t, 0, m.cursor, "up should skip merged b2 and land on b1") +} + +func TestNew_CursorSkipsMergedBranch(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} + m := New(nodes, testTrunk, "0.0.1") + assert.Equal(t, 1, m.cursor, "cursor should skip merged b1 and start on b2") +} + +func TestNew_CursorSkipsMergedCurrentBranch(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + nodes[0].IsCurrent = true + nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} + m := New(nodes, testTrunk, "0.0.1") + assert.Equal(t, 1, m.cursor, "cursor should not start on merged current branch") +} + +func TestUpdate_EnterOnMergedDoesNothing(t *testing.T) { + // All non-merged so we can navigate, but force cursor onto a merged node + // by having b1 active and b2 merged and b3 active. + nodes := makeNodes("b1", "b2") + nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} + m := New(nodes, testTrunk, "0.0.1") + // Cursor is on b2 (first non-merged). Manually set to b1 to test guard. + m.cursor = 0 + + updated, cmd := m.Update(keyMsg("enter")) + m = updated.(Model) + assert.Equal(t, "", m.CheckoutBranch(), "enter on merged branch should not set checkout") + assert.Nil(t, cmd, "enter on merged branch should not quit") +} diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index 7f7105e5..ded9150b 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.3" + version: "0.0.4" --- # gh-stack @@ -152,6 +152,7 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current | Create PRs as ready for review | `gh stack submit --auto --open` | | Sync (fetch, rebase, push) | `gh stack sync` | | Sync with specific remote | `gh stack sync --remote origin` | +| Sync and prune merged branches | `gh stack sync --prune` | | Rebase entire stack | `gh stack rebase` | | Rebase upstack only | `gh stack rebase --upstack` | | Continue after conflict | `gh stack rebase --continue` | @@ -305,8 +306,13 @@ gh stack push ```bash # Single command: fetch, rebase, push, sync PR state gh stack sync + +# Sync and automatically clean up local branches for merged PRs +gh stack sync --prune ``` +> **Note for agents:** In non-interactive environments, the prune prompt is not shown. Use `--prune` explicitly to delete local branches for merged PRs. + ### Squash-merge recovery When a PR is squash-merged on GitHub, the original branch's commits no longer exist in the trunk history. `gh stack` detects this automatically and uses `git rebase --onto` to correctly replay remaining commits. @@ -615,6 +621,7 @@ gh stack sync [flags] | Flag | Description | |------|-------------| | `--remote ` | Remote to fetch from and push to (use if multiple remotes exist) | +| `--prune` | Delete local branches for merged PRs | **What it does (in order):** @@ -623,6 +630,7 @@ gh stack sync [flags] 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 +6. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to skip the prompt. In non-interactive environments, pruning only happens when `--prune` is passed explicitly **Output (stderr):** @@ -632,6 +640,7 @@ gh stack sync [flags] - `✓ Pushed N branches` - `✓ PR #N () — Open` per branch - `Merged: #N, #M` for merged branches +- `✓ Pruned (merged)` per pruned branch (when pruning) - `✓ Stack synced` ---