diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b705a14bc..ad9156a69 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -125,7 +125,7 @@ { "name": "sc-docling-pdf", "source": "./packages/sc-docling-pdf", - "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required — pure CLI workflow for Claude Code.", + "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required \u2014 pure CLI workflow for Claude Code.\n", "version": "0.1.0", "author": { "name": "randlee" @@ -143,6 +143,25 @@ ], "category": "tools" }, + { + "name": "sc-gh-stack", + "source": "./packages/sc-gh-stack", + "category": "tools", + "description": "Stacked pull requests with the gh-stack GitHub CLI extension, run the way that lands: an append-only, linear stack of frozen layers above a named trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships the sc-gh-stack skill (model, preconditions, recipes, full command guide) and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and landing table). Supersedes the generic gh-stack skill.\n", + "version": "0.1.0", + "author": { + "name": "randlee" + }, + "license": "MIT", + "keywords": [ + "git", + "github", + "gh-stack", + "stacked-prs", + "workflow", + "skills" + ] + }, { "name": "sc-git-worktree", "source": "./packages/sc-git-worktree", @@ -183,7 +202,7 @@ { "name": "sc-just", "source": "./packages/sc-just", - "description": "Set up repo-local just task runners with a curated Justfile, optional .just/ helper scripts, and starter templates for minimal, Python, Go, .NET, and Rust repos.", + "description": "Set up repo-local just task runners with a curated Justfile, optional .just/ helper scripts, and starter templates for minimal, Python, Go, .NET, and Rust repos.\n", "version": "0.1.0", "author": { "name": "randlee" @@ -220,7 +239,7 @@ { "name": "sc-launch-term", "source": "./packages/sc-launch-term", - "description": "Launch Claude, Codex, and Gemini sessions in supported terminals with platform-aware terminal autodetect and optional tmux session management.\n", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), and Gemini sessions in supported terminals with platform-aware autodetect, cmux workspace tabs, and optional tmux session management.\n", "version": "0.12.0", "author": { "name": "randlee" @@ -232,8 +251,13 @@ "macos", "windows", "tmux", + "cmux", "claude", + "fable", "codex", + "sol", + "terra", + "luna", "gemini" ], "category": "tools" @@ -241,7 +265,7 @@ { "name": "sc-launchpad", "source": "./packages/sc-launchpad", - "description": "Launch Claude, Codex, or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", "version": "0.12.0", "author": { "name": "synaptic-canvas" @@ -251,7 +275,11 @@ "background-agents", "claude", "codex", + "sol", + "terra", + "luna", "gemini", + "fable", "atm" ], "category": "tools" @@ -352,21 +380,6 @@ "agents" ], "category": "tools" - }, - { - "name": "sc-observability", - "description": "Skills for bootstrapping, adopting, and reviewing sc-observability in downstream Rust projects.", - "author": { - "name": "randlee" - }, - "category": "tools", - "source": { - "source": "git-subdir", - "url": "https://github.com/randlee/sc-observability.git", - "path": "packages/sc-observability", - "ref": "main" - }, - "homepage": "https://github.com/randlee/sc-observability" } ] } diff --git a/.claude-plugin/registry.json b/.claude-plugin/registry.json index aa70cb209..f6b459410 100644 --- a/.claude-plugin/registry.json +++ b/.claude-plugin/registry.json @@ -21,7 +21,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.185067+00:00" + "lastUpdated": "2026-09-23T04:23:32.767188+00:00" }, { "name": "sc-ci-automation", @@ -38,7 +38,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.186098+00:00" + "lastUpdated": "2026-09-23T04:23:32.768231+00:00" }, { "name": "sc-codex", @@ -55,7 +55,7 @@ "scripts": 2, "schemas": 2 }, - "lastUpdated": "2026-04-29T04:44:47.186834+00:00" + "lastUpdated": "2026-09-23T04:23:32.769032+00:00" }, { "name": "sc-coding-agent-hardening", @@ -72,7 +72,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.187295+00:00" + "lastUpdated": "2026-09-23T04:23:32.769512+00:00" }, { "name": "sc-commit-push-pr", @@ -89,7 +89,7 @@ "scripts": 9, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.187922+00:00" + "lastUpdated": "2026-09-23T04:23:32.770223+00:00" }, { "name": "sc-delay-tasks", @@ -106,24 +106,15 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.188514+00:00" + "lastUpdated": "2026-09-23T04:23:32.770829+00:00" }, { "name": "sc-docling-pdf", "version": "0.1.0", - "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required — pure CLI workflow for Claude Code.", + "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required \u2014 pure CLI workflow for Claude Code.\n", "author": "randlee", "license": "MIT", - "keywords": [ - "pdf", - "docling", - "conversion", - "markdown", - "ocr", - "images", - "tables", - "datasheets" - ], + "keywords": [], "category": "tools", "artifacts": { "commands": 0, @@ -132,7 +123,24 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-05-14T00:00:00.000000+00:00" + "lastUpdated": "2026-09-23T04:23:32.771669+00:00" + }, + { + "name": "sc-gh-stack", + "version": "0.1.0", + "description": "Stacked pull requests with the gh-stack GitHub CLI extension, run the way that lands: an append-only, linear stack of frozen layers above a named trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships the sc-gh-stack skill (model, preconditions, recipes, full command guide) and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and landing table). Supersedes the generic gh-stack skill.\n", + "author": "randlee", + "license": "MIT", + "keywords": [], + "category": "tools", + "artifacts": { + "commands": 2, + "skills": 2, + "agents": 0, + "scripts": 3, + "schemas": 0 + }, + "lastUpdated": "2026-09-23T04:23:32.772546+00:00" }, { "name": "sc-git-worktree", @@ -149,7 +157,7 @@ "scripts": 7, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.189493+00:00" + "lastUpdated": "2026-09-23T04:23:32.773480+00:00" }, { "name": "sc-github-issue", @@ -166,7 +174,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.190573+00:00" + "lastUpdated": "2026-09-23T04:23:32.774495+00:00" }, { "name": "sc-just", @@ -183,7 +191,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-05-15T21:50:55.867734+00:00" + "lastUpdated": "2026-09-23T04:23:32.775479+00:00" }, { "name": "sc-kanban", @@ -200,7 +208,7 @@ "scripts": 5, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.191285+00:00" + "lastUpdated": "2026-09-23T04:23:32.776157+00:00" }, { "name": "sc-launch-term", @@ -211,13 +219,13 @@ "keywords": [], "category": "tools", "artifacts": { - "commands": 9, + "commands": 0, "skills": 0, "agents": 0, "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-08-09T14:54:36.245664" + "lastUpdated": "2026-09-23T04:23:32.776793+00:00" }, { "name": "sc-launchpad", @@ -234,7 +242,7 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-08-09T14:54:36.395961" + "lastUpdated": "2026-09-23T04:23:32.777456+00:00" }, { "name": "sc-manage", @@ -251,7 +259,7 @@ "scripts": 8, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.193173+00:00" + "lastUpdated": "2026-09-23T04:23:32.778108+00:00" }, { "name": "sc-repomix-nuget", @@ -268,7 +276,7 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.193840+00:00" + "lastUpdated": "2026-09-23T04:23:32.780173+00:00" }, { "name": "sc-roslyn-diff", @@ -285,7 +293,7 @@ "scripts": 6, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.194536+00:00" + "lastUpdated": "2026-09-23T04:23:32.781191+00:00" }, { "name": "sc-rust", @@ -302,7 +310,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.195728+00:00" + "lastUpdated": "2026-09-23T04:23:32.782313+00:00" }, { "name": "sc-startup", @@ -319,17 +327,17 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-04-29T04:44:47.196460+00:00" + "lastUpdated": "2026-09-23T04:23:32.783003+00:00" } ], "metadata": { - "totalPackages": 18, - "totalCommands": 19, - "totalSkills": 21, + "totalPackages": 19, + "totalCommands": 12, + "totalSkills": 23, "totalAgents": 45, - "totalScripts": 50, + "totalScripts": 53, "totalSchemas": 2 }, - "generated": "2026-08-09T14:54:36.395998", - "lastUpdated": "2026-08-09T14:54:36.396000" + "generated": "2026-09-23T04:23:32.783016+00:00", + "lastUpdated": "2026-09-23T04:23:32.783017+00:00" } diff --git a/docs/registries/nuget/registry.json b/docs/registries/nuget/registry.json index fafcbfe64..0c51164e8 100644 --- a/docs/registries/nuget/registry.json +++ b/docs/registries/nuget/registry.json @@ -1,7 +1,7 @@ { "$schema": "https://yourcompany.github.io/schemas/package-registry.schema.json", "version": "2.0.0", - "generated": "2026-05-15T21:50:55Z", + "generated": "2026-09-23T04:23:32Z", "repo": "randlee/synaptic-canvas", "marketplace": { "name": "Synaptic Canvas", @@ -45,7 +45,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-ci-automation/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-codex": { @@ -82,7 +82,7 @@ "pyyaml" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-codex/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-delay-tasks": { @@ -119,7 +119,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-delay-tasks/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-docling-pdf": { @@ -127,7 +127,7 @@ "version": "0.1.0", "status": "beta", "tier": 0, - "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required — pure CLI workflow for Claude Code.", + "description": "Convert PDF documents to markdown and structured output using the docling CLI. Selects the optimal conversion profile based on document content: clean text, scanned/OCR, rich datasheets with images and tables, complex layouts via VLM, or technical documents with code and formulas. Extracts images as referenced PNG files for viewing. No MCP required \u2014 pure CLI workflow for Claude Code.\n", "github": "randlee/synaptic-canvas", "repo": "https://github.com/randlee/synaptic-canvas", "path": "packages/sc-docling-pdf", @@ -154,11 +154,15 @@ "schemas": 0 }, "dependencies": [ - "python3", - "docling" + "python >= 3.10", + "docling >= 2.90.0", + "poppler", + "docling[easyocr,vlm]", + "peft >= 0.18.1", + "transformers < 5.5" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-docling-pdf/CHANGELOG.md", - "lastUpdated": "2026-05-14", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-git-worktree": { @@ -201,7 +205,7 @@ "git >= 2.20" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-git-worktree/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [ "sc-github-issue" ] @@ -241,7 +245,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-github-issue/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-launch-term": { @@ -249,7 +253,7 @@ "version": "0.12.0", "status": "beta", "tier": 0, - "description": "Launch Claude, Codex, and Gemini sessions in supported terminals with platform-aware terminal autodetect and optional tmux session management.\n", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), and Gemini sessions in supported terminals with platform-aware autodetect, cmux workspace tabs, and optional tmux session management.\n", "github": "randlee/synaptic-canvas", "repo": "https://github.com/randlee/synaptic-canvas", "path": "packages/sc-launch-term", @@ -264,8 +268,13 @@ "macos", "windows", "tmux", + "cmux", "claude", + "fable", "codex", + "sol", + "terra", + "luna", "gemini" ], "artifacts": { @@ -279,7 +288,7 @@ "Python 3 launcher on PATH (`python3`, `py -3`, or `python`)" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-launch-term/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-kanban": { @@ -318,7 +327,7 @@ "sc-git-worktree>=0.5.2" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-kanban/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-launchpad": { @@ -326,7 +335,7 @@ "version": "0.12.0", "status": "beta", "tier": 0, - "description": "Launch Claude, Codex, or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", + "description": "Launch Claude (including Fable), Codex (including Sol, Terra, and Luna), or Gemini as a separate background sub-agent runtime, with explicit ATM teammate-mode normalization and roster registration.\n", "github": "randlee/synaptic-canvas", "repo": "https://github.com/randlee/synaptic-canvas", "path": "packages/sc-launchpad", @@ -338,7 +347,11 @@ "background-agents", "claude", "codex", + "sol", + "terra", + "luna", "gemini", + "fable", "atm" ], "artifacts": { @@ -355,7 +368,7 @@ "gemini", "pydantic" ], - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [], "readme": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-launchpad/README.md", "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-launchpad/CHANGELOG.md" @@ -393,7 +406,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-manage/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-repomix-nuget": { @@ -436,7 +449,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-repomix-nuget/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-roslyn-diff": { @@ -477,7 +490,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-roslyn-diff/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-startup": { @@ -516,7 +529,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-startup/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-commit-push-pr": { @@ -555,7 +568,7 @@ "gh" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-commit-push-pr/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-rust": { @@ -596,7 +609,7 @@ "sc-compose" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-rust/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-ai-cli": { @@ -631,7 +644,7 @@ }, "dependencies": [], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-ai-cli/CHANGELOG.md", - "lastUpdated": "2026-04-29", + "lastUpdated": "2026-09-23", "dependents": [] }, "sc-coding-agent-hardening": { @@ -666,7 +679,7 @@ "schemas": 0 }, "dependencies": [], - "lastUpdated": "2026-04-29" + "lastUpdated": "2026-09-23" }, "sc-just": { "name": "sc-just", @@ -702,7 +715,45 @@ "just >= 1.0", "python3 >= 3.11" ], - "lastUpdated": "2026-05-15" + "lastUpdated": "2026-09-23" + }, + "sc-gh-stack": { + "name": "sc-gh-stack", + "status": "beta", + "tier": 2, + "github": "randlee/synaptic-canvas", + "repo": "https://github.com/randlee/synaptic-canvas", + "path": "packages/sc-gh-stack", + "readme": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-gh-stack/README.md", + "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-gh-stack/CHANGELOG.md", + "dependents": [], + "version": "0.1.0", + "description": "Stacked pull requests with the gh-stack GitHub CLI extension, run the way that lands: an append-only, linear stack of frozen layers above a named trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships the sc-gh-stack skill (model, preconditions, recipes, full command guide) and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and landing table). Supersedes the generic gh-stack skill.\n", + "license": "MIT", + "author": { + "name": "randlee" + }, + "tags": [ + "git", + "github", + "gh-stack", + "stacked-prs", + "workflow", + "skills" + ], + "artifacts": { + "commands": 2, + "skills": 2, + "agents": 0, + "scripts": 3, + "schemas": 0 + }, + "dependencies": [ + "gh >= 2.0", + "git >= 2.38", + "python3 >= 3.9" + ], + "lastUpdated": "2026-09-23" } }, "metadata": { diff --git a/packages/sc-gh-stack/.claude-plugin/plugin.json b/packages/sc-gh-stack/.claude-plugin/plugin.json new file mode 100644 index 000000000..ee74fd739 --- /dev/null +++ b/packages/sc-gh-stack/.claude-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "sc-gh-stack", + "description": "Stacked pull requests with the gh-stack GitHub CLI extension, run the way that lands: an append-only, linear stack of frozen layers above a named trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships the sc-gh-stack skill (model, preconditions, recipes, full command guide) and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and landing table). Supersedes the generic gh-stack skill.", + "version": "0.1.0", + "author": { + "name": "randlee" + }, + "license": "MIT", + "keywords": [ + "git", + "github", + "gh-stack", + "stacked-prs", + "workflow", + "skills" + ], + "commands": [ + "./commands/sc-gh-stack.md", + "./commands/sc-gh-stack-view.md" + ], + "skills": [ + "./skills/sc-gh-stack/SKILL.md", + "./skills/sc-gh-stack-view/SKILL.md" + ] +} diff --git a/packages/sc-gh-stack/CHANGELOG.md b/packages/sc-gh-stack/CHANGELOG.md new file mode 100644 index 000000000..6c53ecfa9 --- /dev/null +++ b/packages/sc-gh-stack/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to the **sc-gh-stack** package will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-09-23 + +### Added +- `sc-gh-stack` skill: the append-only stack model, lifecycle workflow, + preconditions distilled from five production phases (2026-09-05 to + 2026-09-23, stacks of 2 to 21 PRs), and recipes for cutting a layer, + linking, restacking (insert, remove a red layer, collapse the bottom), + landing (atomic merge plus merge-async and non-linear fallbacks) and + clearing stale per-worktree tracking; a phase-model worked example. +- Full `gh stack` v0.1.0 command guide, troubleshooting table and stack-design + guidance carried over from the retired generic `gh-stack` skill, with + field-verified overrides. +- `sc-gh-stack-view` skill and `gh_stack_view.py`: one-call coherence, + mergeability, CI and LANDING table for every open stack (ported from + atm-core; default view now shows every open stack on any trunk). +- `gh_stack_chain_check.py`: read-only pre-link check (pushed heads, linear + ancestry, PR state and bases, clean merge into trunk) that prints the exact + `gh stack link --base` command to run next. +- `/sc-gh-stack` and `/sc-gh-stack-view` commands. +- `gh_stack_shared.py`: the stdlib subprocess and git lookup helpers both scripts share. +- Unit tests for the scripts (real-git and mocked; every exit path). + +### Notes +- Written against gh-stack extension v0.1.0. Re-verify `unstack --local`, + `link ` and `merge` flags with `--help` if the extension moves. +- This package replaces the earlier `managing-gh-stacks` attempt (PR #101), + whose rules contradicted the field lessons. diff --git a/packages/sc-gh-stack/LICENSE b/packages/sc-gh-stack/LICENSE new file mode 100644 index 000000000..8d188336e --- /dev/null +++ b/packages/sc-gh-stack/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Rand Lee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sc-gh-stack/README.md b/packages/sc-gh-stack/README.md new file mode 100644 index 000000000..1edcdef0d --- /dev/null +++ b/packages/sc-gh-stack/README.md @@ -0,0 +1,117 @@ +# sc-gh-stack + +[![Publisher Verified](https://img.shields.io/badge/publisher-verified-brightgreen)](https://github.com/randlee/synaptic-canvas/blob/main/docs/PUBLISHER-VERIFICATION.md) +[![Security Scanned](https://img.shields.io/badge/security-scanned-blue)](https://github.com/randlee/synaptic-canvas/blob/main/SECURITY.md) +[![License MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![Version 0.1.0](https://img.shields.io/badge/version-0.1.0-blue)](CHANGELOG.md) + +Scope: Local or global +Requires: `gh` >= 2.0 with the `gh-stack` extension (v0.1.0), `git` >= 2.38, `python3` >= 3.9; `jq` optional (documented one-liners only) + +Stacked pull requests with the `gh stack` GitHub CLI extension, run the way +that actually lands: an append-only, linear stack of frozen layers above a +named trunk, one stack writer, QA and CI on the top only, one atomic merge. +Two skills: `sc-gh-stack` (the model, preconditions, recipes and the full +command guide) and `sc-gh-stack-view` (the one-call status table). This +package supersedes the generic `gh-stack` skill; uninstall that one to avoid +duplicate guidance. + +Security: See [SECURITY.md](../../SECURITY.md) for security policy and practices. + +## Summary + +Every rule in this package was paid for during five production phases of +stacked development (stacks of 2 to 21 PRs). The skill is a table of +contents that points to one reference per situation: + +| Situation | Reference | +|-----------|-----------| +| The model and vocabulary | `references/model.md` | +| Lifecycle of a layer | `references/workflow.md` | +| Checks before every write | `references/preconditions.md` | +| Cut a layer, link, restack, land, stale tracking | `references/recipe-*.md` | +| Layer boundaries and naming | `references/stack-design.md` | +| Every `gh stack` command, flag, exit code, JSON schema | `references/commands.md` | +| Error signatures and fixes | `references/troubleshooting.md` | +| A whole phase on one stack | `references/phase-model-example.md` | + +## Quick Start + +1. Install into a repo: + ```bash + python3 tools/sc-install.py install sc-gh-stack --dest /path/to/your-repo/.claude + ``` +2. Make sure the extension is present: + ```bash + gh extension install github/gh-stack + ``` +3. Status of every open stack (read-only, paste verbatim): + ``` + /sc-gh-stack-view + ``` +4. Before linking a proposed order: + ``` + /sc-gh-stack --check develop fix/a fix/b docs/c + ``` + +## Usage + +- `/sc-gh-stack-view [--trunk ] [--all] [--json]` +- `/sc-gh-stack --status | --check | --cut | --link | --restack | --land` + +Scripts (installed under `.claude/scripts/`). Both are read-only apart from one `git fetch origin` (skip it with `--no-fetch`); neither runs a `gh stack` write command: + +| Script | Purpose | Exit codes | +|--------|---------|-----------| +| `gh_stack_view.py` | Coherence (base == parent head), origin vs local vs PR head, `needsRebase`, `mergeStateStatus`, CI rollup, LANDING verdict | 0 coherent, 1 problems, 2 environment | +| `gh_stack_chain_check.py --trunk ... ` | Pre-link check: pushed heads, linear ancestry, PR state and bases, clean merge into trunk; prints the exact link command | 0 linkable, 1 problems, 2 environment | + +## The model in one paragraph + +Trunk is the branch the stack lands on (`develop`, `integrate/phase-N`, +whatever you name). Every unit of work is a new worktree cut from the current +pushed top; its PR opens on the first push with base = the layer below and is +linked at once with `gh stack link --base ...`. A layer is frozen when +its task closes; findings on it are fixed on a new layer above the top. One +writer per branch, one stack writer for every `gh stack` write. QA and CI gate +the top only. The stack lands once with `gh stack merge --yes --merge`. Small +fixes with one owner do not get a stack at all. + +## Safety + +- The scripts never run a `gh stack` write command. +- The skill confirms landing, closing PRs and unstacking with the user unless + the user directed that exact action. +- Never `--squash`, never `gh pr merge --admin`, never edit rulesets. + +## Storage + +Installs into `.claude/` only (commands, skills, scripts). Writes no logs, settings, state or output under `.claude/state/` or `.sc/`. Stack tracking is gh-stack's own per-worktree state; see `references/recipe-stale-tracking.md`. + +## Install / Uninstall + +```bash +python3 tools/sc-install.py install sc-gh-stack --dest /path/to/your-repo/.claude +python3 tools/sc-install.py uninstall sc-gh-stack --dest /path/to/your-repo/.claude +``` + +## Tests + +```bash +python3 -m pytest packages/sc-gh-stack/tests -q +``` + +## Troubleshooting + +- "no open gh stack found": a stack is discovered through `git worktree list` + and needs at least one layer checked out in a worktree. +- 🔄 or a false NOT COHERENT with `-` rows after an unstack: stale per-worktree + tracking; see `references/recipe-stale-tracking.md`. +- Anything else: `references/troubleshooting.md` and + `references/installation-and-troubleshooting.md`. + +## Components + +- Commands: `commands/sc-gh-stack.md`, `commands/sc-gh-stack-view.md` +- Skills: `skills/sc-gh-stack/SKILL.md` (+ `references/`), `skills/sc-gh-stack-view/SKILL.md` +- Scripts: `scripts/gh_stack_view.py`, `scripts/gh_stack_chain_check.py` diff --git a/packages/sc-gh-stack/commands/sc-gh-stack-view.md b/packages/sc-gh-stack/commands/sc-gh-stack-view.md new file mode 100644 index 000000000..0ea6a8554 --- /dev/null +++ b/packages/sc-gh-stack/commands/sc-gh-stack-view.md @@ -0,0 +1,48 @@ +--- +name: sc-gh-stack-view +description: Print the one-call coherence, mergeability, CI and LANDING table for every open gh stack (read-only). Paste the script output verbatim. +version: 0.1.0 +options: + - name: --trunk + description: Only stacks whose trunk is this branch (e.g. develop, integrate/phase-bc). + - name: --phase + description: Shorthand for --trunk integrate/phase-. + - name: --all + description: Show every stack, including merged/closed ones. + - name: --no-fetch + description: Skip git fetch origin; rebase column shows ❓. + - name: --no-pr + description: Skip the GraphQL PR query (offline / local-only view). + - name: --json + description: Emit stacks[].rows[], problems[], notes[], landing, coherent as JSON. + - name: --help + description: Show options. +--- + +# /sc-gh-stack-view command + +Read-only status table for one or more `gh stack`s (under a plugin install substitute `$CLAUDE_PLUGIN_ROOT/scripts/` for `.claude/scripts/`). Runs: + +```bash +python3 .claude/scripts/gh_stack_view.py +``` + +from the repo root, passing through whichever of `--trunk`, `--phase`, +`--all`, `--no-fetch`, `--no-pr`, `--json` were given. If the script is not +found at that path, locate it with `find .claude ~/.claude -name gh_stack_view.py` +and use that path instead. + +Paste stdout **verbatim and unfenced** (no ``` around it) so the markdown +table renders. Do not reformat, summarize, or replace it with per-branch +`gh pr view` calls. + +Exit codes: + +| Code | Meaning | +|------|---------| +| 0 | every shown stack coherent | +| 1 | problems listed under a VERDICT — report them, do not silently retry | +| 2 | nothing to show or the environment failed — show the single stderr line (`gh-stack-view: ...`) verbatim; it names the next action | + +See the `sc-gh-stack-view` skill for the column legend and full output +walkthrough. diff --git a/packages/sc-gh-stack/commands/sc-gh-stack.md b/packages/sc-gh-stack/commands/sc-gh-stack.md new file mode 100644 index 000000000..e7c146956 --- /dev/null +++ b/packages/sc-gh-stack/commands/sc-gh-stack.md @@ -0,0 +1,59 @@ +--- +name: sc-gh-stack +description: Run stacked PRs with gh-stack the way that lands (append-only layers, one stack writer, QA/CI on the top, one atomic merge). Routes to the sc-gh-stack skill and its recipes. +version: 0.1.0 +options: + - name: --status + description: Run /sc-gh-stack-view and paste the coherence, mergeability, CI and LANDING table verbatim. + - name: --check + args: + - name: trunk + description: Branch the stack lands on (e.g. develop, integrate/phase-bc). + - name: layers + description: Proposed order bottom to top, branch names or PR numbers. + description: Read-only pre-link chain check (gh_stack_chain_check.py); prints the exact link command to run next. + - name: --cut + args: + - name: layer + description: New branch name for the layer. + description: Walk the cut-a-layer recipe (new worktree from the pushed top, PR on first push, link). + - name: --link + description: Walk the link recipe (append to the stack, or the full ordered link with --base). + - name: --restack + description: Walk the restack recipe (insert a layer, remove a red layer whose fix is above, collapse the bottom). + - name: --land + description: Walk the landing checklist and the atomic merge, with the merge-async and non-linear fallbacks. + - name: --help + description: Show options and the recipe index. +--- + +# /sc-gh-stack command + +Delegates to the `sc-gh-stack` skill. For `--cut`, `--link`, `--restack` and +`--land`, start with the skill's Step 1 (CLI verification) and Step 2 +(`/sc-gh-stack-view`), then open only the reference the option maps to. +`--status` and `--check` are themselves the status calls (Step 1 only); +`--help` runs nothing. + +| Option | Reference in `skills/sc-gh-stack/references/` | +|--------|-----------------------------------------------| +| `--status` | run `python3 .claude/scripts/gh_stack_view.py [--trunk ]` (or `$CLAUDE_PLUGIN_ROOT/scripts/...` under a plugin install), paste stdout verbatim and unfenced | +| `--check ` | run `python3 .claude/scripts/gh_stack_chain_check.py --trunk `, paste stdout verbatim | +| `--cut ` | `recipe-cut-layer.md`, then `recipe-link.md` | +| `--link` | `recipe-link.md` | +| `--restack` | `recipe-restack.md`, then `recipe-stale-tracking.md` | +| `--land` | `recipe-land.md` | +| no option / `--help` | print this table and the one-paragraph model from `SKILL.md`; no repository chatter | + +Rules the command enforces regardless of option: + +- Every `gh stack` write command (`link`, `unstack`, `sync`, `rebase`, + `merge`) is run only by the stack writer, only after `/sc-gh-stack-view`, + and is followed by `/sc-gh-stack-view` again. +- Creating or re-creating a stack is one `gh stack link --base ` with + the full ordered list; `gh stack link ` only appends on top. + Landing is `gh stack merge --yes --merge`, never `--squash`. +- Landing, closing PRs and unstacking are confirmed with the user unless the + user already directed that exact action. +- No tool traces in the reply: the pasted script output, the verdict, and + the next command. diff --git a/packages/sc-gh-stack/manifest.yaml b/packages/sc-gh-stack/manifest.yaml new file mode 100644 index 000000000..5c2a8c1ef --- /dev/null +++ b/packages/sc-gh-stack/manifest.yaml @@ -0,0 +1,56 @@ +name: sc-gh-stack +version: 0.1.0 +description: > + Stacked pull requests with the gh-stack GitHub CLI extension, run the way + that lands: an append-only, linear stack of frozen layers above a named + trunk, one stack writer, QA and CI on the top only, one atomic merge. Ships + the sc-gh-stack skill (model, preconditions, recipes, full command guide) + and the sc-gh-stack-view skill (one-call coherence, mergeability, CI and + landing table). Supersedes the generic gh-stack skill. +author: randlee +license: MIT +tags: + - git + - github + - gh-stack + - stacked-prs + - workflow + - skills + +# Files to install (relative to package root) +artifacts: + commands: + - commands/sc-gh-stack.md + - commands/sc-gh-stack-view.md + skills: + - skills/sc-gh-stack/SKILL.md + - skills/sc-gh-stack/references/model.md + - skills/sc-gh-stack/references/workflow.md + - skills/sc-gh-stack/references/preconditions.md + - skills/sc-gh-stack/references/recipe-cut-layer.md + - skills/sc-gh-stack/references/recipe-link.md + - skills/sc-gh-stack/references/recipe-restack.md + - skills/sc-gh-stack/references/recipe-land.md + - skills/sc-gh-stack/references/recipe-stale-tracking.md + - skills/sc-gh-stack/references/phase-model-example.md + - skills/sc-gh-stack/references/stack-design.md + - skills/sc-gh-stack/references/commands.md + - skills/sc-gh-stack/references/troubleshooting.md + - skills/sc-gh-stack/references/installation-and-troubleshooting.md + - skills/sc-gh-stack-view/SKILL.md + - skills/sc-gh-stack-view/references/installation-and-troubleshooting.md + scripts: + - scripts/gh_stack_shared.py + - scripts/gh_stack_view.py + - scripts/gh_stack_chain_check.py + +# Runtime requirements (scripts are stdlib-only Python 3) +requires: + cli: + - gh >= 2.0 + - git >= 2.38 + - python3 >= 3.9 + gh_extensions: + - github/gh-stack >= 0.1.0 # `gh extension install github/gh-stack`; checked in SKILL.md Step 1 + optional: + - jq # only in documented shell one-liners, not used by the scripts diff --git a/packages/sc-gh-stack/scripts/gh_stack_chain_check.py b/packages/sc-gh-stack/scripts/gh_stack_chain_check.py new file mode 100755 index 000000000..551359df8 --- /dev/null +++ b/packages/sc-gh-stack/scripts/gh_stack_chain_check.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Pre-link chain check for a proposed gh stack (read-only). + +Installed as ``.claude/scripts/gh_stack_chain_check.py`` by the sc-gh-stack +package. Given a trunk and the intended layer order (bottom to top, branch +names or PR numbers), it verifies what the stack writer otherwise checks by +hand before every ``gh stack link``: + +* every layer head is pushed (``origin/`` exists); +* each layer contains its parent's pushed head (``git merge-base + --is-ancestor``), so the chain is linear; +* the PR for each layer, if one exists, is open, not a draft, its head is the + pushed head, and its base is the expected parent (the trunk for the bottom); +* the top merges clean into the trunk (``git merge-tree --write-tree``, exit + code only; never the legacy 3-arg form). + +Three data sources: ``git rev-parse``/``merge-base``/``merge-tree`` after one +fetch, and ONE ``gh pr list`` call. Never runs a ``gh stack`` write command. + +Exit codes: 0 chain is linkable, 1 problems listed under VERDICT, 2 the +environment failed (one ``gh-stack-chain-check: ...`` line on stderr). +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from gh_stack_shared import ToolError, guarded, is_ancestor, origin_sha, run, short # noqa: E402 + + +def merge_clean(base_sha: str, head_sha: str) -> bool | None: + """True/False from ``git merge-tree --write-tree``; None when git is too old.""" + proc = run(["git", "merge-tree", "--write-tree", base_sha, head_sha], check=False) + if proc.returncode == 0: + return True + if proc.returncode == 1: + return False + return None # usage error: git < 2.38 or an unexpected failure + + +PR_FIELDS = "number,headRefName,baseRefName,headRefOid,isDraft,state" + + +def list_prs() -> list[dict]: + """One call: every OPEN PR (bounded; closed/merged PRs cannot be linked anyway).""" + proc = run(["gh", "pr", "list", "--state", "open", "--limit", "500", "--json", PR_FIELDS]) + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise ToolError(f"gh pr list returned non-JSON: {proc.stdout[:200]!r}") from exc + if not isinstance(data, list): + raise ToolError("gh pr list returned an unexpected shape; run `gh auth status`") + return data + + +def index_prs(prs: list[dict]) -> tuple[dict[int, dict], dict[str, dict]]: + """Map PR number -> PR and head branch -> the OPEN PR for it (else newest).""" + prs = [p for p in prs if isinstance(p, dict) and isinstance(p.get("number"), int) and isinstance(p.get("headRefName"), str)] + by_number = {int(p["number"]): p for p in prs} + by_branch: dict[str, dict] = {} + for p in sorted(prs, key=lambda p: int(p["number"])): + cur = by_branch.get(p["headRefName"]) + if cur is None or (cur.get("state") != "OPEN" and p.get("state") == "OPEN"): + by_branch[p["headRefName"]] = p + elif cur.get("state") == p.get("state"): + by_branch[p["headRefName"]] = p # newest wins among equals + return by_number, by_branch + + +def view_pr(number: int) -> dict: + """Exact lookup for a PR number that is not in the open list (closed, merged, or beyond the page).""" + proc = run(["gh", "pr", "view", str(number), "--json", PR_FIELDS], check=False) + if proc.returncode != 0: + raise ToolError(f"PR #{number} not found (`gh pr view {number}` failed); pass a branch name instead") + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise ToolError(f"gh pr view {number} returned non-JSON: {proc.stdout[:200]!r}") from exc + if not isinstance(data, dict) or not isinstance(data.get("headRefName"), str): + raise ToolError(f"gh pr view {number} returned an unexpected shape; upgrade gh or pass the branch name") + return data + + +def resolve_layers(args: list[str], by_number: dict[int, dict], by_branch: dict[str, dict] | None = None) -> list[str]: + """Turn PR numbers into head branch names; branch names pass through. + + A number missing from the open list is fetched individually so a closed or + merged PR is reported by ``evaluate`` as a problem instead of "not found". + """ + layers: list[str] = [] + for arg in args: + if arg.isdigit(): + pr = by_number.get(int(arg)) + if pr is None: + pr = view_pr(int(arg)) + by_number[int(arg)] = pr + if by_branch is not None: + by_branch.setdefault(pr["headRefName"], pr) + layers.append(pr["headRefName"]) + else: + layers.append(arg) + return layers + + +def evaluate(trunk: str, layers: list[str], by_branch: dict[str, dict], *, fetched: bool, use_pr: bool) -> dict: + """Pure chain evaluation over injected lookups (origin_sha, is_ancestor, merge_clean).""" + rows: list[dict] = [] + problems: list[str] = [] + notes: list[str] = [] + trunk_sha = origin_sha(trunk) if fetched else None + if fetched and trunk_sha is None: + raise ToolError(f"origin/{trunk} does not exist; pass the trunk branch name exactly as on origin") + if len(layers) != len(set(layers)): + problems.append("a branch appears twice in the proposed order") + if trunk in layers: + problems.append(f"{trunk} is the trunk; it is never a layer (drop it from the list)") + if any(not n.strip() for n in layers): + problems.append("an empty branch name was passed") + parent_name = trunk + parent_sha = trunk_sha + for idx, name in enumerate(layers, start=1): + sha = origin_sha(name) if fetched else None + pr = by_branch.get(name) if use_pr else None + row = {"layer": idx, "branch": name, "origin": sha, "pr": pr.get("number") if pr else None, + "pushed": None if not fetched else sha is not None, "contains_parent": None, + "pr_base_ok": None, "pr_head_ok": None, "draft": bool(pr and pr.get("isDraft")), + "pr_state": pr.get("state") if pr else None, "expected_base": parent_name} + if fetched and sha is None: + problems.append(f"L{idx} {name}: not on origin (unpushed) -> push it before linking") + elif fetched and parent_sha: + row["contains_parent"] = is_ancestor(parent_sha, sha) + if not row["contains_parent"]: + if idx == 1: + if is_ancestor(sha, parent_sha): + problems.append(f"L1 {name}: has no commits beyond {trunk} (already merged or empty)") + else: + notes.append(f"L1 {name}: behind {trunk} ({short(sha)} does not contain {short(parent_sha)}); fine unless CONFLICTING, do not rebase just to catch up") + row["contains_parent"] = None + else: + problems.append(f"L{idx} {name}: does not contain parent {parent_name} @ {short(parent_sha)} -> cut from a stale head or a fork; declare a merge-forward or reorder") + if pr: + if pr.get("state") != "OPEN": + problems.append(f"L{idx} {name}: PR #{pr['number']} is {pr.get('state')} -> a merged/closed PR cannot be linked; open a new one") + if pr.get("isDraft"): + problems.append(f"L{idx} {name}: PR #{pr['number']} is DRAFT -> blocks gh stack merge; mark ready before landing") + base_ok = pr.get("baseRefName") == parent_name + row["pr_base_ok"] = base_ok + if not base_ok: + notes.append(f"L{idx} {name}: PR #{pr['number']} base is {pr.get('baseRefName')}, expected {parent_name}; `gh stack link --base {trunk} ...` corrects it, verify afterwards") + if fetched and sha: + head_ok = pr.get("headRefOid") == sha + row["pr_head_ok"] = head_ok + if not head_ok: + problems.append(f"L{idx} {name}: PR #{pr['number']} head {short(pr.get('headRefOid'))} != origin {short(sha)} -> stale PR head; wait for GitHub or re-push") + elif use_pr: + notes.append(f"L{idx} {name}: no PR yet; `gh stack link` creates one with base {parent_name}") + rows.append(row) + parent_name = name + parent_sha = sha or parent_sha + landing: dict = {"clean": None, "reason": "not judged"} + if fetched and rows and rows[-1]["origin"] and trunk_sha and not any(r["pushed"] is False for r in rows): + result = merge_clean(trunk_sha, rows[-1]["origin"]) + if result is None: + landing = {"clean": None, "reason": "git merge-tree --write-tree unavailable (git < 2.38); check with a scratch `git merge --no-commit`"} + elif result: + landing = {"clean": True, "reason": f"top {rows[-1]['branch']} @ {short(rows[-1]['origin'])} merges clean into {trunk} @ {short(trunk_sha)}"} + else: + landing = {"clean": False, "reason": f"top {rows[-1]['branch']} conflicts with {trunk}; resolve on a new top layer, never on a frozen one"} + problems.append(f"top {rows[-1]['branch']}: merge into {trunk} conflicts") + report = {"trunk": trunk, "trunk_origin": trunk_sha, "rows": rows, "problems": problems, + "notes": notes, "landing": landing, "linkable": not problems} + report["link_command"] = link_command(report) if report["linkable"] and rows else None + return report + + +def icon(value: bool | None) -> str: + return {True: "✅", False: "⛔", None: "❓"}[value] + + +def render(report: dict) -> str: + lines = [f"chain: {' -> '.join(r['branch'] for r in report['rows'])} -> {report['trunk']} @ {short(report['trunk_origin'])}", ""] + hdr = ["L", "branch", "PR", "pushed", "contains parent", "PR base", "PR head"] + lines.append("| " + " | ".join(hdr) + " |") + lines.append("|" + "|".join("---" for _ in hdr) + "|") + for r in report["rows"]: + pr = f"#{r['pr']}" + (" (draft)" if r["draft"] else "") if r["pr"] else "-" + lines.append("| " + " | ".join([f"{r['layer']}/{len(report['rows'])}", r["branch"], pr, + icon(r["pushed"]), icon(r["contains_parent"]), + icon(r["pr_base_ok"]) if r["pr"] else "-", + icon(r["pr_head_ok"]) if r["pr"] else "-"]) + " |") + lines.append("") + if report["problems"]: + lines.append(f"VERDICT: ❌ NOT LINKABLE ({len(report['problems'])} issue(s))") + lines.extend(f"- {p}" for p in report["problems"]) + else: + lines.append("VERDICT: ✅ LINKABLE - every head pushed, chain linear, PR state consistent") + land = report["landing"] + lines.append(f"MERGE INTO TRUNK: {icon(land['clean'])} {land['reason']}") + lines.extend(f"- note: {n}" for n in report["notes"]) + if report["linkable"] and report["rows"]: + lines.append("") + lines.append("next: " + link_command(report)) + if any(not r["pr"] for r in report["rows"]): + lines.append(" (a bare branch name pushes the LOCAL ref: run it from that layer's own worktree, or open its PR first and re-run the check)") + else: + lines.append(" (run from a worktree checked out on a stack branch)") + return "\n".join(lines) + + +def link_command(report: dict) -> str: + """Shell-safe: bare PR numbers, never `#N` (a `#` starts a comment in bash).""" + return f"gh stack link --base {report['trunk']} " + " ".join( + str(r["pr"]) if r["pr"] else r["branch"] for r in report["rows"]) + + +def preflight(*, need_gh: bool) -> None: + for tool in ("git",) + (("gh",) if need_gh else ()): + if not shutil.which(tool): + raise ToolError(f"`{tool}` not on PATH") + if run(["git", "rev-parse", "--git-dir"], check=False).returncode != 0: + raise ToolError("not inside a git repository") + if run(["git", "remote", "get-url", "origin"], check=False).returncode != 0: + raise ToolError("no `origin` remote; the check compares origin/* refs (`git remote add origin `)") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--trunk", required=True, help="branch the stack lands on (e.g. develop, integrate/phase-bc)") + ap.add_argument("layers", nargs="+", help="proposed order bottom to top: branch names or PR numbers") + ap.add_argument("--no-fetch", action="store_true", help="skip `git fetch origin`; compare against the cached origin/* refs") + ap.add_argument("--no-pr", action="store_true", help="skip the gh pr list call (local-only view)") + ap.add_argument("--json", action="store_true", help="emit the report as JSON") + args = ap.parse_args() + return guarded("gh-stack-chain-check", lambda: run_check(args)) + + +def run_check(args: argparse.Namespace) -> int: + preflight(need_gh=not args.no_pr) + if not args.no_fetch: + try: + fetch = run(["git", "fetch", "--quiet", "origin"], check=False) + why = None if fetch.returncode == 0 else ((fetch.stderr or fetch.stdout).strip().splitlines() or ["no output"])[-1] + except ToolError as exc: # timeout + why = str(exc) + if why: + sys.stderr.write(f"gh-stack-chain-check: warning: git fetch origin failed ({why}); comparing against cached origin/* refs\n") + prs = [] if args.no_pr else list_prs() + by_number, by_branch = index_prs(prs) + layers = resolve_layers(args.layers, by_number, by_branch) + report = evaluate(args.trunk, layers, by_branch, fetched=True, use_pr=not args.no_pr) + if args.json: + print(json.dumps(report, indent=2)) + else: + print(render(report)) + return 0 if report["linkable"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/sc-gh-stack/scripts/gh_stack_shared.py b/packages/sc-gh-stack/scripts/gh_stack_shared.py new file mode 100755 index 000000000..c4609dc62 --- /dev/null +++ b/packages/sc-gh-stack/scripts/gh_stack_shared.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Helpers shared by the sc-gh-stack scripts (stdlib only). + +Installed next to ``gh_stack_view.py`` and ``gh_stack_chain_check.py`` under +``.claude/scripts/``. Everything here is read-only: subprocess wrappers and +the git lookups both scripts need. Never runs a ``gh stack`` write command. +""" +from __future__ import annotations + +import subprocess +import sys +from typing import Callable + +GIT_TIMEOUT = 60 +GH_TIMEOUT = 120 + + +class ToolError(Exception): + """A required external command is missing or failed; message is actionable.""" + + +def run(cmd: list[str], *, check: bool = True, cwd: str | None = None) -> subprocess.CompletedProcess[str]: + timeout = GH_TIMEOUT if cmd and cmd[0] == "gh" else GIT_TIMEOUT + try: + proc = subprocess.run(cmd, text=True, capture_output=True, cwd=cwd, timeout=timeout) + except subprocess.TimeoutExpired as exc: + raise ToolError(f"{' '.join(cmd[:3])} timed out after {timeout}s; check network, `gh auth status`, or a hung prompt") from exc + except FileNotFoundError as exc: + missing = cmd[0] if exc.filename in (None, cmd[0]) else f"directory {exc.filename}" + raise ToolError(f"cannot run {' '.join(cmd[:3])}: {missing} not found " + f"(install gh + gh-stack extension and git; prune stale worktrees with `git worktree prune`)") from exc + except OSError as exc: + raise ToolError(f"cannot run {' '.join(cmd[:3])}: {exc}") from exc + if check and proc.returncode != 0: + detail = (proc.stderr or proc.stdout).strip().splitlines() + raise ToolError(f"{' '.join(cmd[:3])} failed (exit {proc.returncode}): {detail[-1] if detail else 'no output'}" + + hint_for(" ".join(detail))) + return proc + + +def hint_for(text: str) -> str: + """Append the next action for the failure signatures gh and git actually produce.""" + low = text.lower() + if "rate limit" in low or "secondary" in low or "abuse" in low: + return "; GitHub rate limit: stop all gh calls for 30 min, or use --no-pr for a local-only view" + if "auth" in low or "401" in low or "token" in low or "not logged" in low: + return "; run `gh auth status` / `gh auth login`" + if "could not resolve host" in low or "network" in low or "timed out" in low or "connection" in low: + return "; network problem: retry, or use --no-fetch/--no-pr for a local-only view" + if "not a git repository" in low: + return "; run from inside the repository or one of its worktrees" + if "extension" in low and "not found" in low: + return "; `gh extension install github/gh-stack`" + return "" + + +def short(sha: str | None) -> str: + return (sha or "")[:9] or "-" + + +def origin_sha(ref: str) -> str | None: + """None when the branch is not on origin (unpushed or deleted after merge).""" + proc = run(["git", "rev-parse", "--verify", "--quiet", f"origin/{ref}"], check=False) + return proc.stdout.strip() or None + + +def is_ancestor(older: str, newer: str) -> bool: + return run(["git", "merge-base", "--is-ancestor", older, newer], check=False).returncode == 0 + + +def utf8_stdout() -> None: + """Emoji in the tables must not raise on a non-UTF-8 console (Windows legacy code pages).""" + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + try: + reconfigure(encoding="utf-8", errors="replace") + except (ValueError, OSError): + pass + + +def guarded(tag: str, body: Callable[[], int]) -> int: + """Run ``body``; every failure is one ``: ...`` line on stderr and exit 2, never a traceback.""" + utf8_stdout() + try: + return body() + except ToolError as exc: + sys.stderr.write(f"{tag}: {exc}\n") + return 2 + except KeyboardInterrupt: + sys.stderr.write(f"{tag}: interrupted\n") + return 2 + except Exception as exc: # noqa: BLE001 - the contract is "never a traceback" + sys.stderr.write(f"{tag}: unexpected {type(exc).__name__}: {exc} (report this with the command you ran)\n") + return 2 diff --git a/packages/sc-gh-stack/scripts/gh_stack_view.py b/packages/sc-gh-stack/scripts/gh_stack_view.py new file mode 100755 index 000000000..910e6b151 --- /dev/null +++ b/packages/sc-gh-stack/scripts/gh_stack_view.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +"""One-call status table for a `gh stack`. + +Installed as ``.claude/scripts/gh_stack_view.py`` by the sc-gh-stack package +(command: ``/sc-gh-stack-view``, skill: ``sc-gh-stack-view``). + +Combines exactly three data sources into one table: + +1. ``gh stack view --json`` - local stack tracking: layer order, head/base + SHAs, needsRebase, PR number. +2. ``git rev-parse origin/`` (after one ``git fetch``) - what is + actually pushed. +3. One batched GraphQL query - per-PR ``mergeable``, ``mergeStateStatus``, + ``baseRefName``, ``headRefOid``, ``isDraft`` and CI rollup. + +Coherence checks (the two metrics conventional per-branch calls never show): + +* ``base ok`` - each layer's base == the layer below's head (bottom == trunk). +* ``origin ok`` - local head == origin head == PR head. +* ``needsRebase`` straight from gh stack, plus GitHub's ``mergeable`` / + ``mergeStateStatus`` which reveal CONFLICTING / BEHIND / DIRTY layers. + +Read-only. Never runs ``gh stack sync`` or ``gh stack rebase``. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +from concurrent.futures import ThreadPoolExecutor + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from gh_stack_shared import ToolError, guarded, is_ancestor, origin_sha, run, short # noqa: E402 + + +SKIPPED: list[str] = [] # worktrees the discovery could not read, reported once as warnings + + +def stack_json_at(path: str) -> dict | None: + """None when this worktree is not on a stack; unreadable worktrees are recorded in SKIPPED.""" + try: + proc = run(["gh", "stack", "view", "--json"], check=False, cwd=path) + except ToolError as exc: + SKIPPED.append(f"{path}: {exc} (`git worktree prune` removes stale entries)") + return None + if proc.returncode == 2: + return None # not in a stack: the normal case for develop/main worktrees + if proc.returncode != 0: + tail = (proc.stderr or proc.stdout).strip().splitlines() + why = tail[-1] if tail else "no output" + if proc.returncode == 6: + why += " (branch belongs to several stacks; `gh stack checkout ` there)" + elif proc.returncode == 8: + why += " (stack file locked by another gh stack process; retry in a few seconds)" + elif proc.returncode == 10: + why += " (interrupted `gh stack modify`; run `gh stack modify --abort` there)" + SKIPPED.append(f"{path}: gh stack view exit {proc.returncode}: {why}") + return None + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError: + SKIPPED.append(f"{path}: gh stack view --json returned non-JSON ({proc.stdout[:80]!r}); upgrade gh-stack") + return None + if not isinstance(data, dict) or not isinstance(data.get("branches"), list) or not data["branches"]: + return None + if not isinstance(data.get("trunk"), str) or not all( + isinstance(b, dict) and isinstance(b.get("name"), str) for b in data["branches"] + ): + raise ToolError(f"gh stack view --json in {path} returned an unexpected shape; " + "upgrade gh-stack (`gh extension upgrade stack`) or report the output") + return data + + +def worktree_paths() -> list[str]: + """Every worktree of this repo that is checked out on a branch (cwd first).""" + out = run(["git", "worktree", "list", "--porcelain"]).stdout + paths: list[str] = [] + cur: str | None = None + for line in out.splitlines(): + if line.startswith("worktree "): + cur = line[len("worktree "):] + elif line.startswith("branch ") and cur: + paths.append(cur) + cur = None + elif line == "" : + cur = None + cwd = run(["git", "rev-parse", "--show-toplevel"]).stdout.strip() + paths.sort(key=lambda p: p != cwd) + return paths + + +def select_stacks(found: list[dict], trunk_filter: str | None, *, include_all: bool) -> tuple[list[dict], int]: + """Filter the deduped stack list down to what should be shown. + + Rules: + + * ``trunk_filter`` set - keep only stacks with that trunk (open-only + unless ``include_all``). + * no filter and ``include_all`` - keep everything. + * no filter, not ``include_all`` (the default) - keep every stack that + still has an open layer, regardless of trunk name. + + Returns (stacks, hidden_count). + """ + def is_open(d: dict) -> bool: + return any(not b.get("isMerged") and (b.get("pr") or {}).get("state", "OPEN") != "CLOSED" + for b in d["branches"]) + + if trunk_filter: + stacks = [d for d in found if d["trunk"] == trunk_filter and (include_all or is_open(d))] + elif include_all: + stacks = found + else: + stacks = [d for d in found if is_open(d)] + stacks.sort(key=lambda d: (not d["trunk"].startswith("integrate/"), d["trunk"], d["branches"][0]["name"])) + return stacks, len(found) - len(stacks) + + +def discover_stacks(trunk_filter: str | None, *, include_all: bool) -> tuple[list[dict], int]: + """Run `gh stack view --json` once per worktree, dedupe by branch set. + + Works from any branch not part of a stack (e.g. develop/main): every + stack that has at least one worktree checked out is found. Concurrent, + read-only. Filtering/sorting of the deduped list is delegated to + ``select_stacks``. Returns (stacks, hidden_count). + """ + paths = worktree_paths() + with ThreadPoolExecutor(max_workers=4) as pool: # one gh call per worktree; keep bursts small (secondary rate limit) + results = list(pool.map(stack_json_at, paths)) + found: list[dict] = [] + for path, data in zip(paths, results): + if data: + data["worktree"] = path + found.append(data) + + # Each worktree only knows the layers linked from it; the same stack seen + # from a lower layer is a prefix of the view from the top. Keep the longest. + def names(d: dict) -> tuple[str, ...]: + return tuple(b["name"] for b in d["branches"]) + found = [d for d in found if not any( + o is not d and len(names(o)) > len(names(d)) and names(o)[: len(names(d))] == names(d) + for o in found)] + uniq: dict[tuple[str, ...], dict] = {} + for d in found: + uniq.setdefault(names(d), d) + found = list(uniq.values()) + + return select_stacks(found, trunk_filter, include_all=include_all) + + +def pr_details(numbers: list[int]) -> dict[int, dict]: + """One GraphQL round-trip for every PR in the stack.""" + if not numbers: + return {} + remote = run(["gh", "repo", "view", "--json", "owner,name"]).stdout + try: + repo = json.loads(remote) + except json.JSONDecodeError as exc: + raise ToolError(f"gh repo view returned non-JSON: {remote[:200]!r}") from exc + try: + owner, name = repo["owner"]["login"], repo["name"] + except (KeyError, TypeError) as exc: + raise ToolError(f"gh repo view returned an unexpected shape: {remote[:200]!r}; run `gh auth status`") from exc + if not all(isinstance(n, int) and n > 0 for n in numbers): + raise ToolError(f"non-integer PR number in gh stack view output: {numbers!r}; upgrade gh-stack") + fields = ( + "number isDraft mergeable mergeStateStatus baseRefName headRefOid " + "reviewDecision state " + "commits(last:1){nodes{commit{statusCheckRollup{state}}}}" + ) + aliases = " ".join(f"pr{n}: pullRequest(number:{n}) {{ {fields} }}" for n in numbers) + query = f'query($owner:String!,$name:String!){{ repository(owner:$owner,name:$name) {{ {aliases} }} }}' + proc = run(["gh", "api", "graphql", "-f", f"query={query}", "-F", f"owner={owner}", "-F", f"name={name}"]) + try: + payload = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise ToolError(f"gh api graphql returned non-JSON: {proc.stdout[:200]!r}") from exc + data = (payload.get("data") or {}).get("repository") + if payload.get("errors"): + msgs = "; ".join(str(e.get("message", "?")) for e in payload["errors"][:3]) + if not data: + raise ToolError(f"GraphQL errors: {msgs} (PRs {numbers}; use --no-pr for a local-only view)") + sys.stderr.write(f"gh-stack-view: warning: GraphQL reported: {msgs}; affected PRs show as unknown\n") + if not data: + raise ToolError("GraphQL returned no repository data; run `gh auth status` or use --no-pr") + out: dict[int, dict] = {} + for n in numbers: + pr = data.get(f"pr{n}") + if not isinstance(pr, dict): + out[n] = {} # unresolved on GitHub: falsy, rendered as unknown + continue + nodes = ((pr.get("commits") or {}).get("nodes") or [None]) + first = nodes[0] if isinstance(nodes[0], dict) else {} + rollup = ((first.get("commit") or {}).get("statusCheckRollup") or {}).get("state") + pr["ci"] = rollup or "NONE" + out[n] = pr + return out + + +def landing_verdict(stack: dict, rows: list[dict], trunk_origin: str | None) -> dict: + """Judge the stack as one landing, independent of chain coherence. + + The chain verdict answers "can gh stack merge walk this bottom-up?". A + stack is also landable as a single merge of its top layer when (a) the + top layer's pushed head contains every open layer's pushed head and (b) + that head merges into the trunk without conflicts. Both are checked + against origin refs only, never local tracking. + """ + open_rows = [r for r in rows if not r.get("merged")] + if not open_rows or trunk_origin is None: + return {"landable": None, "top": None, "reason": "no open layer or trunk not fetched"} + top = open_rows[-1] + top_sha = top.get("origin") + if not top_sha: + return {"landable": None, "top": top["branch"], "reason": "top layer has no origin head"} + missing = [r["branch"] for r in open_rows[:-1] if not r.get("origin") or not is_ancestor(r["origin"], top_sha)] + if missing: + return {"landable": False, "top": top["branch"], "top_sha": top_sha, + "reason": "top head does not contain: " + ", ".join(missing)} + tree = run(["git", "merge-tree", "--write-tree", trunk_origin, top_sha], check=False) + if tree.returncode == 1: + conflicts = [line for line in tree.stdout.splitlines() if line.startswith("CONFLICT")] + return {"landable": False, "top": top["branch"], "top_sha": top_sha, + "reason": "merge into trunk conflicts: " + ("; ".join(conflicts[:3]) or "see git merge-tree")} + if tree.returncode != 0: + return {"landable": None, "top": top["branch"], "top_sha": top_sha, + "reason": "git merge-tree --write-tree unavailable (git < 2.38) or failed; check with a scratch `git merge --no-commit`"} + return {"landable": True, "top": top["branch"], "top_sha": top_sha, + "reason": f"top head contains all {len(open_rows)} open layer(s) and merges clean into {stack['trunk']}"} + + +def build_rows(stack: dict, prs: dict[int, dict], *, fetched: bool) -> tuple[list[dict], list[str], list[str]]: + trunk = stack["trunk"] + trunk_origin = origin_sha(trunk) if fetched else None + rows: list[dict] = [] + problems: list[str] = [] + notes: list[str] = [] + expected_base = trunk_origin + # Name of the branch the next open layer must be based on. Starts at the + # trunk and only advances past OPEN layers: a merged layer's content now + # lives in the trunk (GitHub retargets its child onto the trunk), so the + # layer above a merged one is judged against the trunk, not the merged head. + parent = trunk + for idx, br in enumerate(stack["branches"], start=1): + name = br["name"] + pr = prs.get((br.get("pr") or {}).get("number") or -1, {}) + if br.get("isMerged"): + rows.append({"layer": idx, "branch": name, "pr": (br.get("pr") or {}).get("number"), + "head": br.get("head"), "base": br.get("base"), "merged": True, "queued": False, + "draft": False, "mergeable": None, "merge_state": "MERGED", "ci": pr.get("ci"), + "base_ok": None, "origin_ok": None, "needs_rebase": False, "origin": None, + "pr_head": pr.get("headRefOid"), "expected_base": expected_base, "pr_base": pr.get("baseRefName")}) + # Merged: its head is (an ancestor of) the trunk head now. The next + # open layer must sit on the trunk; ``parent`` stays at the trunk. + expected_base = trunk_origin or br.get("head") or expected_base + continue + # gh stack omits ``head``/``base`` for a layer that has no local branch + # (e.g. viewed from a sibling worktree before the branch was fetched). + # Never subscript them directly: a missing key must degrade to ❓, not crash. + head = br.get("head") + base = br.get("base") + origin = origin_sha(name) if fetched else None + if (br.get("pr") or {}).get("number") is not None and prs and not pr: + notes.append(f"L{idx} {name}: PR #{(br.get('pr') or {}).get('number')} could not be resolved on GitHub (deleted, or no access); merge and CI shown as unknown") + base_ok = (base == expected_base) if (expected_base and base) else None + origin_ok = None + if origin: + if head: + origin_ok = head == origin and (not pr or pr.get("headRefOid") == origin) + elif pr: + # No local head to compare; the remote side (origin vs PR) can still be checked. + origin_ok = pr.get("headRefOid") == origin + if head is None: + notes.append(f"L{idx} {name}: gh stack reported no local head (branch not present locally); local tracking not verified") + if base is None: + notes.append(f"L{idx} {name}: gh stack reported no base SHA; base coherence not verified") + row = { + "layer": idx, + "branch": name, + "pr": (br.get("pr") or {}).get("number"), + "head": head, + "origin": origin, + "pr_head": pr.get("headRefOid"), + "base": base, + "expected_base": expected_base, + "base_ok": base_ok, + "origin_ok": origin_ok, + "needs_rebase": br.get("needsRebase"), + "merged": br.get("isMerged"), + "queued": br.get("isQueued"), + "draft": pr.get("isDraft"), + "mergeable": pr.get("mergeable"), + "merge_state": pr.get("mergeStateStatus"), + "ci": pr.get("ci"), + "pr_base": pr.get("baseRefName"), + "behind_trunk": False, + "pr_unresolved": bool((br.get("pr") or {}).get("number") is not None and prs and not pr), + } + parent_is_trunk = parent == trunk + if pr and pr.get("baseRefName") not in (None, parent): + problems.append(f"L{idx} {name}: PR #{row['pr']} base is {pr['baseRefName']}, expected {parent}") + if base_ok is False: + # The lowest OPEN layer (idx 1, or any layer whose lower layers are + # all merged) may sit on an older trunk commit: that is a note, not + # a rebase order. Against an open parent it is a real mismatch. + if parent_is_trunk and is_ancestor(base or "", expected_base): + row["behind_trunk"] = True + notes.append(f"L{idx} {name}: behind trunk ({short(base)} < {short(expected_base)}); fine unless CONFLICTING, do not restart CI just to catch up") + else: + problems.append(f"L{idx} {name}: base {short(base)} != parent head {short(expected_base)} -> needs rebase") + if origin_ok is False: + problems.append( + f"L{idx} {name}: local {short(head)} / origin {short(origin)} / PR {short(pr.get('headRefOid'))} differ" + " -> local tracking stale or unpushed; owner must fetch+reset or push" + ) + if br.get("needsRebase"): + if row["behind_trunk"]: + notes.append(f"L{idx} {name}: gh stack reports needsRebase, but only against a trunk that moved; no action while its CI can go green") + else: + problems.append(f"L{idx} {name}: gh stack reports needsRebase") + if pr.get("mergeable") == "CONFLICTING": + problems.append(f"L{idx} {name}: PR #{row['pr']} CONFLICTING") + if pr.get("isDraft"): + problems.append(f"L{idx} {name}: PR #{row['pr']} is DRAFT (blocks stack merge)") + rows.append(row) + # The next layer must be based on THIS layer's pushed head (fall back to local, then PR). + expected_base = origin or head or pr.get("headRefOid") or expected_base + parent = name + return rows, problems, notes + + +ICON_SYNC = {"ok": "✅", "stale": "🔄", "rebase": "⚠️", "behind": "⏳", "unknown": "❓"} +ICON_MERGE = {"MERGED": "\U0001f3c1", "OK": "✅", "BLOCKED": "\U0001f6a7"} +ICON_CI = {"SUCCESS": "✅", "FAILURE": "⛔", "ERROR": "⛔", "PENDING": "\U0001f300", + "EXPECTED": "\U0001f300", "NONE": "—"} + + +def sync_icon(r: dict) -> str: + if r["merged"]: + return ICON_MERGE["MERGED"] + if r["origin_ok"] is False: + return ICON_SYNC["stale"] + if r.get("behind_trunk"): + return ICON_SYNC["behind"] + if r.get("pr_unresolved"): + return ICON_SYNC["unknown"] + if r["base_ok"] is False or r["needs_rebase"]: + return ICON_SYNC["rebase"] + if r["base_ok"] is None or r["origin_ok"] is None: + return ICON_SYNC["unknown"] + return ICON_SYNC["ok"] + + +def merge_icon(r: dict) -> str: + """✅ only when GitHub says the PR can merge now; anything else is 🚧.""" + if r["merged"]: + return ICON_MERGE["MERGED"] + if r.get("pr_unresolved"): + return ICON_SYNC["unknown"] + if r["draft"] or r["queued"] or r["mergeable"] != "MERGEABLE": + return ICON_MERGE["BLOCKED"] + return ICON_MERGE["OK"] if r["merge_state"] in ("CLEAN", "HAS_HOOKS", "UNSTABLE") else ICON_MERGE["BLOCKED"] + + +def ci_icon(r: dict) -> str: + if r.get("pr_unresolved"): + return ICON_SYNC["unknown"] + return ICON_CI.get(r["ci"] or "NONE", ICON_CI["NONE"]) + + +def render_landing(landing: dict) -> str: + if landing["landable"] is None: + return f"LANDING: ❓ not judged - {landing['reason']}" + if landing["landable"]: + return f"LANDING: ✅ one merge of {landing['top']} @ {short(landing['top_sha'])} lands the stack - {landing['reason']}" + return f"LANDING: ❌ {landing['top']} @ {short(landing.get('top_sha'))} cannot land as one merge - {landing['reason']}" + + +def render_table(stack: dict, rows: list[dict], problems: list[str], notes: list[str], landing: dict, *, trunk_origin: str | None) -> str: + hdr = ["L", "PR", "rebase", "merge", "CI"] + lines = [f"stack: {stack['branches'][-1]['name']} -> {stack['trunk']} @ {short(trunk_origin)}", ""] + lines.append("| " + " | ".join(hdr) + " |") + lines.append("|" + "|".join("---" for _ in hdr) + "|") + for r in rows: + pr = f"#{r['pr']}" if r["pr"] else "-" + lines.append("| " + " | ".join([ + f"{r['layer']}/{len(rows)}", pr, sync_icon(r), merge_icon(r), ci_icon(r), + ]) + " |") + lines.append("") + if problems: + lines.append(f"VERDICT: ❌ NOT COHERENT ({len(problems)} issue(s))") + lines.extend(f"- {p}" for p in problems) + else: + lines.append("VERDICT: ✅ COHERENT - every base == parent head, every head pushed and on its PR") + lines.append(render_landing(landing)) + lines.extend(f"- note: {n}" for n in notes) + return "\n".join(lines) + + +def legend() -> str: + lines = [] + lines.append("rebase: ✅ not needed (base==parent head, local==origin==PR) ⚠️ needed ⏳ behind a moved trunk, no action 🔄 local tracking stale: fetch+reset before any sync ❓ unknown (--no-fetch, or PR unresolved) 🏁 merged") + lines.append("merge: ✅ mergeable now 🚧 blocked (conflicting, behind, draft, queued, required checks, or still computing) 🏁 merged") + lines.append("CI: ✅ green 🌀 running ⛔ failed, do not enter — none") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--trunk", help="only stacks whose trunk is this branch (e.g. develop, integrate/phase-bc)") + ap.add_argument("--phase", help="shorthand for --trunk integrate/phase-") + ap.add_argument("--all", action="store_true", help="show every stack, including merged/closed ones and other trunks") + ap.add_argument("--no-fetch", action="store_true", help="skip `git fetch origin` and origin comparison") + ap.add_argument("--no-pr", action="store_true", help="skip the GraphQL PR query (offline / local-only view)") + ap.add_argument("--json", action="store_true", help="emit the merged rows as JSON instead of tables") + args = ap.parse_args() + trunk_filter = args.trunk or (f"integrate/phase-{args.phase.lower()}" if args.phase else None) + + return guarded("gh-stack-view", lambda: run_report(args, trunk_filter)) + + +def preflight() -> None: + for tool in ("git", "gh"): + if not shutil.which(tool): + raise ToolError(f"`{tool}` not on PATH") + if run(["gh", "stack", "--help"], check=False).returncode != 0: + raise ToolError("gh-stack extension missing: `gh extension install github/gh-stack`") + if run(["git", "rev-parse", "--git-dir"], check=False).returncode != 0: + raise ToolError("not inside a git repository") + + +def run_report(args: argparse.Namespace, trunk_filter: str | None) -> int: + preflight() + stacks, hidden = discover_stacks(trunk_filter, include_all=args.all) + if not stacks: + where = f" with trunk {trunk_filter}" if trunk_filter else "" + hint = f" ({hidden} merged/closed/other-trunk stack(s) hidden; --all to show)" if hidden else "" + sys.stderr.write( + f"gh-stack-view: no open gh stack found{where}{hint}. Stacks are discovered through `git worktree list`; a stack " + "needs at least one of its layers checked out in a worktree (never `git checkout` in the main repo).\n" + ) + for line in SKIPPED: + sys.stderr.write(f"gh-stack-view: warning: skipped worktree {line}\n") + return 2 + fetched = not args.no_fetch + if fetched: + try: + fetch = run(["git", "fetch", "--quiet", "origin"], check=False) + why = None if fetch.returncode == 0 else ((fetch.stderr or fetch.stdout).strip().splitlines() or ["no output"])[-1] + except ToolError as exc: # timeout + why = str(exc) + if why: + fetched = False + sys.stderr.write(f"gh-stack-view: warning: git fetch origin failed ({why}); rebase column reported as unknown (❓)\n") + numbers = sorted({(b.get("pr") or {}).get("number") for st in stacks for b in st["branches"] if (b.get("pr") or {}).get("number") is not None}) + prs = {} if args.no_pr else pr_details(numbers) + + report: list[dict] = [] + blocks: list[str] = [] + any_problem = False + for st in stacks: + rows, problems, notes = build_rows(st, prs, fetched=fetched) + trunk_origin = origin_sha(st["trunk"]) if fetched else None + if fetched and trunk_origin is None: + notes.append(f"trunk {st['trunk']} is not on origin; base coherence for L1 and LANDING cannot be judged (push the trunk or check its name)") + if SKIPPED: + # A skipped worktree may have held the longest (true) view of this stack: the table + # could be a truncated prefix, so neither coherence nor landing can be trusted. + problems.append(f"discovery incomplete: {len(SKIPPED)} worktree(s) unreadable (see warnings); fix them and re-run before trusting this stack's shape") + any_problem |= bool(problems) + landing = landing_verdict(st, rows, trunk_origin) + if SKIPPED: + landing = {"landable": None, "top": landing.get("top"), "top_sha": landing.get("top_sha"), + "reason": "not judged while a worktree is unreadable"} + report.append({"trunk": st["trunk"], "trunk_origin": trunk_origin, "worktree": st["worktree"], + "rows": rows, "problems": problems, "notes": notes, "coherent": not problems, + "landing": landing}) + blocks.append(render_table(st, rows, problems, notes, landing, trunk_origin=trunk_origin)) + for line in SKIPPED: + sys.stderr.write(f"gh-stack-view: warning: skipped worktree {line}\n") + if args.json: + print(json.dumps({"stacks": report, "hidden": hidden, "coherent": not any_problem, "skipped_worktrees": SKIPPED}, indent=2)) + else: + print("\n\n".join(blocks)) + print() + if hidden: + print(f"hidden: {hidden} merged/closed/other-trunk stack(s); --all to show") + print(legend()) + return 1 if any_problem else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/sc-gh-stack/skills/sc-gh-stack-view/SKILL.md b/packages/sc-gh-stack/skills/sc-gh-stack-view/SKILL.md new file mode 100644 index 000000000..a8dd4eb62 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack-view/SKILL.md @@ -0,0 +1,151 @@ +--- +name: sc-gh-stack-view +version: 0.1.0 +description: One-call coherence, mergeability, CI and LANDING table for every open gh stack. Use for any stacked-PR status question, before and after every link, unstack, rebase or merge, or /sc-gh-stack-view. Never check layers one branch at a time. +entry_point: /sc-gh-stack-view +--- + +# sc-gh-stack-view + +Read-only stack status. One command replaces the "one `gh pr view` per branch" +habit, which never shows the two things that actually break stacks: + +1. **Base coherence** - is each layer's base SHA the head SHA of the layer + below (and the bottom layer's base the trunk head)? Only + `gh stack view --json` exposes `head` and `base`; `gh pr view` does not. +2. **Rebase / mergeability** - `needsRebase` from gh stack plus GitHub's + `mergeable` and `mergeStateStatus` (`CONFLICTING`, `BEHIND`, `DIRTY`, + `BLOCKED`, `CLEAN`). `gh pr list` and `gh pr checks` do not return + `needsRebase`, and `gh pr checks` output must never be text-parsed. + +The script also compares every local head against `origin/` and the +PR's `headRefOid`, so stale local tracking (someone else rebased the stack) is +caught before anyone runs `gh stack sync` on top of it. + +## Step 1 — Verify gh, gh-stack extension, git, python3 + +Before running the script, confirm the toolchain is present: + +```bash +which gh && gh --version # >= 2.0 +gh extension list | grep -i stack && gh stack --version # github/gh-stack v0.1.0 +which git && git --version # >= 2.38 +python3 --version # >= 3.9 +gh auth status # needed unless --no-pr +``` + +If any is missing, probe the usual off-PATH locations first (Claude Code's +bash may not share PATH with the interactive shell): + +```bash +for cli in gh git python3; do + command -v "$cli" >/dev/null && continue + for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/.pyenv/shims"; do + [ -x "$d/$cli" ] && echo "$cli found at: $d/$cli" && break + done +done +``` + +Found off-PATH: `export PATH=":$PATH"` for this session. Still missing, +below the floors (git < 2.38, gh < 2.0, python3 < 3.9), or no gh-stack +extension: read `references/installation-and-troubleshooting.md` and stop; +do not work around a missing `gh`, `gh-stack` or `git`. + +## Usage + +``` +/sc-gh-stack-view [--trunk | --phase aw] [--all] [--no-fetch] [--no-pr] [--json] +``` + +Run from anywhere in the repo, normally the main checkout on `develop` or +`main`. The script discovers every stack by running `gh stack view --json` in +each worktree from `git worktree list` (concurrently), keeps the longest view +of each stack (a lower-layer worktree only sees the layers linked from it), +and joins them into one report. The default view shows every stack that still +has an open layer, on any trunk. `--trunk`/`--phase` narrows to one trunk +(`--phase` is shorthand for `--trunk integrate/phase-`); `--all` also shows +merged/closed stacks. Hidden stacks are counted on a `hidden:` line. + +```bash +python3 .claude/scripts/gh_stack_view.py --trunk develop +``` + +Under a plugin install the script is at `$CLAUDE_PLUGIN_ROOT/scripts/gh_stack_view.py`. +If it is at neither path, locate it with `find .claude ~/.claude -name gh_stack_view.py` +and use the newest match. + +Exit codes: + +| Code | Meaning | +|------|---------| +| 0 | every shown stack coherent | +| 1 | problems listed under a VERDICT | +| 2 | nothing to show or the environment failed; stderr says which (see Errors) | + +## Errors + +Every failure is one `gh-stack-view: ...` line on stderr with the next action, +never a traceback. Exit 2 covers all of these, so read the line: + +- `git`/`gh` not on PATH, gh-stack extension missing (`gh extension install github/gh-stack`), not inside a git repository. +- `gh repo view` / `gh api graphql` failure: run `gh auth status`; `--no-pr` gives a local-only view meanwhile. +- GraphQL errors, null data or non-JSON output: same, with the PR numbers named. +- `gh stack view --json` returning an unexpected shape: upgrade gh-stack. +- No open stack found: the line reports how many merged/closed/other-trunk stacks were hidden; use `--trunk`, `--phase` or `--all`. + +Non-fatal: a failed `git fetch origin` is warned once and the rebase column +shows ❓ instead of comparing against stale refs. Pruned or unreadable +worktrees are skipped silently (`git worktree prune` cleans them up). + +## Output + +The script renders everything. **Paste its output verbatim and unfenced** +(no ``` around it) so the markdown table renders in the terminal. The agent +makes no rendering decisions: no reformatting, no re-summarising, no +substituting its own per-branch lookups. Example (as it should appear): + +stack: fix/aw-pool-read-migration -> integrate/phase-aw @ 0e640b20a + +| L | PR | rebase | merge | CI | +|---|---|---|---|---| +| 1/2 | #1242 | ✅ | 🚧 | 🌀 | +| 2/2 | #1244 | ✅ | 🚧 | 🌀 | + +VERDICT: ✅ COHERENT - every base == parent head, every head pushed and on its PR + +| Column | Source | Icons | +|--------|--------|-------| +| L | layer / stack depth, bottom first | | +| rebase | `gh stack view --json` `head`/`base`/`needsRebase`, `origin/` after one fetch, PR `headRefOid` | ✅ not needed (base==parent head and local==origin==PR) · ⚠️ needed · 🔄 local tracking stale, fetch+reset before any sync · ❓ unknown (`--no-fetch`) · 🏁 merged | +| merge | one GraphQL query: `mergeable`, `mergeStateStatus`, `isDraft`; gh stack `isMerged`/`isQueued` | ✅ mergeable now · 🚧 blocked (conflicting, behind, draft, queued, required checks, still computing) · 🏁 merged | +| CI | same query, `statusCheckRollup.state` of the head commit | ✅ green · 🌀 running · ⛔ failed, do not enter · — none | + +`VERDICT` names the branch and the owner action for every problem (SHAs +appear there, not in the table). The lowest OPEN layer (layer 1, or the first +layer above already-merged ones) merely behind trunk is a note, not a problem: +do not rebase a layer whose CI could go green just to catch up with trunk. The legend is printed once at the end. `--json` emits +`stacks[].rows[]`, `problems[]`, `notes[]`, `coherent` for agents that need to +branch on the result. + +If you see stale local tracking (🔄, or a false `NOT COHERENT` with `-` rows +right after an `unstack`), that is fixed by the recipe in +`../sc-gh-stack/references/recipe-stale-tracking.md`, not by re-running this +script harder. + +## Rules the skill enforces by convention + +- This is **the** status call for stacks. Do not fan out `gh pr view` per + branch; that costs N calls and still misses base coherence and needsRebase. +- The script is read-only. `gh stack sync` and `gh stack rebase` rewrite and + force-push every layer; only the stack writer runs them, and only after this + rebase column shows ✅ on every layer (otherwise sync re-rebases stale local + heads over someone else's push and turns PRs CONFLICTING). +- After ANY merge or rebase on a stack, run this again and reconcile before + dispatching dev or QA against a layer. +- Draft PRs block `gh stack merge`; the table flags them. + +## Related + +The `sc-gh-stack` skill (`../sc-gh-stack/SKILL.md`) owns the stacked-PR model, +preconditions, and the create/sync/rebase/merge recipes; this skill only +reports status. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack-view/references/installation-and-troubleshooting.md b/packages/sc-gh-stack/skills/sc-gh-stack-view/references/installation-and-troubleshooting.md new file mode 100644 index 000000000..74cf4e7a6 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack-view/references/installation-and-troubleshooting.md @@ -0,0 +1,98 @@ +# Installation and troubleshooting + +Read this when `gh`, the `gh-stack` extension, `git`, or `python3` might be missing or too old before any `sc-gh-stack` workflow runs — it is the CLI-dependency doc this skill's SKILL.md Step 1 points to, per the repo's skill guidelines (`docs/claude-code-skills-agents-guidelines.md`). + +This skill depends on: +- `gh` (GitHub CLI), authenticated +- the `gh-stack` extension (`github/gh-stack`), v0.1.0 +- `git` +- `python3` (for any accompanying stdlib-only scripts) +- stacked pull requests enabled on the target GitHub repository + +## Check First + +```bash +which gh && gh --version +gh extension list | grep stack +gh stack --version +which git && git --version +python3 --version +``` + +Skip installation for anything already present. `gh stack --version` (not `gh stack version`) reports the installed extension version, e.g. `gh stack version 0.1.0`. + +## Find Existing Install + +If `which`/`command -v` fails for any dependency, probe common locations before concluding it is absent: + +```bash +for cli in gh git python3; do + command -v "$cli" >/dev/null && continue + for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/.pyenv/shims"; do + [ -x "$d/$cli" ] && "$d/$cli" --version && break + done +done +``` + +If a binary exists off-PATH, `export PATH=":$PATH"` for the session, or call it by absolute path. + +## Install + +- macOS: `brew install gh` +- Linux: distribution package, or see https://github.com/cli/cli#installation +- Windows: `winget install GitHub.cli` + +Then: + +```bash +gh auth login +gh extension install github/gh-stack +gh extension upgrade stack +gh stack --version +``` + +## Minimum Version + +- `gh >= 2.0` +- `gh-stack` v0.1.0 (this reference is field-verified against v0.1.0; re-check flags with `--help` if a newer extension version is installed) +- `git >= 2.38` — needed for `git merge-tree --write-tree`, used in conflict pre-checks (see `troubleshooting.md`) +- `python3 >= 3.9` + +## PATH Troubleshooting + +Claude Code's bash inherits a minimal PATH that may omit directories populated by `.zshrc`/`.bashrc` init (Homebrew, pyenv shims, user-local bin dirs). A `gh` that works in an interactive shell can be silently absent in the agent's shell. Use the probe loop above and export the directory for the session rather than assuming the CLI is missing. + +## Validation + +```bash +gh stack view --json # in a worktree that is on a stack branch; exits 0 +python3 .claude/scripts/gh_stack_view.py --help +``` + +`gh stack view --json` exits 0 and prints the stack payload when run from a branch that is part of a tracked stack; run it from trunk or a non-stack branch and it exits **2** (not in a stack) instead — that is expected, not a failure of the tool itself. + +## Known Issues + +### `gh stack submit` exits 9 + +Stacked pull requests are not enabled on the repository. This cannot be fixed from the CLI — a repository admin must enable the feature on GitHub. Stop and tell the user. + +### `git config rerere.enabled true` prompt on first `init` + +The first `gh stack init` in a repo may prompt under a TTY to enable `git rerere`. Pre-set `git config rerere.enabled true` before running `init` to skip the prompt entirely. + +### Multiple remotes + +`gh stack` commands that push or fetch need a single default remote. If more than one remote is configured: `git config remote.pushDefault origin`. Note `checkout`, `modify`, and `trunk` have no `--remote` flag at all and always rely on `remote.pushDefault`. + +### View script needs a checked-out layer + +Any wrapper script that shells out to `gh stack view --json` reads the *current* worktree's stack state — it needs at least one stack branch checked out in that worktree. Running it from trunk, or in a worktree that was never part of a stack, returns "not in a stack" (exit 2), not stack data. + +### `gh stack help ` doesn't work + +Only the top-level `gh stack --help` / `gh stack help` prints subcommand help. To see a subcommand's own flags, use `gh stack --help` (not `gh stack help `). + +### Commands re-verified against v0.1.0 + +All flags and behavior in `commands.md` and `troubleshooting.md` were re-confirmed against `gh stack --help` output for extension v0.1.0. If the installed extension has moved to a newer version, re-run `gh stack --help` for any command before trusting a specific flag name. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md b/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md new file mode 100644 index 000000000..66df0fcdb --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md @@ -0,0 +1,146 @@ +--- +name: sc-gh-stack +version: 0.1.0 +description: Run stacked PRs with the gh stack extension the way that lands (append-only frozen layers on a named trunk, one stack writer, QA/CI on the top, one atomic merge). Use for any stack, stacked/dependent PRs, gh stack link/unstack/merge, landing, a red layer, or /sc-gh-stack. Supersedes /gh-stack. +entry_point: /sc-gh-stack +--- + +# sc-gh-stack + +Supersedes the generic `/gh-stack` skill; its command guide lives in +`references/commands.md`. A stack exists so CI completes once, on the landing head, and the merge +happens once. Every rule here was paid for in production: the "why" lines +cite the incident. This file is the table of contents; read the reference a +step points to before acting, and nothing else. + +## Step 1 — Verify gh, the gh-stack extension, git, python3 + +```bash +which gh && gh --version +gh extension list | grep -i stack && gh stack --version +which git && git --version +which python3 && python3 --version +``` + +If any is missing, probe the usual off-PATH locations (Claude Code's bash may +not share PATH with the interactive shell): + +```bash +for cli in gh git python3; do + command -v "$cli" >/dev/null && continue + for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/.pyenv/shims"; do + [ -x "$d/$cli" ] && echo "$cli found at: $d/$cli" && break + done +done +``` + +Found off-PATH: `export PATH=":$PATH"` for this session. Still missing, +below the floors (git < 2.38, gh < 2.0, python3 < 3.9, no gh-stack +extension), or `gh auth status` fails: read `references/installation-and-troubleshooting.md` +and stop. Never continue with degraded behavior. + +## Step 2 — Status first, and after every write + +`/sc-gh-stack-view` (skill `sc-gh-stack-view`, script +`.claude/scripts/gh_stack_view.py`) is THE status call. Run it before any +`gh stack` write command, after every link, unstack, rebase or merge, and +before dispatching anyone to a layer. Paste its output verbatim. Never fan out +`gh pr view` per branch, never text-parse `gh pr checks`, never use bare +`gh stack view` (it opens a TUI; the script uses `--json`). + +## The model (read `references/model.md` once) + +- **Trunk** is the branch the stack lands on: `develop`, an integration branch + such as `integrate/phase-N`, or whatever the user names. Never assume the + repository default branch. +- **Append-only and linear.** Every unit of work is a new worktree cut from the + current pushed top. A layer is **frozen** the moment its task closes; nothing + below the top is edited again. Findings on layer K are fixed on a new layer + above the top, never on K. +- **One writer per branch, one stack writer.** Only the stack writer (the lead + or orchestrator) runs `link`, `unstack`, `sync`, `rebase`, `merge`. Everyone + else pushes commits to their own layer only. +- **QA and CI gate the top only.** Red on a lower layer is informational unless + that layer is to be merged alone. +- **Small fixes do not get a stack.** One owner, well under a few hundred + lines, fix and tests together: one PR off trunk, no stack. + +## What to do (pick the row, open the reference) + +| Situation | Reference | +|-----------|-----------| +| Starting a new sprint, fix round, docs or evidence layer | `references/recipe-cut-layer.md` | +| First push of a layer landed; it needs a PR and a stack link | `references/recipe-link.md` | +| Several open PRs on one trunk depend on each other | `references/recipe-link.md` (full ordered link) | +| Fixing a finding on a frozen layer, or on any layer that has children | `references/recipe-cut-layer.md` (new layer on top; never edit below the top) | +| Starting a task on the live top layer (no children, not frozen) | `references/workflow.md` step 4 (rebase at task start) | +| Insert a layer mid-stack, remove a red layer whose fix is above, collapse a passing bottom | `references/recipe-restack.md` | +| Everything frozen, top green and QA PASS: land it | `references/recipe-land.md` | +| `gh stack merge` refused, `gh pr merge` refused, non-linear stack | `references/recipe-land.md` (fallbacks A and B) | +| View shows 🔄, or NOT COHERENT with `-` rows after an unstack | `references/recipe-stale-tracking.md` | +| Deciding layer boundaries, naming, what belongs where | `references/stack-design.md` | +| Any `gh stack` command, flag, exit code, `--json` schema | `references/commands.md` | +| An error message you do not recognise | `references/troubleshooting.md` | +| How a whole phase runs on one stack (worked example) | `references/phase-model-example.md` | + +Every recipe ends the same way: run `/sc-gh-stack-view`, paste it, and record +the current stack number (each unstack mints a new one). + +## Hard preconditions (details and incidents in `references/preconditions.md`) + +1. Creating or re-creating a stack is ONE `gh stack link --base + ... `: the full ordered list, from a worktree on a + stack branch. Without `--base` the bottom PR is retargeted to the default + branch and locked there. Appending one PR to an existing stack may use + `gh stack link `, which appends on top only and cannot + insert. Either way, verify every base with `/sc-gh-stack-view` afterwards. +2. Cut a new layer only from a **pushed** head that contains every lower + layer's head (`git merge-base --is-ancestor`). Two layers cut from the same + head must declare at cut time which one merges the other forward. +3. Never rewrite a layer that has children. Never rebase a frozen layer to + "catch up" with trunk. Never force-push under a live agent. +4. Freeze the trunk from the final sync until the landing is confirmed: + explicit FREEZE to every trunk writer, acked. One stray push restarts every + layer's CI. +5. Never merge a red layer, and never merge a red bottom layer alone when its + fix lives above (that puts the red on the trunk). Remove the red layer from + the stack so the fixing layer above carries its commits + (`recipe-restack.md`, section 2). +6. Before any scoped `gh stack merge `, read the full `branches[]` from + `gh stack view --json`; an upper empty draft gets swept in and its branch + deleted. +7. Land with `gh stack merge --yes --merge` (merge commits only, by + stack number so stale local tracking cannot pick the wrong stack). Never + `--squash`. +8. Mergeability is `git merge-tree --write-tree A B` (exit code) or a real + `git merge --no-commit` in a scratch worktree. Never the legacy 3-arg + `merge-tree`; it prints diff3 hunks on clean merges. +9. Every push to a PR head restarts its CI. No cosmetic or metadata pushes to + a green or running head; batch follow-ups into one validated push. +10. Landing, closing PRs and unstacking are outward-facing. Confirm with the + user before `gh stack merge`, `gh pr close` or `gh stack unstack` unless the + user already directed that exact action. + +## Deterministic helpers + +| Script (installed under `.claude/scripts/`) | Purpose | Mutates | +|---|---|---| +| `gh_stack_view.py` | Coherence, mergeability, CI and LANDING table for every open stack | no | +| `gh_stack_chain_check.py --trunk ... ` | Pre-link check: every head pushed, linear ancestry, PR bases as expected, top merges clean into trunk | no | + +If `CLAUDE_PLUGIN_ROOT` is set (plugin install), the scripts are at +`$CLAUDE_PLUGIN_ROOT/scripts/`. Otherwise, if a script is not at +`.claude/scripts/`, locate it with `find .claude ~/.claude -name 'gh_stack_*.py'` +and use the newest match. Never reproduce the checks by hand. + +## Storage + +No state under `.claude/` or `.sc/`. The skill runs `git`, `gh` and `gh stack` +in the target repository and its worktrees. Stack tracking is gh-stack's own, +per worktree (`recipe-stale-tracking.md` explains the consequence). + +## Related + +- `../sc-gh-stack-view/SKILL.md` — the status tool this skill depends on. +- `sc-git-worktree` (if installed) creates the layer worktrees; the recipes + show the plain `git worktree add` equivalent. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/commands.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/commands.md new file mode 100644 index 000000000..016110108 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/commands.md @@ -0,0 +1,181 @@ +# Command guide for gh stack + +Read this when you need the exact flags, behavior, or exit codes for a `gh stack` subcommand — the operational reference behind `sc-gh-stack`'s workflow. `gh stack --help` is authoritative for flags; this file adds behavior, side effects, and field-verified failure modes `--help` doesn't cover. + +## Non-interactive rules + +**Read with the model in mind.** `init`, `add`, `submit`, `sync` and `rebase` +are documented for completeness; the sc-gh-stack recipes use one worktree per +layer, `gh pr create` on the first push and `gh stack link --base`. `link`, +`unstack`, `sync`, `rebase` and `merge` are stack-writer only; `merge`, +`unstack` and `gh pr close` are confirmed with the user unless directed +(`SKILL.md` precondition 10). + + +Every invocation must be shaped so it cannot prompt or open a TUI — a prompt hangs an agent indefinitely. **Never do:** + +- `gh stack view` or `--short` — always `--json` (bare/`--short` render for humans, may open a TUI) +- `gh stack submit` without `--auto` (otherwise opens an interactive title/description editor) +- `gh stack init` / `add` / `checkout` with no argument — always pass branch names / a stack#, PR#, PR URL, or branch +- `gh stack checkout ` when a different local stack already covers those branches — unbypassable conflict prompt; `gh stack unstack --local` first, then retry +- `gh stack switch` — TUI picker; use `up`/`down`/`top`/`bottom`/`checkout` instead +- `gh stack modify` — TUI-only, no non-interactive form at all +- `gh pr merge` on a stacked PR — refused; use `gh stack merge` + +## Quick reference + +| Task | Command | +|---|---| +| Create a (multi-layer) stack | `gh stack init auth api frontend` | +| Custom trunk | `gh stack init --base develop branch-a` | +| Add a branch | `gh stack add api-routes` | +| Add + stage all + commit | `gh stack add -Am "message" api-routes` | +| Push branches | `gh stack push` | +| Push + create draft PRs | `gh stack submit --auto` | +| Create PRs ready for review | `gh stack submit --auto --open` | +| Sync (fetch, rebase, push) | `gh stack sync` / `gh stack sync --prune` | +| Rebase (all / upstack / continue / abort) | `gh stack rebase` · `--upstack` · `--continue` · `--abort` | +| View stack (JSON) | `gh stack view --json` | +| Move / jump | `gh stack up [n]` / `down [n]` / `top` / `bottom` / `trunk` | +| Check out by stack#/PR#/branch | `gh stack checkout 7` | +| Link PRs, no local tracking | `gh stack link --base main a b c` | +| Tear down a stack | `gh stack unstack [7]` | +| Merge whole/partial stack (merge commits) | `gh stack merge --yes --merge` / `gh stack merge 42 --yes --merge` | + +## init + +`gh stack init [flags] ` — `-b, --base ` trunk (default: repo default branch). + +Processes branches bottom to top: existing ones adopted, missing ones created (first from trunk, later ones from the branch before). Checks out the **last** branch listed. Enables `git rerere` — first run under a TTY may prompt; pre-set `git config rerere.enabled true` to skip it. + +## add + +`gh stack add [flags] ` — `-m, --message `; `-A, --all` (stage all incl. untracked, requires `-m`); `-u, --update` (tracked only, requires `-m`, exclusive with `-A`). + +Must run from the **top** branch (or trunk if empty), else exits **5** `can only add branches on top of the stack` — `gh stack top` first. Without `-Am`, uncommitted changes carry onto the new branch (working tree untouched). `add -Am` commits in place (no new branch) when the current branch has no commits yet (e.g. right after `init`). Prefer plain `git add`/`git commit` for deliberate staging; reserve `-Am`/`-um` for simple single-commit layers. + +## push + +`gh stack push [flags]` — `--remote `. Pushes every active (non-merged, non-queued) branch in one multi-ref push, per-branch `--force-with-lease`. **Not atomic** — one rejection doesn't block others; fix and rerun. Never creates/updates PRs — that's `submit`. + +## submit + +`gh stack submit [flags]` — `--auto` (required non-interactively), `--open` (new+existing PRs ready for review), `--remote `. + +Pushes each active branch sequentially (not atomic — a rejection leaves earlier pushes standing; fix and rerun), creates a PR for every branch lacking one (base = first non-merged ancestor), links into a Stack on GitHub. If every PR is already merged, forks unmerged branches into a **new** stack rooted at trunk. Exits **9** non-interactively if stacks aren't enabled. Title: single commit → its subject/body; multiple commits → humanized branch name; no custom-title flag — `gh pr edit` after. + +## link + +`gh stack link [flags] ...` — `--base `, `--open`, `--remote `. + +Bottom-to-top arguments; each a branch name, PR#, or PR URL (numeric tries PR# first, falls back to branch). Branch args auto-push (non-force, atomic). Missing PRs are created with correct chained bases; wrong bases on existing PRs are corrected. A numeric first arg is a **stack#** only if that stack exists, then the rest append to its top. Additive only. + +**Field note:** always pass `--base ` explicitly. Without it the bottom PR is retargeted to the repo's default branch, and GitHub then refuses `gh pr edit --base` on it afterward. + +**Field note:** `gh stack link ` appends on TOP and retargets the new PR onto the current top — it cannot insert mid-stack. Attempting one fails with `HTTP 422 PullRequest.base is invalid`, then `new PRs must be added to the top of the existing stack`. To insert: `unstack`, `gh pr edit --base`, full re-link. + +**Field note:** `link` never removes a PR — dropping a layer means `unstack` and re-link with the reduced list. Every `unstack` + re-link mints a **new** stack number. + +## sync + +`gh stack sync [flags]` — `--remote `, `--prune`. + +Order: fetch → reconcile GitHub's stack (pulls remotely-added branches; non-interactive divergence aborts) → fast-forward trunk → cascade-rebase (handles squash-merges via `--onto`; conflict restores all branches, exits **3**) → push atomically → refresh PR state → sync stack object (additive, 2+ PRs only) → prune (only with `--prune`, non-interactive). + +**Field note:** on divergence, sync prints `ℹ Sync aborted`, changes nothing, and **exits 0** — not a success signal here. Check stderr for that message, or diff `view --json` before/after. + +## rebase + +`gh stack rebase [flags] [branch]` — `--upstack`, `--downstack`, `--no-trunk` (skip fetch/trunk), `--continue`, `--abort`, `--remote `, `--committer-date-is-author-date`/`--preserve-dates`. + +**Not used in this model** (lower layers are frozen; a fix is a new top layer, `recipe-cut-layer.md`). Upstream: use `--upstack` after editing a lower layer, or when `sync` reports a conflict. Squash-merged parents detected and replayed via `--onto` automatically. `rerere` (enabled by `init`) auto-resolves previously-seen conflicts. Starting while one is in progress exits **7**. + +## view + +`gh stack view --json` — always `--json`; bare/`--short` are for humans, may open a TUI. + +```json +{ + "trunk": "main", "currentBranch": "api-routes", + "branches": [{ + "name": "auth", "head": "abc1234...", "base": "def5678...", + "isCurrent": false, "isMerged": true, "isQueued": false, "needsRebase": false, + "pr": { "number": 42, "url": "https://github.com/o/r/pull/42", "state": "MERGED" } + }] +} +``` + +Fields: `name` · `head`/`base` current/parent HEAD SHA · `isCurrent` · `isMerged` · `isQueued` (merge queue) · `needsRebase` (base not an ancestor) · `pr` (omitted if none; `state` is `OPEN`/`MERGED`/`QUEUED`). `view` refreshes PR state from GitHub best-effort. + +## Navigation — up / down / top / bottom / trunk + +All fully non-interactive, no flags besides `-h`; `up`/`down` accept a count. Movement clamps to stack bounds; merged branches are skipped, so `bottom` lands on the lowest **unmerged** branch. `gh stack switch` is a TUI picker — don't use it. + +## checkout + +`gh stack checkout ` — no flags; relies on `remote.pushDefault` with multiple remotes. + +A bare number resolves stack# → locally-tracked PR# → GitHub-discovered PR# → branch name. Stack/PR#/URL fetches from GitHub and sets up locally. If a local stack already covers those branches with a different composition, checkout can't force past it — `unstack --local` then retry. + +## unstack + +`gh stack unstack [] [flags]` (alias `delete`) — `--local` (local only, never contacts GitHub). + +Removes the stack **grouping** only — never deletes PRs/branches. No argument → active stack (current branch's). A stack# works from anywhere via the API, tracked or not. Queued/auto-merge PRs stay stacked; if any remain, the whole grouping is kept. Unknown stack# exits **2**. + +## merge + +`gh stack merge [] [flags]` — `--squash`, `--rebase`, `--merge`, `--merge-method `, `-y, --yes`. + +No arg → current stack; PR# → that PR + everything below; stack# → every unmerged PR in it. **All-or-nothing.** Only open/not-draft checked pre-merge; branch protection/rules evaluated by GitHub at merge time. A merge queue on the base overrides: queued instead, queue picks the method (flag ignored with a warning), PRs may land in separate groups. + +**Field note:** `merge` blocks the whole set on "not a linear descendant" or "out-of-date with base" — it doesn't selectively skip a bad layer. A scoped `gh stack merge ` can sweep in an upper *empty* draft too (GitHub marks it merged, deletes its branch). Read full `branches[]` from `view --json` before scoping. + +**Field note:** `gh pr merge` on a stacked PR: `must be merged using the asynchronous merge REST API`. Working fallback (poll until `"merged"`; an abbreviated SHA fails with the misleading `Pull request head branch was modified` — always pass the full 40-char `headRefOid`): +```bash +gh api -X PUT repos/{owner}/{repo}/pulls/{n}/merge-async -f merge_method=merge -f sha= +gh api repos/{owner}/{repo}/pulls/{n}/merge-async/ # poll +``` +After the parent merges, GitHub retargets/rebases the child branch within ~30s; once trees are confirmed identical, `git fetch && git reset --hard origin/` — never force-push over it. + +**Field note:** to check whether a PR's base was ever silently retargeted, filter `/events` (not `/timeline`, which misses it): `gh api repos/{owner}/{repo}/issues/{n}/events | jq '.[] | select(.event=="base_ref_changed")'`. + +## Output conventions + +Status messages go to **stderr** (`✓`/`✗`/`⚠`/`ℹ` prefixes); data output (`view --json`) goes to **stdout**. Pipe `2>/dev/null` to isolate data. + +## Exit codes + +| Code | Meaning | Agent action | +|---|---|---| +| 0 | Success | Proceed — but see sync divergence field note | +| 1 | Generic error | Read stderr | +| 2 | Not in a stack / unknown stack# | `gh stack init`, or check the number | +| 3 | Rebase conflict | Resolve, `git add`, `gh stack rebase --continue` | +| 4 | GitHub API failure | Check `gh auth status`, retry | +| 5 | Invalid arguments | Fix invocation (e.g. `add` off the top branch) | +| 6 | Disambiguation required | `gh stack checkout ` first | +| 7 | Rebase in progress | `--continue` or `--abort` | +| 8 | Stack file locked | Wait (~5s timeout), retry | +| 9 | Stacked PRs unavailable | Tell user; repo admin must enable | +| 10 | Modify recovery required | `gh stack modify --abort` (never invoke `modify` yourself) | + +## Parsing `--json` with jq + +```bash +output=$(gh stack view --json) +echo "$output" | jq '[.branches[] | select(.needsRebase)] | length' # needs rebase? +echo "$output" | jq -r '.branches[] | select(.pr.state=="OPEN") | .pr.url' +echo "$output" | jq -r '.branches[] | select(.isMerged) | .name' # merged branches +echo "$output" | jq -r '.currentBranch, .trunk' +echo "$output" | jq '[.branches[] | .isMerged] | all' # fully merged? +``` + +## Known limitations + +1. Stacks are strictly linear (one parent, one child max) — use separate stacks for parallel work. +2. Stack disambiguation (exit 6) has no bypass flag. +3. Multiple remotes need `remote.pushDefault` or `--remote` (`push`/`submit`/`sync`/`rebase`/`link` only — `checkout`/`modify`/`trunk` have none). +4. `checkout` by branch name only resolves locally tracked stacks — use a stack#/PR# to pull from GitHub. +5. `submit` generates title/body from commits, no custom-title flag — `gh pr edit` after. +6. `link` never removes a PR and cannot insert mid-stack (see field notes above). diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/installation-and-troubleshooting.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/installation-and-troubleshooting.md new file mode 100644 index 000000000..74cf4e7a6 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/installation-and-troubleshooting.md @@ -0,0 +1,98 @@ +# Installation and troubleshooting + +Read this when `gh`, the `gh-stack` extension, `git`, or `python3` might be missing or too old before any `sc-gh-stack` workflow runs — it is the CLI-dependency doc this skill's SKILL.md Step 1 points to, per the repo's skill guidelines (`docs/claude-code-skills-agents-guidelines.md`). + +This skill depends on: +- `gh` (GitHub CLI), authenticated +- the `gh-stack` extension (`github/gh-stack`), v0.1.0 +- `git` +- `python3` (for any accompanying stdlib-only scripts) +- stacked pull requests enabled on the target GitHub repository + +## Check First + +```bash +which gh && gh --version +gh extension list | grep stack +gh stack --version +which git && git --version +python3 --version +``` + +Skip installation for anything already present. `gh stack --version` (not `gh stack version`) reports the installed extension version, e.g. `gh stack version 0.1.0`. + +## Find Existing Install + +If `which`/`command -v` fails for any dependency, probe common locations before concluding it is absent: + +```bash +for cli in gh git python3; do + command -v "$cli" >/dev/null && continue + for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/.pyenv/shims"; do + [ -x "$d/$cli" ] && "$d/$cli" --version && break + done +done +``` + +If a binary exists off-PATH, `export PATH=":$PATH"` for the session, or call it by absolute path. + +## Install + +- macOS: `brew install gh` +- Linux: distribution package, or see https://github.com/cli/cli#installation +- Windows: `winget install GitHub.cli` + +Then: + +```bash +gh auth login +gh extension install github/gh-stack +gh extension upgrade stack +gh stack --version +``` + +## Minimum Version + +- `gh >= 2.0` +- `gh-stack` v0.1.0 (this reference is field-verified against v0.1.0; re-check flags with `--help` if a newer extension version is installed) +- `git >= 2.38` — needed for `git merge-tree --write-tree`, used in conflict pre-checks (see `troubleshooting.md`) +- `python3 >= 3.9` + +## PATH Troubleshooting + +Claude Code's bash inherits a minimal PATH that may omit directories populated by `.zshrc`/`.bashrc` init (Homebrew, pyenv shims, user-local bin dirs). A `gh` that works in an interactive shell can be silently absent in the agent's shell. Use the probe loop above and export the directory for the session rather than assuming the CLI is missing. + +## Validation + +```bash +gh stack view --json # in a worktree that is on a stack branch; exits 0 +python3 .claude/scripts/gh_stack_view.py --help +``` + +`gh stack view --json` exits 0 and prints the stack payload when run from a branch that is part of a tracked stack; run it from trunk or a non-stack branch and it exits **2** (not in a stack) instead — that is expected, not a failure of the tool itself. + +## Known Issues + +### `gh stack submit` exits 9 + +Stacked pull requests are not enabled on the repository. This cannot be fixed from the CLI — a repository admin must enable the feature on GitHub. Stop and tell the user. + +### `git config rerere.enabled true` prompt on first `init` + +The first `gh stack init` in a repo may prompt under a TTY to enable `git rerere`. Pre-set `git config rerere.enabled true` before running `init` to skip the prompt entirely. + +### Multiple remotes + +`gh stack` commands that push or fetch need a single default remote. If more than one remote is configured: `git config remote.pushDefault origin`. Note `checkout`, `modify`, and `trunk` have no `--remote` flag at all and always rely on `remote.pushDefault`. + +### View script needs a checked-out layer + +Any wrapper script that shells out to `gh stack view --json` reads the *current* worktree's stack state — it needs at least one stack branch checked out in that worktree. Running it from trunk, or in a worktree that was never part of a stack, returns "not in a stack" (exit 2), not stack data. + +### `gh stack help ` doesn't work + +Only the top-level `gh stack --help` / `gh stack help` prints subcommand help. To see a subcommand's own flags, use `gh stack --help` (not `gh stack help `). + +### Commands re-verified against v0.1.0 + +All flags and behavior in `commands.md` and `troubleshooting.md` were re-confirmed against `gh stack --help` output for extension v0.1.0. If the installed extension has moved to a newer version, re-run `gh stack --help` for any command before trusting a specific flag name. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/model.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/model.md new file mode 100644 index 000000000..d03596755 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/model.md @@ -0,0 +1,121 @@ +# The append-only stack model + +Read this once, before the first `gh stack` command in a repository. It defines +the vocabulary every recipe uses and the four rules everything else follows +from. The rules were derived over five production phases (2026-09-05 to +2026-09-23) of running 2 to 21 stacked PRs per stack; every "why" is an +incident, not a preference. + +## Vocabulary + +| Term | Meaning | +|------|---------| +| **trunk** | The branch the stack lands on. `develop`, an integration branch such as `integrate/phase-N`, or a release branch. Always named explicitly; never the repository default branch by assumption. | +| **layer** | One branch with one PR whose base is the layer below (the bottom layer's base is the trunk). | +| **bottom / top** | Bottom is closest to the trunk and merges first. Top is the landing head. `gh stack up` moves away from trunk, `down` toward it. | +| **writer** | The single agent allowed to push a layer. | +| **stack writer** | The single agent (lead, orchestrator) allowed to run any `gh stack` write command: `link`, `unstack`, `sync`, `rebase`, `merge`. | +| **frozen** | A layer whose task has closed. Nobody touches it again, ever. | +| **landing head** | The pushed head of the top layer; the only tree that reaches the trunk. | +| **stack number** | GitHub's identifier for the stack. `gh stack link` prints it and the PR page shows it in the stack panel; `gh stack view --json` does not. Record it after every link. If it was lost, `gh stack checkout ` from a worktree without stale tracking resolves the stack by PR number and imports it. Every unstack + re-link mints a new one. | + +## Rule 1 — The stack is append-only and linear + +Every unit of work (sprint, fix round, cleanup, docs, evidence) is a **new +worktree cut from the current pushed top**. Its PR opens on the first push with +base = the layer below and is linked into the stack at once. A layer is frozen +the moment its task closes; anything found on it later is fixed on a new layer +above the top. + +Consequences, all of them deliberate: + +- Every layer is immutable once it exists, so reviews and QA verdicts on it + stay true. +- The top contains everything by construction, so there is never a + merge-forward. +- Fixes are new layers that close findings in lower layers. +- Only the top runs the gating CI, and the stack merges once from the top. +- Who owns what is derivable from git instead of relayed by message. +- Nobody waits for a lower layer's QA or CI. A dev's next sprint starts on a + layer cut from their just-pushed head. + +*Why:* a phase that reintroduced serial waits (a sprint queued behind a fix +round, fix rounds on frozen layers, a layer cut from a stale head) lost an hour +per instance. One agent working this way moves through every sprint of a phase +back-to-back without stopping. + +**Strictly linear.** One parent, at most one child. Two layers cut from the +same head are a fork: `gh stack merge` refuses forks, and the sibling that is +not merged forward loses files upstack, shows red CI on stale merge refs, and +produces false conflict reports. Order siblings by dependency and chain them, +or run the independent one as its own stack on the trunk. + +## Rule 2 — One writer per branch, one stack writer + +The stack writer opens PRs, links, syncs, restacks and lands. A layer's writer +pushes commits to that layer and nothing else: never to another layer, never to +the trunk. Devs never run `gh stack` write commands. + +*Why:* devs running `gh stack` writes, cloned variable files, and a dev holding a +push "until the parent SHA is known" each cost hours in one phase. + +## Rule 3 — QA and CI gate the top only + +The landing head is the only tree that reaches the trunk, so its CI and its QA +verdict are the only gates. Red CI on a frozen layer is not fixed there. + +- QA is dispatched once, on the top, when the top is pushed. A QA already + running on a mid layer of a large stack may finish and its verdict carries + forward; nothing new is dispatched below the top. +- QA diffs the layer against the commit it was cut from (pinned SHA), not + against the moving GitHub base. The verdict is posted on the PR. +- A red check on a lower layer can be a base-branch defect fixed minutes later: + PR CI builds `head + base as it stood when the run started`. Read the job + log's "Merge into " line before dispatching anyone. +- Per-layer QA on a small stack only doubles the "qualitative best-practices" + findings. + +## Rule 4 — CI runs once, the merge happens once + +Every push to a PR head cancels and restarts its CI (30 to 60 minutes on a +large repository). The whole layering discipline exists to make CI run once per +landing, not once per tweak. + +- No cosmetic or metadata commit to a PR that is green or mid-run; put it in + the layer that lands next. +- Batch follow-ups into one validated push, not a drip. +- Never rebase or sync a branch whose CI is running unless it is the landing + layer and the sync is required to land. +- Never push to the trunk while a stack sync or merge is in flight; that + restarts CI on every layer (see `preconditions.md`, trunk freeze). +- Prefer local gates (format, lint, tests) over CI polling; at most one CI + watcher at a time, polling no faster than every 60 seconds. GitHub's + secondary rate limit is per user across every agent and trips on bursts. + +## The small-fix rule + +A change with one owner and bounded scope, well under a few hundred lines with +fix and tests together, is **one PR off the trunk with no stack**: local lint +and tests, one acceptance check, CI green, merge. No stack, no QA task, no +triage record. + +*Why:* a thirty-line fix became two layers, two QA rounds (the second produced +only a pre-existing "best-practices" finding), and a bottom-alone merge that +turned the trunk red. + +## Two shapes that work + +| Shape | Trunk | Use | +|-------|-------|-----| +| Phase stack | `integrate/phase-N` (or any integration branch) | Several sprints and fix rounds from several devs that must land as one unit. The phase closes with one PR trunk → `develop`. Worked example: `phase-model-example.md`. | +| Develop stack | `develop` | Several open fix or feature PRs already targeting `develop` that must land together: chain them (one merge-forward commit per upper layer), link with `--base develop`, freeze everything below the top, QA and CI on the top, merge once. Evidence-only PRs may merge alone. `recipe-link.md` section B. | + +*Why (develop stack):* "do not wait 5 hours for CI to re-run after every PR"; +three independent PRs onto an integration branch cost three CI cycles and two +re-runs. One linear stack means the top's CI validates everything. + +## Related + +- `workflow.md` — the lifecycle of one layer, step by step. +- `preconditions.md` — the checks that stop each known failure. +- `stack-design.md` — how to cut layers so they stay independent. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/phase-model-example.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/phase-model-example.md new file mode 100644 index 000000000..fc0dac526 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/phase-model-example.md @@ -0,0 +1,123 @@ +# Worked example: a phase as one append-only stack + +Read this to see every rule applied end to end. The example is the model the +rules were learned in: a multi-sprint phase of a Rust workspace, one lead +agent as stack writer, several dev agents, a QA agent, an integration branch +as trunk. Names and numbers are real (phase-bc, 2026-09-20 to 2026-09-23). +Repository-specific tooling is named where it appears so you can substitute +your own. + +## Setup + +- Trunk: `integrate/phase-bc`, cut from `develop`. Sprint work never targets + `develop` or `main` directly; the phase closes with one PR + `integrate/phase-bc → develop`. +- Roles: the lead owns every `gh stack` write and every PR open. Each sprint + has one dev with one worktree and one branch. A QA agent reviews the top + only. +- Local gates run before every accepted push (format, clippy with warnings as + errors, workspace tests, line-count and boundary lints). They are the CI, + locally; CI itself is a merge gate, never a dispatch gate. +- Merge commits only (repository rule). + +## The plan is cut by layer + +Sprints were cut on crate boundaries: `bc.1` storage, `bc.2` typed +observability (runtime), `bc.3` CLI, `bc.4` release tooling, `bc.6` docs. +Each sprint's file list was disjoint from every other's. The earlier phase +that used vertical slices (every crate in every sprint) was cancelled after +five hours with ten layers and three fix rounds per sprint. + +## Cutting and linking + +1. `bc.1` worktree cut from `origin/integrate/phase-bc`. First push within + minutes; the lead opened PR #1547 (base `integrate/phase-bc`, body with the + parent SHA and the fence). A stack needs two PRs, so the first + `gh stack link --base integrate/phase-bc 1547 ` ran the moment + the second layer's PR opened. +2. As soon as `bc.1`'s types and core paths were pushed ("could `bc.2` + compile against this head?"), the `bc.2` worktree was cut from + `origin/feature/bc1-...` and its dev deployed. `bc.1` continued its QA and + fix rounds on layers above. +3. Every QA finding became a fix layer at the top (`fix/bc2-review-N`), PR on + first push, appended with `gh stack link `. The stack reached + 20 PRs: 1547 → … → 1565 (sprints plus 13 review-fix layers), then + 1566 → 1567 → 1568 (`bc.4`). +4. The lead ran `/sc-gh-stack-view --trunk integrate/phase-bc` after every link + and before every dispatch; devs never ran a stack write. + +## Rebase at task start + +When a dev started a fix task on their sprint layer (a live layer with no +children, never a frozen one), the first step was: + +```bash +git fetch origin +git rebase --onto origin/ +git push --force-with-lease +``` + +The lead recorded the new parent SHA in the ledger. No other rebases happened +between tasks. Frozen intermediate layers were not rebased at all. + +## QA on the top, pinned + +The phase-end review was dispatched once, on PR #1568 at `5c53dff79` with all +checks green, with the pinned head and the base ref +(`git merge-base integrate/phase-bc 5c53dff79`) in the task variables. The +reviewer read every file with `git show 5c53dff79:`. Seventeen +per-layer background QA rounds run earlier were history, not the gate. + +The verdict was FAIL with seven blocking findings. Each was verified before +dispatch (one was pre-existing on `develop`, one was false), then fixed on new +layers above #1568: a docs layer by one dev, artifact layers by another. +Nothing below the top was touched. + +## Removing red layers whose fix lived above + +Four layers (#1555, #1556, #1566, #1567) were red for defects fixed on the +layers above them. Instead of waiting for the stack merge to serialise behind +them: + +```bash +gh stack unstack 1564 +gh stack link --base integrate/phase-bc # 17 PRs +gh pr close 1555 --comment "Carried by #1557; branch kept." # and 1556, 1566, 1567 +``` + +Result: stack #1564 became #1570, 17 layers, #1557 rebased by GitHub onto +`feature/bc2-typed-observability`, #1568 onto `fix/bc2-review-7`, all green in +one CI pass. Base changes did not restart CI on unchanged heads. Then the +stale-tracking cleanup in every other worktree, then the view tool. + +## Merge-forward as a head-of-queue task + +Where a sibling had to merge another forward, the instruction was sent as a +task with a stable id, re-assigned with the updated head as siblings landed +(`atm task assign --task-id MERGE- --head` in that repository's +team tooling): it cannot be missed or crossed, it is ordered in the task list, +and `--head` does not pre-empt the active task, so it runs right after the +layer task closes. + +## Landing + +Checklist from `recipe-land.md`: every lower layer frozen with its SHA in the +ledger; top gates green and QA PASS on the PR; FREEZE sent to the two trunk +writers (the lead and the QA agent, both of which push triage records to the +trunk) and acked; then `gh stack merge 1570 --yes --merge`. After landing: +view tool, check that no in-flight PR was closed, "landed" sent, freeze +lifted, phase PR `integrate/phase-bc → develop` opened. Integration tests that +need the landed tree run on `develop`, not on the stack. + +## What it cost when a rule was skipped (same repository, earlier phases) + +| Skipped | Cost | +|---------|------| +| One event-log push to the trunk one minute after the final sync | Async-merge fallback, CI restarted on every layer, 40 to 60 minutes | +| Two layers cut from the same top with no declared order | Missing files upstack, false conflicts, an afternoon | +| A lower layer rewritten after children branched | `gh stack merge` refused; landed via fallback B | +| Bottom layer merged alone with its red fixed above | `develop` red; an unrelated PR held its push | +| Scoped merge without reading `branches[]` | An upper draft merged and its branch deleted | +| `gh stack link` without `--base` | Bottom PR retargeted to `main` and locked | +| Per-branch fix rounds and waiting on lower-layer QA | Two to three hours per sprint | +| Sprints as vertical slices | Phase cancelled at 10 layers | diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/preconditions.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/preconditions.md new file mode 100644 index 000000000..3ee5c5f73 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/preconditions.md @@ -0,0 +1,145 @@ +# Preconditions: the checks that stop each known failure + +Read this before any `gh stack` write command (`link`, `unstack`, `sync`, +`rebase`, `merge`) and before pushing to a trunk that has a stack above it. +Each entry is a check, the failure it prevents, and the recovery if the check +was skipped. Incident references are dates and PR numbers from the phases the +rules were learned in. + +## Before `gh stack link` + +**Check:** the command carries `--base ` and the full ordered list +bottom to top; it runs from a worktree checked out on a stack branch (never +from the trunk worktree, never with the trunk or the phase PR as a layer). +**Failure:** without `--base`, `link` retargets the bottom PR to the +repository default branch and GitHub then refuses `gh pr edit --base` on a +stacked PR (PR #1400, 2026-09-11; PR #1253 sat on `main` for 50 seconds). +**Recovery:** `gh stack unstack `, `gh pr edit --base `, +re-link with `--base`. Confirm with +`gh api repos/{owner}/{repo}/issues//events` filtered on `base_ref_changed`; +`/timeline` does not show it. + +**Check:** pass PR numbers, not branch names, when linking from the main +checkout. **Failure:** a branch name pushes the *local* ref, which may be +another agent's unpushed state. + +**Check:** every head is pushed and each layer contains its parent +(`gh_stack_chain_check.py`). **Failure:** a layer cut from a stale or unpushed +head shows phantom diffs and false conflicts. + +**Check:** no two layers share a parent without a declared merge-forward +order. **Failure:** two layers cut from the same top (2026-09-13, both from +#1453) meant missing files upstack, red CI on stale merge refs and false +conflict reports. **Recovery:** the declared sibling merges the other forward +with a plain merge commit, then re-link the linear order. + +**Check:** after linking, verify every base with the view tool (one GraphQL +query), not with per-PR `gh pr view` calls. + +## Before rewriting or rebasing a layer + +**Check:** the layer has no children, or every child will be rebased in the +same pass. **Failure:** rewriting a lower layer after children branched made +the stack non-linear; `gh stack merge` refused with "not a linear descendant" +(stack #1416, 2026-09-12). **Recovery:** `recipe-land.md`, fallback B. + +**Check:** the layer is not frozen and this is the start of a task on it by +its writer. **Failure:** rebasing frozen layers to "catch up" and per-push +rebases cascaded force-pushes under live agents. + +**Check:** the layer's CI is not running, unless it is the landing layer and +the sync is required to land. + +## Before pushing to the trunk + +**Check:** no stack sync or merge targeting this trunk is in flight; if one +is, an explicit FREEZE was sent to every trunk writer and acked, and this push +waits. **Failure:** one routine event-log push to the trunk one minute after +the final sync made the stack "out-of-date with its base branch", forced the +async-merge fallback and restarted 40 to 60 minutes of CI on every layer +(2026-09-06). **Rule:** anything that must reach the trunk becomes a layer +*before* the final sync, or waits behind the landing. Same for the phase PR +into `develop`: resolve `develop` drift before opening the merge window. + +**Check:** a direct push to a protected trunk is not blocked by a ruleset +(GH013 "required status checks are expected" is the ruleset, not a transient). +**Rule:** never edit rulesets or branch protection; report the state and the +exact change needed. The only working bypass is `gh pr merge --merge --admin` +by a bypass-listed account, and that is the user's call. + +## Before `gh stack merge` + +**Check:** read the full `branches[]` from `gh stack view --json` and confirm +nothing above the merge target would be swept in. **Failure:** a scoped +`gh stack merge ` merged and deleted an upper draft that held only a +merge-forward commit (#1386, 2026-09-10). **Recovery:** a fresh PR for the +remaining work; the old one cannot be restored. + +**Check:** the top's CI is green and its QA verdict is PASS; every lower layer +is frozen with its head SHA recorded; every lower head is an ancestor of the +top (`git merge-base --is-ancestor`). Lower-layer CI is not a gate. + +**Check:** no draft PR in the stack (`gh stack merge` refuses drafts; the view +tool flags them). + +**Check:** the trunk is frozen and acked (above). + +**Check:** the method is `--merge`. Never `--squash`; squash-merged layers +rewrite history for every child. + +## Before merging a bottom layer alone (collapse) + +**Check:** the layer is green **by itself**, not merely "its red is fixed on +the layer above". **Failure:** merging #1492 alone, whose known-red test was +fixed by #1493 above it, put the red on `develop`; an unrelated PR then failed +on it and had to hold its push (2026-09-13). **Rule:** remove the red layer +from the stack first so the fixing layer above carries its commits +(`recipe-restack.md`, section 2); required status checks block a red PR from +merging inside a stack merge anyway (phase-bc, 2026-09-23). If a red layer +ever reaches the trunk, treat the trunk as frozen until the fix lands: no +cuts, no merges. + +## Before trusting the view tool + +**Check:** the 🔄 icon or a NOT COHERENT verdict with `-` rows after an +unstack is stale per-worktree tracking, not a real problem. gh-stack tracking +lives per worktree; after an unstack or re-link, other worktrees still show +the old stack number and layer list, and the view tool keeps the *longest* +list it finds. **Recovery:** `recipe-stale-tracking.md`. + +## Before dispatching a fix for red CI + +**Check:** the job log's "Merge into " line. PR CI builds the +merge ref against the base **as it stood when the run started**; a red on a +lower layer may be a base-branch defect fixed minutes later (PR #1460: a lint +failed on a dependency the layer below had not yet allow-listed). The next +push re-runs it green; nobody is dispatched. + +**Check:** a lower layer is frozen. Red CI on a frozen layer is not fixed; the +top gates. + +## Before any fix dispatch + +**Check:** could this land as one more commit on a PR that will get CI anyway, +going to the same base, without blocking that PR's review? Default to that. +A separate PR costs a full CI cycle and a runner slot. + +**Check:** one line per finding stating the exact change and the files +allowed. "Fix these findings" with no ruling and no fence made devs re-edit +whole modules for one-line findings, and each QA sweep filed more. + +## Standing rules that need no check + +- Devs never run `gh stack` write commands, never open PRs, never push to + another layer or the trunk. +- Never hold a push "until the parent SHA is known"; push the WIP, record the + SHA afterwards. +- No cosmetic or metadata commit to a green or running PR head. +- At most one CI watcher, interval 60 seconds or more; one verification pass + right before merging, not one per status ping. On an HTTP 403 secondary + rate limit, stop all `gh` calls for at least 30 minutes. +- Read files at a pinned head with `git show :`; a worktree may be + checked out elsewhere and a verdict on a stale checkout is rejected. +- Refresh a rarely-touched local ref before branching from it + (`git fetch origin && git update-ref refs/heads/ origin/`); a + stale local `main` produced two worktrees on the wrong base in one session. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-cut-layer.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-cut-layer.md new file mode 100644 index 000000000..c60fdb258 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-cut-layer.md @@ -0,0 +1,72 @@ +# Recipe: cut a new layer from the top of the stack + +Use for every unit of work on a stack: a sprint, a fix round, a cleanup, a +docs or evidence layer. The layer is a new worktree cut from the current +**pushed** top. Nothing below the top is edited again. + +## Inputs + +- ``: the stack's trunk (for example `develop`, `integrate/phase-bc`). +- ``: the current top branch, from `/sc-gh-stack-view` (the last open + row). For the first layer of a new stack, `` is the trunk. +- ``: the new branch name. Follow the repository's naming policy; + names are used verbatim by gh-stack. + +## Steps + +1. Status: `/sc-gh-stack-view`. Note the top branch and its origin SHA. + +2. Verify the top is pushed and contains every lower layer: + + ```bash + git fetch origin + git rev-parse --verify origin/ + for lower in ; do + git merge-base --is-ancestor origin/$lower origin/ && echo "ok $lower" || echo "MISSING $lower" + done + ``` + + A `MISSING` line means the top is not the top; stop and fix the chain + first (`recipe-restack.md`). A fork is never cut from a head that does not + contain everything below it. + +3. Create the worktree from the pushed head, never from a local ref: + + ```bash + git worktree add --no-track ../-worktrees/ -b origin/ + ``` + + `--no-track` matters: without it the new branch's upstream is + `origin/`, and the first `git push` or a later + `push --force-with-lease` targets the parent branch. The first push is + `git push -u origin `. + + With `sc-git-worktree` installed: `/sc-git-worktree --create ` + after refreshing the local `` ref + (`git fetch origin && git update-ref refs/heads/ origin/`), + because that skill branches from the local ref. + +4. Record the parent SHA the layer was cut from + (`git rev-parse origin/`). It goes in the PR body and any ledger; QA + diffs against it, and the rebase-at-task-start uses it as + ``. + +5. Hand the worktree to its single writer. The writer pushes a WIP commit + within minutes; there is no reason to wait. + +6. On that first push: `recipe-link.md` (PR with base = ``, then link). + +## Two layers from the same head + +Sometimes two units of work are ready at once (a fix round and the next +sprint). Declare at cut time which one merges the other forward, and cut the +second only from the first's pushed head as soon as it exists. If both must +start now from the same head, the declared follower does one plain merge +commit (never a rebase, never a force-push) of the leader before linking, so +the chain is linear. Push the WIP first; the merge-forward is a later +appended commit. + +## Finish + +`/sc-gh-stack-view` after the link. Record the stack number if the link +printed a new one. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-land.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-land.md new file mode 100644 index 000000000..4fde1b5a6 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-land.md @@ -0,0 +1,117 @@ +# Recipe: land the stack + +Use when every layer below the top is frozen, the top's QA verdict is PASS and +the top's CI is green. One atomic merge lands the stack; two fallbacks cover +the cases GitHub refuses. Landing is irreversible: confirm with the user +unless the user directed it. + +## Landing checklist + +- [ ] Every layer below the top is frozen and recorded with its head SHA. +- [ ] Top layer: local gates green, QA PASS posted on the PR. +- [ ] Top CI green on the exact landing SHA. If the repository's CI runs only + for trunk-family base branches (stacked PRs get no check-runs), open a + plain CI-trigger PR `top-head → trunk`, not linked into the stack, wait + for its run on the landing SHA, then close it; check-runs are per + commit, so the ruleset's required checks are satisfied. +- [ ] No draft PR in the stack; `branches[]` read in full; no empty upper + layer that would be swept in. +- [ ] `/sc-gh-stack-view`: COHERENT, LANDING ✅. +- [ ] FREEZE sent to every trunk writer and acked. Anything for the trunk + became a layer before the final sync or waits. + +## Primary: one atomic stack merge + +```bash +gh stack merge --yes --merge # by stack number, from anywhere +``` + +Always by stack number: a worktree's local tracking can be stale +(`recipe-stale-tracking.md`) and would land the wrong list. + +All-or-nothing, bottom to top. Only open, non-draft state is checked; the +ruleset's required checks are satisfied because the top head carried CI +(skipped docs-only checks count as satisfied). Never `--squash`. If the base +uses a merge queue the stack is queued as a group and may land in separate +batches; the queue picks the method. + +Refusals and what they mean: + +| Message | Cause | Go to | +|---------|-------|-------| +| "stack is out-of-date with its base branch" | Someone pushed to the trunk after the stack's bases were computed | Freeze the trunk, then land through fallback B mechanics (unstack, retarget the top, merge the top). Never `gh stack sync`: it rebases and force-pushes every frozen layer and restarts CI everywhere | +| "PR #X's branch is not a linear descendant of PR #Y's branch" | A lower layer was rewritten after children branched | Fallback B | +| Draft PR listed | A layer is still a draft | `gh pr ready `, retry | + +## Fallback A: merge one stacked PR through the async API + +`gh pr merge` on a stacked PR is refused ("must be merged using the +asynchronous merge REST API") and the synchronous `POST pulls/N/merge` +returns 404. The working call: + +```bash +SHA=$(gh pr view --json headRefOid -q .headRefOid) # FULL 40 characters +gh api -X PUT repos/{owner}/{repo}/pulls//merge-async \ + -H "Accept: application/vnd.github+json" \ + -f merge_method=merge -f sha="$SHA" # returns a uuid +gh api repos/{owner}/{repo}/pulls//merge-async/ # poll until status == "merged" +``` + +An abbreviated SHA fails with the misleading "Pull request head branch was +modified". After the parent merges, GitHub retargets **and rebases** the child +branch within about 30 seconds (same tree, new committer dates). In the child +worktree: + +```bash +git fetch origin +git diff --stat HEAD origin/ # must be empty: identical trees +git reset --hard origin/ # ONLY if the diff was empty; otherwise stop and report +``` + +Never force-push the local child over GitHub's rebase. Do not use fallback A +to land a whole stack layer by layer: each merge rebases every remaining +frozen layer. + +## Fallback B: non-linear stack + +When a lower layer was rewritten after its children branched, do not fall back +to sequential fallback-A merges (each rebases frozen layers). Instead land the +top PR directly: + +```bash +gh stack unstack +gh pr edit --base +gh pr merge --merge # top head already carried CI on this SHA +``` + +The top must merge clean into the trunk (`git merge-tree --write-tree +origin/ origin/`, exit 0); if the trunk moved and it conflicts, +resolve on a new top layer, never on a frozen one. Verify every lower head is +an ancestor of the new trunk head; GitHub marks the lower PRs MERGED by +itself and the merge commit carries the whole history. Then +`recipe-stale-tracking.md`: the unstack left every worktree's tracking stale. + +```bash +git fetch origin +for b in ; do git merge-base --is-ancestor origin/$b origin/ && echo "landed $b" || echo "NOT LANDED $b"; done +``` + +## After landing + +1. `/sc-gh-stack-view`: all PRs MERGED, or the remaining open stack coherent + on the new trunk head. +2. Confirm no in-flight PR was closed as a side effect + (`gh pr list --state merged --limit 10` around the merge time, and the + specific PRs you know are in flight). +3. Send "landed"; lift the freeze; push any held trunk commits. +4. Reset local refs to origin in every layer worktree; never force-push a + landed branch. +5. For a phase stack: the phase PR `trunk → develop` opens now; a + review-findings stack, if any, opens above the trunk. + +## Never + +- `--squash`. +- `gh pr merge --admin` to get past a ruleset; that is the user's decision + and the user's account. +- A push to the trunk between the final sync and the landed confirmation. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-link.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-link.md new file mode 100644 index 000000000..fe34d7c2c --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-link.md @@ -0,0 +1,100 @@ +# Recipe: open the PR and link it into the stack + +Use on the first push of every layer, and whenever several open PRs on one +trunk depend on each other. Stacking happens when the PR opens, never "once it +is green". One link command, the full ordered list, `--base` every time. + +## A. First push of a new layer + +1. Open the PR with base = the parent layer (the trunk for the bottom layer). + The body records the parent SHA the layer was cut from, the task or sprint + id, and the file fence: + + ```bash + gh pr create --base --head --title "" --body "$(cat <<'B' + Parent: <parent> @ <parent-sha> + Task: <id> + Fence: <paths this layer may touch> + B + )" + ``` + + Drafts block `gh stack merge`; open as ready, or mark ready before landing. + +2. Pre-link check from the main checkout (read-only): + + ```bash + python3 .claude/scripts/gh_stack_chain_check.py --trunk <trunk> <bottom> ... <layer> + ``` + + Exit 0 means every head is pushed, each layer contains its parent, PR bases + match the chain and the top merges clean into the trunk. Fix anything it + lists before linking. + +3. Link. The canonical form, used for the first link, after any unstack, and + whenever in doubt, is the full ordered list with `--base`: + + ```bash + gh stack link --base <trunk> <bottom-pr#> ... <top-pr#> + ``` + + Idempotent: re-run with the whole list whenever a PR is added. Sets every + base, pushes branches that are not pushed, creates PRs for branches without + one. Confirmed working for 2 to 21 PRs. The chain check prints this + command with the numbers filled in. + + Accepted shortcut when appending exactly one PR to an existing stack: + + ```bash + gh stack link <stack#> <pr#> + ``` + + It appends on top and retargets that PR onto the current top; it cannot + insert (`recipe-restack.md`), and `--base` is ignored by it. + + Run from a worktree checked out on a stack branch. Pass PR numbers, not + branch names, when running from the main checkout: a branch name pushes + the *local* ref, which may be another writer's unpushed state. + +4. Verify: `/sc-gh-stack-view`. Every base must equal its parent's head + (`✅` in the rebase column, VERDICT COHERENT). Record the stack number the + link printed. + +## B. Several open PRs on one trunk that must land together + +When two or more open PRs against the same trunk depend on each other (a fix +another PR's CI needs, docs describing code in a sibling, evidence for a fix), +or should land in one CI cycle instead of three: + +1. Order them by dependency, cleanest-first at the bottom: the PR the others + need lowest, docs and likely-PASS layers low, code with open findings + above. +2. Make the chain linear before linking. For each layer above the bottom, its + writer makes **one plain merge-forward commit** of the layer below + (`git merge --no-ff origin/<lower>`; never a rebase, never a force-push), + pushes, and that layer is then frozen unless it is the top. Independent + PRs are never left as a fork: `gh stack merge` refuses a stack whose layers + are not linear descendants, and the chain check fails it. +3. `python3 .claude/scripts/gh_stack_chain_check.py --trunk <trunk> <bottom-pr#> ... <top-pr#>` + until it prints LINKABLE. +4. `gh stack link --base <trunk> <bottom-pr#> ... <top-pr#>` from a layer + worktree. +5. From here the stack follows the model: nothing below the top is edited + again, QA runs once on the top (a QA already in flight on a lower PR may + finish and its verdict carries forward), CI gates the top, and the stack + lands once. Tell every writer that bases changed and that only the top + moves. +6. `/sc-gh-stack-view`; record the stack number. + +## Never + +- `gh stack link` without `--base` when creating or re-creating a stack. +- The trunk, the trunk worktree, or the phase PR as a layer. +- Holding a PR out of the stack until CI is green. +- A mid-stack insert via `link` (it fails with HTTP 422 then "new PRs must be + added to the top"): use `recipe-restack.md`. + +## Finish + +`/sc-gh-stack-view`, pasted verbatim, and the stack number recorded wherever +the team tracks it (ledger, PR bodies). diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-restack.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-restack.md new file mode 100644 index 000000000..93766443a --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-restack.md @@ -0,0 +1,99 @@ +# Recipe: restack (insert, remove a red layer, collapse the bottom) + +Three operations share one mechanism: `gh stack unstack <n>` (PRs and branches +untouched), fix PR bases with `gh pr edit --base`, then one full +`gh stack link --base <trunk> <ordered list>`. Every cycle mints a **new stack +number**; update any ledger or PR text that names it, clear stale tracking in +other worktrees, and finish with `/sc-gh-stack-view`. Merged PRs drop out of +the new stack by themselves. + +Unstacking and closing PRs are outward-facing: confirm with the user unless +the user directed the exact operation. + +## 1. Insert a layer mid-stack + +`gh stack link` cannot insert; with the new branch in the middle it fails with +`HTTP 422 PullRequest.base is invalid` and then "new PRs must be added to the +top of the existing stack". Confirmed three times. + +The layer above the insertion point must already contain the new branch's +head (its writer merges it forward with one merge commit first), or the +result is non-linear and cannot land. Then: + +```bash +gh stack unstack <n> # PRs untouched +gh pr edit <pr-above> --base <new-branch> # direct edit succeeds once unstacked +python3 .claude/scripts/gh_stack_chain_check.py --trunk <trunk> <bottom> ... <new> <above> ... <top> # must print LINKABLE +gh stack link --base <trunk> <bottom-pr#> ... <new-pr#> <pr-above#> ... <top-pr#> +``` + +If the worktree reports "Checkout an existing stack using gh stack checkout" +before the link, run `gh stack checkout <old stack#>` there first. Then +`recipe-stale-tracking.md`, then `/sc-gh-stack-view`. + +## 2. Remove a red layer whose fix lives above + +The stack cannot merge with a failing layer, and frozen layers are never fixed +in place. Do this the moment a layer goes red with its fix on a layer above, +so CI runs on every remaining layer in parallel instead of serialising behind +the merge. + +```bash +gh stack unstack <n> +gh stack link --base <trunk> <every GREEN pr#, bottom to top> # reduced list drops the red layer +gh pr close <red-pr#> --comment "Carried by #<pr-above>; branch kept." +``` + +Why this works: `link` never removes PRs, so the reduced list is the only way +to drop a layer. It retargets the green layer above onto the red layer's +parent, so the red layer's commits ride in the green PR's diff. The branch is +kept; nothing is deleted. Base changes do not restart CI on unchanged heads. + +Field result (phase-bc, 2026-09-23): four red PRs closed, absorbed by the two +green layers above them; stack #1564 became #1570 with 17 layers, coherent, +all green in one pass. + +Then `recipe-stale-tracking.md`, then `/sc-gh-stack-view`. + +## 3. Collapse the bottom + +When a contiguous bottom run is QA PASS and **green by itself**, merge it and +keep the open stack 2 to 3 layers deep. A 19-deep stack landed as one merge +after ten hours instead of six to eight small merges over the day, and its +`needsRebase` and stale-tracking noise made the view tool's verdict stop +matching reality. + +1. `/sc-gh-stack-view`: LANDING green, bottom layers QA PASS, no open + findings on them. +2. Read `branches[]` from `gh stack view --json`; confirm the layer above the + run has unique commits (an empty draft would be swept in and deleted). +3. If the bottom's own CI is red for a reason fixed above it, do **not** merge + it: remove the red layer first (section 2) so the fixing layer carries its + commits, then collapse. Merging #1492 alone put its red on `develop`. +4. Freeze the trunk (`preconditions.md`), then: + + ```bash + gh stack merge <highest-passing-pr#> --yes --merge + ``` + + Merges everything up to and including that PR, bottom to top, atomically. +5. GitHub retargets the next layer onto the trunk within about 30 seconds + and may rebase its branch (same tree, new committer dates). In that + layer's worktree: + + ```bash + git fetch origin + git diff --stat HEAD origin/<child> # must be empty: identical trees + git reset --hard origin/<child> # ONLY if the diff was empty; otherwise stop and report + ``` + + Never force-push the local branch over GitHub's rebase. +6. `/sc-gh-stack-view`. The next layer's `base` may now read as "behind + trunk", which is a note, not a rebase order. Lift the freeze; the next unit + of work is cut from the new trunk head. + +## Finish + +Every branch of this recipe ends with `recipe-stale-tracking.md` where a +stack number changed, `/sc-gh-stack-view` pasted verbatim, and the new stack +number recorded. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-stale-tracking.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-stale-tracking.md new file mode 100644 index 000000000..4f14241f6 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/recipe-stale-tracking.md @@ -0,0 +1,59 @@ +# Recipe: clear stale local stack tracking + +Use when `/sc-gh-stack-view` shows 🔄 on a layer, reports NOT COHERENT with +`-` rows, or names a stack number that no longer exists on GitHub; and after +every unstack or re-link as a matter of course. + +## Why it happens + +gh-stack tracking is stored **per worktree**. After `gh stack unstack` and a +re-link, the worktree that ran them knows the new stack; every other worktree +still holds the old stack number and the old layer list. The view tool +discovers stacks through `git worktree list`, runs `gh stack view --json` in +each, and keeps the *longest* view of a stack (a lower-layer worktree only +sees the layers linked from it). A stale worktree therefore wins with its +longer, obsolete list and the report shows a false NOT COHERENT. + +## Steps + +1. Find the worktrees that carry tracking: + + ```bash + git worktree list --porcelain | sed -n 's/^worktree //p' | while IFS= read -r wt; do + out=$(cd "$wt" && gh stack view --json 2>/dev/null) || continue + printf '%s: ' "$wt" + printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["trunk"], "->", [b["name"] for b in d["branches"]])' + done + ``` + + `/sc-gh-stack-view --all --json` shows the same information as + `stacks[].worktree` and `stacks[].rows[]`. + +2. In every worktree whose list is not the current stack: + + ```bash + cd <stale-worktree> && gh stack unstack --local # local only, GitHub untouched + ``` + +3. In one worktree that is checked out on a branch of the current stack: + + ```bash + gh stack checkout <new-stack#> # imports tracking; "Already on <branch>" is fine + gh stack checkout <any-pr#-in-the-stack> # same effect when the stack number was not recorded + ``` + + If the local and remote compositions differ, `checkout` opens an + interactive prompt that cannot be bypassed; that is exactly why step 2 runs + first. + +4. `/sc-gh-stack-view`. The verdict now reflects GitHub, not a dead list. + +## Notes + +- `gh stack unstack --local` never contacts GitHub and never touches PRs or + branches. +- A worktree that prints "Checkout an existing stack using gh stack checkout" + has no tracking; run `gh stack checkout <stack#>` there before any write + command from it. +- Do not run `gh stack sync` or `gh stack rebase` from a stale worktree: they + rewrite and force-push every layer over someone else's push. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/stack-design.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/stack-design.md new file mode 100644 index 000000000..67b32f7cf --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/stack-design.md @@ -0,0 +1,66 @@ +# Designing a stack + +Read this before cutting the first layer of a stack. It covers how to decide what goes in each layer so the stack does not need restructuring later (there is no non-interactive in-place reorder). In this model each layer is its own worktree cut from the pushed top (`recipe-cut-layer.md`); where the upstream text below says `gh stack add`, read "cut the next layer". + +## Dependency chain + +Stacked branches form a dependency chain: each branch builds on the one below it. Foundational changes (models, APIs, shared utilities) belong in lower branches; dependent changes (UI, consumers) belong in higher branches. If code in one layer depends on code in another, the dependency must live in the same branch or a lower one. + +## Plan layers before code + +Decide the layers first, then write into them: + +``` +main (trunk) + └── data-models ← shared types, database schema + └── api-endpoints ← API routes that use the models + └── frontend-ui ← UI components that call the APIs + └── integration ← tests exercising the full stack +``` + +This is illustrative — infer the actual topic and layer names from the task at hand, never reuse generic names literally. The failure mode to avoid is writing everything on one branch and trying to split it afterward; if a task is large enough to warrant a stack, create the stack at the start. + +## Branch naming + +Names are used exactly as given to `init`/`add` — nothing is prepended or transformed, and slashes are kept as part of the name (`gh stack add refactor/foo` creates a branch literally named `refactor/foo`). Prefer a shared topic prefix plus the layer's concern, e.g. `billing/schema`, `billing/api`, `billing/ui` — this keeps related branches recognizable without generic names that could belong to any stack. User and repository branch-naming conventions take precedence over this default; follow them instead. If `-m` is passed to `add` without a branch name, the name is auto-generated from the commit message in date+slug form (e.g. `03-24-add_api_routes`) — prefer naming the branch yourself. + +## Staging changes deliberately + +Use `git add`/`git commit` directly rather than `add -Am` as the default, to control exactly which changes land in which branch: + +```bash +git add internal/models/user.go internal/models/session.go +git commit -m "Add user and session models" + +gh stack add api-routes +git add internal/api/routes.go internal/api/handlers.go +git commit -m "Add user API routes" +``` + +Multiple commits per branch are fine — what matters is that every commit in a branch serves the same concern, and a change belonging to a different concern goes in a different branch. Note that `add <branch>` without `-Am` never touches the working tree, so uncommitted changes carry onto the new branch; commit or stash first if you want a clean start. + +## When to create a new branch + +Cut a new layer (`recipe-cut-layer.md`; upstream: `gh stack add`) when starting a different concern that depends on what's already built. Signals: + +- Switching from backend to frontend work +- Moving from core logic to tests or documentation +- The next changes have a different reviewer audience +- The current branch's PR is already large enough to review on its own + +A layer that can't be described in one sentence is usually two layers. + +## One stack, one story + +Think of a stack from the reviewer's perspective: it should tell a cohesive story about a feature, and a reviewer should be able to read the PRs in sequence and understand the progression. + +**Use a single stack** when every branch serves the same feature or project, even if it spans multiple concerns (models, API, frontend). + +**Use a separate stack** for work that's unrelated to the current effort — a different feature, an unrelated bug fix, an independent refactor. Don't mix unrelated work into one stack just because you happen to be touching both. Start a separate stack (its own first layer cut from the trunk, its own `gh stack link --base`) for each distinct effort. A trivial incidental fix (e.g. a typo you noticed) can ride along in the current stack; once it grows into its own project, it deserves its own stack. + +## Field-verified + +- **Cut layers on crate/module boundaries** — e.g. storage → runtime → CLI → docs — not on vertical slices that touch every module in every layer. A vertical-slice cut is what makes every layer conflict with its neighbor on rebase and forces constant merge-forwards; a module-boundary cut keeps each layer's diff isolated to the files that layer owns. +- **A re-export facade lands on the layer of its first consumer**, not on the frozen-types layer below it. Putting a facade on the types layer just because it re-exports types creates a false dependency and drags unrelated consumer churn into that PR. +- **Treat a blown budget as a signal to stop and re-cut, not push through.** A stack planned at N layers that reaches 2N with nothing merged, or any single layer that needs a third fix round, has failed its plan — stop appending new layers and re-cut the plan by layer (`recipe-restack.md` for the mechanics of dropping or reordering layers) instead of continuing to pile on. +- **Small fixes don't need a stack.** One owner, well under a few hundred lines, fix and its tests together — that's one ordinary PR off trunk, no stack at all. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/troubleshooting.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/troubleshooting.md new file mode 100644 index 000000000..25d351cd3 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/troubleshooting.md @@ -0,0 +1,111 @@ +# Troubleshooting gh stack + +Read this when a `gh stack` command fails, exits non-zero, or produces a confusing message — it maps error signatures to root cause and the concrete recovery steps, including failure modes only confirmed by hands-on use, not by `--help`. + +**Stack-writer only.** `unstack`, `sync`, `rebase`, `submit` and `merge` rewrite +or land shared state: only the stack writer runs them, `merge`, `unstack` and +`gh pr close` are confirmed with the user unless the user directed them +(`SKILL.md` precondition 10), and a layer that has children is never +rewritten. Where a recovery below differs from a recipe, the recipe wins. + +## Handle rebase conflicts (exit 3) + +```bash +gh stack rebase +# exit 3 — conflicted paths listed on stderr +# Read the files, resolve the <<<<<<< / ======= / >>>>>>> markers +git add path/to/resolved-file +gh stack rebase --continue # repeat if the next branch also conflicts +# or, to bail out entirely: +gh stack rebase --abort # restores every branch in the stack +``` + +`sync` restores all branches to their pre-rebase state before exiting 3, so a failed `sync` never leaves anything half-applied; a failed plain `rebase` stops mid-flight and waits for `--continue`/`--abort`. Because `init` enables `git rerere`, a conflict resolved once is replayed automatically the next time it recurs — common, since a low change gets rebased through every branch above it. + +## Squash-merge recovery + +**Not applicable in this model** (merge commits only, never `--squash`); kept for repositories that squash. A squash-merged PR's original commits no longer exist in trunk history. `gh stack sync` detects this and rebases with `git rebase --onto` to replay the remaining commits correctly, skipping the merged branch: + +```bash +gh stack sync +gh stack view --json # merged branch: "isMerged": true, "state": "MERGED" +``` + +If the replay conflicts, `sync` restores everything and exits 3 — resolve with `gh stack rebase` / `--continue` as above. + +## Local and remote stacks diverged + +Divergence: the local stack and the GitHub stack changed differently (e.g. a branch added locally while a PR was added to the stack on github.com). **Non-interactively, `sync` prints `ℹ Sync aborted`, changes nothing, and exits 0.** Exit 0 here is NOT success — always check stderr for "Sync aborted" or diff `gh stack view --json` before/after to confirm sync actually happened. + +Resolution in this model: the GitHub stack is the truth, local tracking is +disposable. In every worktree whose tracking is stale run +`gh stack unstack --local` (GitHub untouched), then `gh stack checkout <stack#>` +in one worktree on a stack branch: `recipe-stale-tracking.md`. Never resolve a +divergence with `gh stack submit --auto`; it force-pushes every local branch, +which may be another writer's unpushed state. + +Remote unstacking leaves queued or auto-merge-enabled PRs stacked; clear that state first if you need a clean unstack. + +## Restructure a stack + +No non-interactive reorder, rename or removal exists, and `gh stack link` +cannot insert mid-stack. The mechanism for every restructure (insert a layer, +drop a red layer, collapse the bottom) is `gh stack unstack <n>` (PRs and +branches untouched), `gh pr edit <pr> --base <intended parent>` where a base +must change, then one full `gh stack link --base <trunk> <bottom-pr#> ... +<top-pr#>`: `recipe-restack.md`. Do not use `gh stack init` + `gh stack submit +--auto` to rebuild: `init` checks out the last branch in the current worktree +(wrong under one-worktree-per-layer) and `submit` force-pushes local refs. +Ancestry is fixed with git first, and only on a layer that has no children; +metadata never changes ancestry. + +## Branch belongs to several stacks (exit 6) + +The current branch can't identify a single stack (typically it's the trunk of more than one). No flag disambiguates: + +```bash +gh stack checkout <a-branch-unique-to-the-intended-stack> +``` + +Commands taking an explicit stack number (`merge 7`, `unstack 7`) sidestep this since they don't infer from the current branch. + +## Stack file locked (exit 8) + +Another `gh stack` process holds `.git/gh-stack.lock`. The lock times out after ~5 seconds — wait and retry. A persistent exit 8 means another process still holds it; find and stop it. + +## Interrupted modify (exit 10) + +`gh stack modify` is TUI-only and this skill never invokes it. If a repo is left in this state by someone else: + +```bash +gh stack modify --abort +``` + +`submit` also detects a pending modify state and, under a TTY, asks before overwriting the GitHub stack with local state. + +## Stacked PRs unavailable (exit 9) + +The repository doesn't have stacked PRs enabled. This can't be fixed from the CLI — a repo admin must enable it on GitHub. Stop and tell the user. + +## `checkout` conflict prompt + +`gh stack checkout <pr-number>` when a different local stack already exists over those branches triggers an unbypassable interactive conflict-resolution prompt. Avoid it: run `gh stack unstack --local` first (keeps the GitHub stack intact), then retry the checkout. + +## Field-verified failure signatures + +| Observed message | Cause | Fix | +|---|---|---| +| `stack is out-of-date with its base branch` | Someone pushed to trunk after the final sync | Freeze trunk pushes during merge, use the merge-async fallback, or re-sync (`gh stack sync`) and retry | +| `PR #X's branch is not a linear descendant of PR #Y's branch` | A lower layer was rewritten (rebased/force-pushed) after children branched from it | `gh stack unstack`, `gh pr edit <top-PR> --base <trunk>`, merge the top PR directly — GitHub marks the lower PRs MERGED by ancestry | +| `HTTP 422 PullRequest.base is invalid` / `new PRs must be added to the top of the existing stack` | Tried a mid-stack insert via `gh stack link` | `unstack` + `gh pr edit <PR> --base <new-base>` + full re-link with the complete branch list | +| `Pull request head branch was modified` (on a `merge-async` call) | Passed an abbreviated SHA instead of the full 40-char `headRefOid` | Re-fetch `headRefOid` from `gh stack view --json` / `gh pr view --json headRefOid` and pass it in full | +| `...part of a stack and must be merged using the asynchronous merge REST API` | Ran `gh pr merge` on a stacked PR | Use `gh stack merge`, or the `merge-async` API fallback directly | +| False `NOT COHERENT` / `-` rows from the view tool | Stale per-worktree gh-stack tracking after an unstack/re-link done in a different worktree | `gh stack unstack --local` in the stale worktree, then `gh stack checkout <new-stack#>` | +| `Checkout an existing stack using gh stack checkout` | Worktree lost tracking after an unstack elsewhere | `gh stack checkout <stack#>` — "Already on `<branch>`" in response is fine, not an error | +| `GH013: Repository rule violations... required status checks are expected` | Direct push to a protected trunk hits a ruleset | Not transient — go through a PR, or use a bypass-listed account; never edit the ruleset yourself | +| Legacy `git merge-tree <base> <a> <b>` prints diff3-style hunks | 3-arg `merge-tree` always prints combined diff output — it is not a conflict signal by itself | Use `git merge-tree --write-tree <a> <b>` and check its exit code, or do a real `git merge --no-commit` in a scratch worktree | +| CI log on a lower layer reads `Merge <head> into <base>` and is red | CI ran against the base as it stood at run start; a base-branch defect fixed afterward will show green on the next push | Read that log line before escalating — it's often stale, not a real regression | + +## Multi-worktree note + +gh-stack's local tracking is per worktree. After any `unstack`/re-link done in one worktree, treat tracking in every other worktree as stale: run `gh stack unstack --local` there, then `gh stack checkout <new-stack#>` in exactly one worktree that is on a stack branch. diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/references/workflow.md b/packages/sc-gh-stack/skills/sc-gh-stack/references/workflow.md new file mode 100644 index 000000000..d4b35dc41 --- /dev/null +++ b/packages/sc-gh-stack/skills/sc-gh-stack/references/workflow.md @@ -0,0 +1,125 @@ +# Lifecycle of a stack, step by step + +Read this when you are about to start, extend, verify or QA a stack. Each step +names the recipe that carries the exact commands. The steps are what worked +across five production phases; the checks inside them are what stopped the +failures listed in `preconditions.md`. + +## 0. Plan the layers before any code + +Cut layers on module or crate boundaries (storage → runtime → CLI → docs), one +concern per layer, so no two layers touch the same files. Vertical slices that +touch every module in every layer make every layer conflict with its neighbour +and turn the phase into merge-forwards. Details and the stop rule: +`stack-design.md`. + +## 1. Cut the layer from the current pushed top + +New worktree, branch from `origin/<current top>`, after verifying the top +contains every lower layer's head. Never from a local ref, never from a head +that is still unpushed. Recipe: `recipe-cut-layer.md`. + +Trigger for the *next* sprint: "could the next sprint compile against this +head?" As soon as a sprint's types, schema and core paths are pushed and only +test, lint or QA rounds remain, cut the next layer and start its dev. Waiting +for QA PASS, CI green or the merge left two devs idle for an hour. + +## 2. PR on the first push, link immediately + +The dev pushes a WIP commit within minutes. The stack writer opens the PR +(base = parent branch; body records the parent SHA, the task id and the file +fence) and links it. Stacking is part of opening the PR, not of merging it. A +branch without a PR has no CI and cannot be judged; a PR held out of the stack +"until it is green" hides the state it was meant to show. Recipe: +`recipe-link.md`. + +## 3. Verify with git before every link + +The stack writer runs, for the proposed order bottom to top: + +```bash +git fetch origin +git log --format='%h %p' -1 origin/<layer> # parents +git merge-base --is-ancestor origin/<parent> origin/<layer> && echo contains-parent +git diff --stat origin/<parent> origin/<layer> # file fence check +git merge-tree --write-tree origin/<trunk> origin/<top> # exit 0 = merges clean +``` + +`gh_stack_chain_check.py --trunk <trunk> <bottom> ... <top>` runs exactly these +checks and reports PR bases too. Mergeability is the `--write-tree` exit code +or a real `git merge --no-commit` in a scratch worktree, never the legacy +3-arg `merge-tree` (it printed a false conflict on a clean merge and the report +had to be retracted). + +## 4. Rebase at task start, once, by the writer + +A layer moves exactly once per task, at the start, by its single writer, and +only if it is live and has no children (`preconditions.md`, "Before rewriting +or rebasing a layer"): + +```bash +git fetch origin +git rebase --onto origin/<parent> <recorded-parent-base> <layer> +git push --force-with-lease +``` + +Record the new parent SHA (ledger, PR body). Between tasks layers do not move. +Never per-push rebases (they cascade force-pushes under live agents), never a +rebase of a frozen layer to catch up with the trunk, never a rebase of a layer +whose CI could go green just to catch up. Frozen intermediate layers are +rebased by the stack writer in one pass, only when the layer below them +freezes and only if the landing needs it. + +## 5. Freeze means freeze + +When a layer's minimum functionality is complete and its task closes, it is +frozen. Red CI on it is not fixed there. A finding on it is a new layer above +the top. If a lower layer *must* be rewritten, every child is rebased in the +same pass before anyone branches again; otherwise the children hold stale +copies, every downstream diff shows phantom regressions, and the stack can no +longer land linearly (`recipe-land.md`, fallback B). + +## 6. Fix rounds bundle every finding for one owner on one layer + +Non-blocking findings from every layer are collected and fixed once, on a new +top layer, with one QA pass there. New findings that arrive mid-round are +appended to the same task when the file fence covers them. Before dispatching +a fix, write one line per finding stating the exact change and the files +allowed; a fix layer that touches an unlisted file is rejected. Never +per-branch fix rounds; never wait for a lower layer's QA or CI before the top +moves. + +## 7. QA at a pinned SHA, on the top + +Dispatch QA once when the top is pushed. Pin the review head; QA reads code +with `git show <sha>:<path>`, never from a worktree that may move. The diff +under review is `layer head` versus the commit it was cut from +(`git merge-base <base> <head>`, or the second parent of the last merge-forward +commit), not versus the moving remote base; otherwise a rewritten lower layer's +delta is misattributed as this layer's regression. Post the verdict on the PR. + +## 8. Status after every write + +`/sc-gh-stack-view` after every link, merge, unstack and before any dispatch. +It shows base coherence (base == parent head), `needsRebase`, +`mergeStateStatus`, CI, stale local tracking and a LANDING line. If the +LANDING line is green and a contiguous bottom run is QA PASS and green by +itself, collapse it (`recipe-restack.md`) and keep the open stack 2 to 3 deep. + +## 9. Land once, from the top + +All layers frozen, QA PASS on the top, top CI green, trunk frozen and acked: +one `gh stack merge --yes --merge`. Then `/sc-gh-stack-view`, confirm no +in-flight PR was closed as a side effect, lift the freeze. Recipe: +`recipe-land.md`. + +## Roles at a glance + +| Action | Who | +|--------|-----| +| Push commits to a layer | That layer's writer only | +| Open PR, `gh stack link` / `unstack` / `sync` / `rebase` / `merge` | Stack writer only | +| Rebase a layer at task start | That layer's writer | +| Rebase frozen intermediate layers | Stack writer, one pass | +| Push to trunk | Nobody while a stack sync or merge is in flight | +| Edit repository rulesets or branch protection | Never the agent; report the exact change needed | diff --git a/packages/sc-gh-stack/tests/test_gh_stack_chain_check.py b/packages/sc-gh-stack/tests/test_gh_stack_chain_check.py new file mode 100644 index 000000000..ca4c9d278 --- /dev/null +++ b/packages/sc-gh-stack/tests/test_gh_stack_chain_check.py @@ -0,0 +1,301 @@ +"""Unit tests for gh_stack_chain_check.evaluate over injected git/gh lookups.""" +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest import mock + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "gh_stack_chain_check.py" +spec = importlib.util.spec_from_file_location("gh_stack_chain_check_under_test", SCRIPT) +assert spec is not None and spec.loader is not None +gcc = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = gcc +spec.loader.exec_module(gcc) + +TRUNK = "develop" +T0, A1, B2, C3, OLD = ("0" * 40, "a" * 40, "b" * 40, "c" * 40, "d" * 40) + + +def pr(number: int, head: str, base: str, oid: str, *, draft: bool = False, state: str = "OPEN") -> dict: + return {"number": number, "headRefName": head, "baseRefName": base, "headRefOid": oid, + "isDraft": draft, "state": state} + + +class EvaluateTests(unittest.TestCase): + def setUp(self) -> None: + self.origins = {TRUNK: T0, "l1": A1, "l2": B2, "l3": C3} + # linear chain: T0 < A1 < B2 < C3 + self.ancestors = {(T0, A1), (T0, B2), (T0, C3), (A1, B2), (A1, C3), (B2, C3)} + self.merge = True + for p in ( + mock.patch.object(gcc, "origin_sha", side_effect=lambda ref: self.origins.get(ref)), + mock.patch.object(gcc, "is_ancestor", side_effect=lambda a, b: a == b or (a, b) in self.ancestors), + mock.patch.object(gcc, "merge_clean", side_effect=lambda base, head: self.merge), + ): + p.start() + self.addCleanup(p.stop) + self.prs = {"l1": pr(1, "l1", TRUNK, A1), "l2": pr(2, "l2", "l1", B2), "l3": pr(3, "l3", "l2", C3)} + + def test_linear_pushed_chain_is_linkable(self) -> None: + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"], report["problems"]) + self.assertEqual(report["notes"], []) + self.assertTrue(report["landing"]["clean"]) + text = gcc.render(report) + self.assertIn("next: gh stack link --base develop 1 2 3", text) + self.assertNotIn("#1", text.split("next:")[1], "a `#` would start a shell comment") + self.assertEqual(report["link_command"], "gh stack link --base develop 1 2 3") + + def test_unpushed_layer_is_a_problem(self) -> None: + self.origins.pop("l2") + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertFalse(report["linkable"]) + self.assertTrue(any("l2: not on origin" in p for p in report["problems"])) + self.assertIsNone(report["landing"]["clean"], "landing not judged while a head is unpushed") + + def test_fork_layer_does_not_contain_parent(self) -> None: + # l3 was cut from l1, not from l2: (B2, C3) missing + self.ancestors.discard((B2, C3)) + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertFalse(report["linkable"]) + self.assertTrue(any("l3: does not contain parent l2" in p for p in report["problems"])) + + def test_bottom_behind_trunk_is_a_note_not_a_problem(self) -> None: + # trunk moved past the bottom's base; bottom still has its own commits + self.ancestors.discard((T0, A1)) + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"], report["problems"]) + self.assertTrue(any("l1: behind develop" in n for n in report["notes"])) + + def test_bottom_already_in_trunk_is_a_problem(self) -> None: + self.ancestors.discard((T0, A1)) + self.ancestors.add((A1, T0)) + report = gcc.evaluate(TRUNK, ["l1", "l2"], self.prs, fetched=True, use_pr=True) + self.assertTrue(any("no commits beyond develop" in p for p in report["problems"])) + + def test_wrong_pr_base_is_a_note_link_corrects_it(self) -> None: + self.prs["l2"] = pr(2, "l2", TRUNK, B2) # should be l1 + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"]) + self.assertTrue(any("PR #2 base is develop, expected l1" in n for n in report["notes"])) + self.assertFalse(report["rows"][1]["pr_base_ok"]) + + def test_draft_closed_and_stale_pr_heads_are_problems(self) -> None: + self.prs["l1"] = pr(1, "l1", TRUNK, A1, draft=True) + self.prs["l2"] = pr(2, "l2", "l1", B2, state="MERGED") + self.prs["l3"] = pr(3, "l3", "l2", OLD) + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + joined = "\n".join(report["problems"]) + self.assertIn("PR #1 is DRAFT", joined) + self.assertIn("PR #2 is MERGED", joined) + self.assertIn("PR #3 head ddddddddd != origin ccccccccc", joined) + + def test_missing_pr_is_a_note(self) -> None: + self.prs.pop("l3") + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"]) + self.assertTrue(any("l3: no PR yet" in n and "base l2" in n for n in report["notes"])) + text = gcc.render(report) + self.assertIn("next: gh stack link --base develop 1 2 l3", text) + self.assertIn("bare branch name pushes the LOCAL ref", text) + + def test_top_conflicts_with_trunk(self) -> None: + self.merge = False + report = gcc.evaluate(TRUNK, ["l1", "l2", "l3"], self.prs, fetched=True, use_pr=True) + self.assertFalse(report["linkable"]) + self.assertFalse(report["landing"]["clean"]) + self.assertIn("MERGE INTO TRUNK: ⛔", gcc.render(report)) + + def test_old_git_marks_merge_unknown(self) -> None: + self.merge = None + report = gcc.evaluate(TRUNK, ["l1"], self.prs, fetched=True, use_pr=True) + self.assertTrue(report["linkable"]) + self.assertIsNone(report["landing"]["clean"]) + self.assertIn("git < 2.38", report["landing"]["reason"]) + + def test_missing_trunk_on_origin_raises_tool_error(self) -> None: + self.origins.pop(TRUNK) + with self.assertRaises(gcc.ToolError): + gcc.evaluate(TRUNK, ["l1"], self.prs, fetched=True, use_pr=True) + + def test_duplicate_branch_is_a_problem(self) -> None: + report = gcc.evaluate(TRUNK, ["l1", "l1"], self.prs, fetched=True, use_pr=True) + self.assertTrue(any("appears twice" in p for p in report["problems"])) + + +class IndexAndResolveTests(unittest.TestCase): + def test_open_pr_wins_over_closed_for_same_branch(self) -> None: + prs = [pr(5, "l1", TRUNK, OLD, state="CLOSED"), pr(9, "l1", TRUNK, A1)] + by_number, by_branch = gcc.index_prs(prs) + self.assertEqual(by_branch["l1"]["number"], 9) + self.assertEqual(set(by_number), {5, 9}) + + def test_newest_wins_among_same_state(self) -> None: + prs = [pr(5, "l1", TRUNK, OLD, state="CLOSED"), pr(7, "l1", TRUNK, A1, state="CLOSED")] + _, by_branch = gcc.index_prs(prs) + self.assertEqual(by_branch["l1"]["number"], 7) + + def test_resolve_numbers_and_names(self) -> None: + by_number, _ = gcc.index_prs([pr(12, "fix/x", TRUNK, A1)]) + self.assertEqual(gcc.resolve_layers(["12", "feat/y"], by_number), ["fix/x", "feat/y"]) + with mock.patch.object(gcc, "view_pr", side_effect=gcc.ToolError("PR #99 not found")): + with self.assertRaises(gcc.ToolError): + gcc.resolve_layers(["99"], by_number) + + +if __name__ == "__main__": + unittest.main() + + +class MainContractTests(unittest.TestCase): + """Exit codes 0/1/2 and the never-a-traceback guard, with every external call patched.""" + + def _args(self, **kw) -> "gcc.argparse.Namespace": + base = {"trunk": TRUNK, "layers": ["l1"], "no_fetch": True, "no_pr": True, "json": False} + base.update(kw) + return gcc.argparse.Namespace(**base) + + def test_exit_0_when_linkable(self) -> None: + with mock.patch.object(gcc, "preflight"), \ + mock.patch.object(gcc, "evaluate", return_value={"trunk": TRUNK, "trunk_origin": T0, "rows": [], "problems": [], "notes": [], "landing": {"clean": None, "reason": "x"}, "linkable": True}), \ + mock.patch("builtins.print"): + self.assertEqual(gcc.run_check(self._args()), 0) + + def test_exit_1_when_problems(self) -> None: + with mock.patch.object(gcc, "preflight"), \ + mock.patch.object(gcc, "evaluate", return_value={"trunk": TRUNK, "trunk_origin": T0, "rows": [], "problems": ["p"], "notes": [], "landing": {"clean": None, "reason": "x"}, "linkable": False}), \ + mock.patch("builtins.print"): + self.assertEqual(gcc.run_check(self._args()), 1) + + def test_exit_2_on_tool_error_and_on_unexpected_exception(self) -> None: + import io + for exc in (gcc.ToolError("git missing"), KeyError("owner")): + with mock.patch.object(gcc, "preflight", side_effect=exc), \ + mock.patch.object(gcc.sys, "stderr", new=io.StringIO()) as err: + self.assertEqual(gcc.guarded("gh-stack-chain-check", lambda: gcc.run_check(self._args())), 2) + self.assertTrue(err.getvalue().startswith("gh-stack-chain-check: "), err.getvalue()) + self.assertNotIn("Traceback", err.getvalue()) + + def test_numeric_layer_outside_open_list_uses_pr_view(self) -> None: + by_number, by_branch = gcc.index_prs([]) + with mock.patch.object(gcc, "view_pr", return_value=pr(1500, "fix/old", TRUNK, A1, state="MERGED")) as vp: + self.assertEqual(gcc.resolve_layers(["1500"], by_number, by_branch), ["fix/old"]) + vp.assert_called_once_with(1500) + self.assertEqual(by_branch["fix/old"]["state"], "MERGED") + + +class ErrorConditionTests(unittest.TestCase): + def setUp(self) -> None: + self.origins = {TRUNK: T0, "l1": A1} + for p in ( + mock.patch.object(gcc, "origin_sha", side_effect=lambda ref: self.origins.get(ref)), + mock.patch.object(gcc, "is_ancestor", side_effect=lambda a, b: a == b or (a, b) == (T0, A1)), + mock.patch.object(gcc, "merge_clean", return_value=True), + ): + p.start() + self.addCleanup(p.stop) + + def test_trunk_listed_as_layer_is_a_problem(self) -> None: + report = gcc.evaluate(TRUNK, ["l1", TRUNK], {}, fetched=True, use_pr=False) + self.assertTrue(any("is the trunk" in p for p in report["problems"])) + + def test_index_prs_skips_malformed_entries(self) -> None: + by_number, by_branch = gcc.index_prs([{"number": "x"}, {"headRefName": "a"}, pr(3, "l1", TRUNK, A1), "junk"]) + self.assertEqual(list(by_number), [3]) + self.assertEqual(list(by_branch), ["l1"]) + + def test_view_pr_rejects_bad_shape(self) -> None: + cp = __import__("subprocess").CompletedProcess(["gh"], 0, stdout='{"number": 5}', stderr="") + with mock.patch.object(gcc, "run", return_value=cp): + with self.assertRaises(gcc.ToolError): + gcc.view_pr(5) + + def test_fetch_failure_is_a_warning_not_exit_2(self) -> None: + import io + failed = __import__("subprocess").CompletedProcess(["git"], 128, stdout="", stderr="fatal: could not read from remote") + with mock.patch.object(gcc, "preflight"), \ + mock.patch.object(gcc, "run", return_value=failed), \ + mock.patch.object(gcc, "evaluate", return_value={"trunk": TRUNK, "trunk_origin": T0, "rows": [], "problems": [], "notes": [], "landing": {"clean": None, "reason": "x"}, "linkable": True}), \ + mock.patch("builtins.print"), \ + mock.patch.object(gcc.sys, "stderr", new=io.StringIO()) as err: + args = gcc.argparse.Namespace(trunk=TRUNK, layers=["l1"], no_fetch=False, no_pr=True, json=False) + self.assertEqual(gcc.run_check(args), 0) + self.assertIn("warning: git fetch origin failed", err.getvalue()) + + def test_run_hint_for_rate_limit_and_auth(self) -> None: + shared = gcc.sys.modules[gcc.run.__module__] + self.assertIn("rate limit", shared.hint_for("HTTP 403: API rate limit exceeded")) + self.assertIn("gh auth", shared.hint_for("gh: Not logged in (HTTP 401)")) + self.assertEqual(shared.hint_for("something else"), "") + + +class NextLineTests(unittest.TestCase): + def test_no_next_line_when_not_linkable(self) -> None: + report = {"trunk": TRUNK, "trunk_origin": T0, "rows": [{"layer": 1, "branch": "l1", "pr": 1, "pushed": False, + "contains_parent": None, "pr_base_ok": True, "pr_head_ok": True, "draft": False}], + "problems": ["L1 l1: not on origin"], "notes": [], "landing": {"clean": None, "reason": "x"}, "linkable": False} + self.assertNotIn("next:", gcc.render(report)) + + def test_link_command_survives_a_shell(self) -> None: + import shlex, subprocess + report = {"trunk": "develop", "rows": [{"branch": "l1", "pr": 1547}, {"branch": "l2", "pr": 1548}], "linkable": True} + cmd = gcc.link_command(report) + echoed = subprocess.run(["sh", "-c", "echo " + cmd], text=True, capture_output=True).stdout.strip() + self.assertEqual(echoed, cmd) + self.assertEqual(shlex.split(cmd)[-2:], ["1547", "1548"]) + + +class RealGitTests(unittest.TestCase): + """Prove the git semantics the chain logic assumes, against a real temporary repository.""" + + @classmethod + def setUpClass(cls) -> None: + import shutil, subprocess, tempfile, os + if shutil.which("git") is None: + raise unittest.SkipTest("git not installed") + cls.tmp = tempfile.mkdtemp() + cls.cwd = os.getcwd() + os.chdir(cls.tmp) + env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x", "PATH": os.environ["PATH"], "HOME": cls.tmp} + def git(*a): return subprocess.run(["git", *a], check=True, text=True, capture_output=True, env=env).stdout.strip() + cls.git = staticmethod(git) + git("init", "-q", "-b", "develop") + Path("a.txt").write_text("a\n"); git("add", "."); git("commit", "-qm", "base") + git("checkout", "-qb", "l1"); Path("a.txt").write_text("l1\n"); Path("b.txt").write_text("b\n"); git("add", "."); git("commit", "-qm", "l1") + git("checkout", "-qb", "l2"); Path("c.txt").write_text("c\n"); git("add", "."); git("commit", "-qm", "l2") + git("checkout", "-q", "develop"); git("checkout", "-qb", "fork"); Path("a.txt").write_text("conflict\n"); git("add", "."); git("commit", "-qm", "fork") + git("checkout", "-q", "develop") + # simulate origin/* by pointing remote-tracking refs at the local branches + for b in ("develop", "l1", "l2", "fork"): + git("update-ref", f"refs/remotes/origin/{b}", b) + cls.sha = {b: git("rev-parse", b) for b in ("develop", "l1", "l2", "fork")} + + @classmethod + def tearDownClass(cls) -> None: + import os, shutil + os.chdir(cls.cwd) + shutil.rmtree(cls.tmp, ignore_errors=True) + + def test_origin_sha_and_is_ancestor(self) -> None: + self.assertEqual(gcc.origin_sha("l1"), self.sha["l1"]) + self.assertIsNone(gcc.origin_sha("nope")) + self.assertTrue(gcc.is_ancestor(self.sha["l1"], self.sha["l2"])) + self.assertFalse(gcc.is_ancestor(self.sha["l2"], self.sha["l1"])) + + def test_merge_clean_detects_conflict_and_clean_merge(self) -> None: + clean = gcc.merge_clean(self.sha["develop"], self.sha["l2"]) + conflict = gcc.merge_clean(self.sha["l1"], self.sha["fork"]) + if clean is None: + self.skipTest("git < 2.38: merge-tree --write-tree unavailable (reported as unknown, as designed)") + self.assertTrue(clean) + self.assertFalse(conflict) + + def test_evaluate_end_to_end_on_real_refs(self) -> None: + with mock.patch.object(gcc, "merge_clean", wraps=gcc.merge_clean): + good = gcc.evaluate("develop", ["l1", "l2"], {}, fetched=True, use_pr=False) + bad = gcc.evaluate("develop", ["l1", "fork"], {}, fetched=True, use_pr=False) + self.assertTrue(good["linkable"], good["problems"]) + self.assertFalse(bad["linkable"]) + self.assertTrue(any("fork: does not contain parent l1" in p for p in bad["problems"])) diff --git a/packages/sc-gh-stack/tests/test_gh_stack_view.py b/packages/sc-gh-stack/tests/test_gh_stack_view.py new file mode 100644 index 000000000..31744d837 --- /dev/null +++ b/packages/sc-gh-stack/tests/test_gh_stack_view.py @@ -0,0 +1,386 @@ +"""Regression tests for gh_stack_view.build_rows against gh-stack JSON shapes, +plus select_stacks (the generalized trunk-selection logic for sc-gh-stack-view). + +gh-stack v0.1.0 omits ``head``/``base`` for a layer whose branch is not present +locally (viewed from a sibling worktree before fetch). The report must degrade +to ❓/notes for that layer, never raise. Runs under pytest and standalone with +``python3 -m unittest``. +""" +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest import mock + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "gh_stack_view.py" +spec = importlib.util.spec_from_file_location("gh_stack_view_under_test", SCRIPT) +assert spec is not None and spec.loader is not None +gsv = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = gsv +spec.loader.exec_module(gsv) + +TRUNK = "integrate/phase-ax" +T0 = "0" * 40 +A1 = "a" * 40 +B2 = "b" * 40 +C3 = "c" * 40 +OLD = "d" * 40 + + +def layer(name: str, pr: int, *, head: str | None, base: str | None, needs_rebase: bool = False) -> dict: + entry = {"name": name, "isCurrent": False, "isMerged": False, "isQueued": False, + "needsRebase": needs_rebase, "pr": {"number": pr, "state": "OPEN"}} + if head is not None: + entry["head"] = head + if base is not None: + entry["base"] = base + return entry + + +def pr(head: str, base: str, *, mergeable: str = "MERGEABLE", state: str = "CLEAN") -> dict: + return {"headRefOid": head, "baseRefName": base, "mergeable": mergeable, + "mergeStateStatus": state, "isDraft": False, "ci": "SUCCESS"} + + +class BuildRowsShapeTests(unittest.TestCase): + def setUp(self) -> None: + self.origins = {TRUNK: T0, "fix/bottom": A1, "fix/middle": B2, "docs/top": C3} + patches = [ + mock.patch.object(gsv, "origin_sha", side_effect=lambda ref: self.origins.get(ref)), + mock.patch.object(gsv, "is_ancestor", return_value=False), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + + def coherent_stack(self) -> tuple[dict, dict]: + stack = {"trunk": TRUNK, "currentBranch": "fix/bottom", "branches": [ + layer("fix/bottom", 1, head=A1, base=T0), + layer("fix/middle", 2, head=B2, base=A1), + layer("docs/top", 3, head=C3, base=B2), + ]} + prs = {1: pr(A1, TRUNK), 2: pr(B2, "fix/bottom"), 3: pr(C3, "fix/middle")} + return stack, prs + + def test_full_shape_is_coherent(self) -> None: + stack, prs = self.coherent_stack() + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, []) + self.assertEqual(notes, []) + self.assertEqual([gsv.sync_icon(r) for r in rows], [gsv.ICON_SYNC["ok"]] * 3) + + def test_missing_head_and_base_keys_do_not_crash(self) -> None: + # Regression: gh stack view --json returned layers without head/base; + # build_rows raised KeyError('head') while formatting the origin problem. + stack, prs = self.coherent_stack() + stack["branches"][1] = layer("fix/middle", 2, head=None, base=None) + stack["branches"][2] = layer("docs/top", 3, head=None, base=None) + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, [], "remote side (origin == PR head) is coherent, so no problems") + self.assertTrue(any("no local head" in n and "fix/middle" in n for n in notes)) + self.assertTrue(any("no base SHA" in n and "docs/top" in n for n in notes)) + self.assertIsNone(rows[1]["head"]) + self.assertIsNone(rows[1]["base"]) + self.assertIsNone(rows[1]["base_ok"]) + self.assertEqual(gsv.sync_icon(rows[1]), gsv.ICON_SYNC["unknown"]) + + def test_missing_head_with_stale_pr_is_reported_not_raised(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][2] = layer("docs/top", 3, head=None, base=B2) + prs[3] = pr(OLD, "fix/middle") # PR head differs from origin -> stale push + rows, problems, _ = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(len(problems), 1) + self.assertIn("docs/top", problems[0]) + self.assertIn("local - / origin ccccccccc / PR ddddddddd differ", problems[0]) + self.assertEqual(gsv.sync_icon(rows[2]), gsv.ICON_SYNC["stale"]) + + def test_missing_head_falls_back_to_pr_head_for_next_layer_base(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][0] = layer("fix/bottom", 1, head=None, base=T0) + self.origins["fix/bottom"] = None # not fetched either + rows, problems, _ = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, []) + self.assertEqual(rows[1]["expected_base"], A1) + + def test_no_fetch_marks_unknown_without_raising(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][2] = layer("docs/top", 3, head=None, base=None) + rows, problems, _ = gsv.build_rows(stack, prs, fetched=False) + self.assertEqual(problems, []) + self.assertEqual(gsv.sync_icon(rows[2]), gsv.ICON_SYNC["unknown"]) + + def test_render_table_handles_missing_head(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][2] = layer("docs/top", 3, head=None, base=None) + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + landing = {"landable": None, "reason": "no open layer or trunk not fetched"} + text = gsv.render_table(stack, rows, problems, notes, landing, trunk_origin=T0) + self.assertIn("VERDICT: ✅ COHERENT", text) + self.assertIn("note: L3 docs/top: gh stack reported no local head", text) + + def test_needs_rebase_and_base_mismatch_still_flagged(self) -> None: + stack, prs = self.coherent_stack() + stack["branches"][1] = layer("fix/middle", 2, head=B2, base=OLD, needs_rebase=True) + _, problems, _ = gsv.build_rows(stack, prs, fetched=True) + self.assertTrue(any("base ddddddddd != parent head aaaaaaaaa" in p for p in problems)) + self.assertTrue(any("gh stack reports needsRebase" in p for p in problems)) + + +T1 = "1" * 40 # trunk head after the bottom layer merged + + +class MergedBottomLayerTests(unittest.TestCase): + """Regression: after ``gh stack merge``/merge-async lands the bottom layer, + GitHub retargets the next layer onto the trunk. The report used to demand + that layer be based on the MERGED branch (PR base and base SHA), producing + two false problems on a stack gh-stack itself reported as needsRebase=false.""" + + def setUp(self) -> None: + # Trunk moved from T0 to T1 (the merge commit of the bottom layer). + self.origins = {TRUNK: T1, "fix/bottom": A1, "fix/middle": B2, "docs/top": C3} + self.ancestors = {(T0, T1), (A1, T1)} + patches = [ + mock.patch.object(gsv, "origin_sha", side_effect=lambda ref: self.origins.get(ref)), + mock.patch.object(gsv, "is_ancestor", side_effect=lambda a, b: (a, b) in self.ancestors), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + + def merged_bottom_stack(self, *, middle_base: str, middle_pr_base: str = TRUNK) -> tuple[dict, dict]: + bottom = layer("fix/bottom", 1, head=A1, base=T0) + bottom["isMerged"] = True + bottom["pr"]["state"] = "MERGED" + stack = {"trunk": TRUNK, "currentBranch": "docs/top", "branches": [ + bottom, + layer("fix/middle", 2, head=B2, base=middle_base), + layer("docs/top", 3, head=C3, base=B2), + ]} + prs = {1: pr(A1, TRUNK, state="MERGED"), 2: pr(B2, middle_pr_base), 3: pr(C3, "fix/middle")} + return stack, prs + + def test_layer_above_merged_bottom_is_judged_against_trunk(self) -> None: + # gh stack reports the retargeted layer's base as the pre-merge trunk + # commit (T0), an ancestor of the new trunk head: behind trunk, not a rebase. + stack, prs = self.merged_bottom_stack(middle_base=T0) + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, [], problems) + self.assertEqual(len(notes), 1) + self.assertIn("L2 fix/middle: behind trunk", notes[0]) + self.assertEqual(gsv.sync_icon(rows[0]), gsv.ICON_MERGE["MERGED"]) + self.assertEqual(gsv.sync_icon(rows[1]), gsv.ICON_SYNC["behind"], "behind a moved trunk is its own icon, not a rebase order") + self.assertEqual(gsv.sync_icon(rows[2]), gsv.ICON_SYNC["ok"]) + self.assertEqual(rows[1]["expected_base"], T1, "expected base is the trunk head, not the merged layer's head") + + def test_layer_above_merged_bottom_on_trunk_head_is_clean(self) -> None: + stack, prs = self.merged_bottom_stack(middle_base=T1) + rows, problems, notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(problems, []) + self.assertEqual(notes, []) + self.assertEqual([gsv.sync_icon(r) for r in rows[1:]], [gsv.ICON_SYNC["ok"]] * 2) + + def test_layer_above_merged_bottom_still_targeting_merged_branch_is_flagged(self) -> None: + # GitHub has not retargeted the PR yet: that IS a problem the owner must fix. + stack, prs = self.merged_bottom_stack(middle_base=T1, middle_pr_base="fix/bottom") + _rows, problems, _notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(len(problems), 1) + self.assertIn("PR #2 base is fix/bottom, expected integrate/phase-ax", problems[0]) + + def test_open_parent_mismatch_is_still_a_problem(self) -> None: + # Above an OPEN layer the ancestor leniency must not apply. + stack, prs = self.merged_bottom_stack(middle_base=T1) + stack["branches"][2]["base"] = OLD + self.ancestors.add((OLD, B2)) + _rows, problems, _notes = gsv.build_rows(stack, prs, fetched=True) + self.assertEqual(len(problems), 1) + self.assertIn("L3 docs/top: base ddddddddd != parent head bbbbbbbbb -> needs rebase", problems[0]) + + +def found_stack(trunk: str, branch: str, pr_num: int, *, merged: bool = False, closed: bool = False) -> dict: + """Minimal deduped ``found`` entry: one layer is enough for select_stacks, + which only inspects trunk and branch-open state.""" + state = "MERGED" if merged else ("CLOSED" if closed else "OPEN") + return { + "trunk": trunk, + "worktree": f"/tmp/{branch}", + "branches": [{"name": branch, "isMerged": merged, "isQueued": False, + "needsRebase": False, "pr": {"number": pr_num, "state": state}}], + } + + +class SelectStacksTests(unittest.TestCase): + def test_default_keeps_open_stacks_on_any_trunk(self) -> None: + found = [ + found_stack("develop", "fix/a", 1), + found_stack("integrate/phase-bc", "fix/b", 2), + found_stack("main", "fix/c", 3), + ] + stacks, hidden = gsv.select_stacks(found, None, include_all=False) + self.assertEqual({s["trunk"] for s in stacks}, {"develop", "integrate/phase-bc", "main"}) + self.assertEqual(hidden, 0) + + def test_default_hides_fully_merged_stack_and_counts_it_hidden(self) -> None: + found = [ + found_stack("develop", "fix/a", 1), + found_stack("develop", "fix/merged", 2, merged=True), + ] + stacks, hidden = gsv.select_stacks(found, None, include_all=False) + self.assertEqual([s["branches"][0]["name"] for s in stacks], ["fix/a"]) + self.assertEqual(hidden, 1) + + def test_trunk_filter_keeps_only_matching_trunk(self) -> None: + found = [ + found_stack("develop", "fix/a", 1), + found_stack("integrate/phase-bc", "fix/b", 2), + found_stack("integrate/phase-bc", "fix/c", 3), + ] + stacks, hidden = gsv.select_stacks(found, "integrate/phase-bc", include_all=False) + self.assertEqual({s["trunk"] for s in stacks}, {"integrate/phase-bc"}) + self.assertEqual(len(stacks), 2) + self.assertEqual(hidden, 1) + + def test_include_all_keeps_merged_stacks(self) -> None: + found = [ + found_stack("develop", "fix/a", 1), + found_stack("develop", "fix/merged", 2, merged=True), + found_stack("main", "fix/closed", 3, closed=True), + ] + stacks, hidden = gsv.select_stacks(found, None, include_all=True) + self.assertEqual(len(stacks), 3) + self.assertEqual(hidden, 0) + + def test_include_all_with_trunk_filter_still_filters_trunk(self) -> None: + found = [ + found_stack("develop", "fix/a", 1, merged=True), + found_stack("main", "fix/b", 2, merged=True), + ] + stacks, hidden = gsv.select_stacks(found, "develop", include_all=True) + self.assertEqual([s["trunk"] for s in stacks], ["develop"]) + self.assertEqual(hidden, 1) + + +if __name__ == "__main__": + unittest.main() + + +class ErrorConditionTests(unittest.TestCase): + def _cp(self, rc: int, out: str = "", err: str = ""): + import subprocess + return subprocess.CompletedProcess(["x"], rc, stdout=out, stderr=err) + + def test_merge_tree_usage_error_is_not_judged_as_conflict(self) -> None: + rows = [{"branch": "top", "origin": C3, "merged": False}] + with mock.patch.object(gsv, "is_ancestor", return_value=True), \ + mock.patch.object(gsv, "run", return_value=self._cp(129, err="usage: git merge-tree")): + landing = gsv.landing_verdict({"trunk": TRUNK}, rows, T0) + self.assertIsNone(landing["landable"]) + self.assertIn("git < 2.38", landing["reason"]) + + def test_merge_tree_conflict_is_reported(self) -> None: + rows = [{"branch": "top", "origin": C3, "merged": False}] + with mock.patch.object(gsv, "is_ancestor", return_value=True), \ + mock.patch.object(gsv, "run", return_value=self._cp(1, out="CONFLICT (content): x.rs")): + landing = gsv.landing_verdict({"trunk": TRUNK}, rows, T0) + self.assertFalse(landing["landable"]) + self.assertIn("CONFLICT (content): x.rs", landing["reason"]) + + def test_stack_json_at_records_unreadable_worktrees(self) -> None: + gsv.SKIPPED.clear() + with mock.patch.object(gsv, "run", return_value=self._cp(6, err="branch belongs to multiple stacks")): + self.assertIsNone(gsv.stack_json_at("/wt/a")) + with mock.patch.object(gsv, "run", return_value=self._cp(2, err="not in a stack")): + self.assertIsNone(gsv.stack_json_at("/wt/b")) + with mock.patch.object(gsv, "run", return_value=self._cp(0, out="not json")): + self.assertIsNone(gsv.stack_json_at("/wt/c")) + joined = "\n".join(gsv.SKIPPED) + self.assertIn("/wt/a: gh stack view exit 6", joined) + self.assertIn("gh stack checkout <specific-branch>", joined) + self.assertNotIn("/wt/b", joined, "exit 2 (not in a stack) is normal and not reported") + self.assertIn("/wt/c: gh stack view --json returned non-JSON", joined) + gsv.SKIPPED.clear() + + def test_pr_details_partial_graphql_errors_warn_and_continue(self) -> None: + import io + repo = self._cp(0, out='{"owner": {"login": "o"}, "name": "r"}') + payload = {"data": {"repository": {"pr1": {"number": 1, "headRefOid": A1}, "pr2": None}}, + "errors": [{"message": "Could not resolve to a PullRequest with the number of 2."}]} + gql = self._cp(0, out=__import__("json").dumps(payload)) + with mock.patch.object(gsv, "run", side_effect=[repo, gql]), \ + mock.patch.object(gsv.sys, "stderr", new=io.StringIO()) as err: + prs = gsv.pr_details([1, 2]) + self.assertEqual(prs[1]["headRefOid"], A1) + self.assertEqual(prs[2], {}, "unresolved PR is falsy so rows render as unknown") + self.assertIn("warning: GraphQL reported", err.getvalue()) + + def test_pr_details_rejects_non_int_numbers(self) -> None: + repo = self._cp(0, out='{"owner": {"login": "o"}, "name": "r"}') + with mock.patch.object(gsv, "run", return_value=repo): + with self.assertRaises(gsv.ToolError): + gsv.pr_details([1, "2; mutation"]) # type: ignore[list-item] + + def test_guarded_never_tracebacks(self) -> None: + import io + with mock.patch.object(gsv.sys, "stderr", new=io.StringIO()) as err: + self.assertEqual(gsv.guarded("gh-stack-view", lambda: (_ for _ in ()).throw(KeyError("pr"))), 2) + self.assertTrue(err.getvalue().startswith("gh-stack-view: unexpected KeyError")) + + +class ReviewRegressionTests(unittest.TestCase): + def setUp(self) -> None: + gsv.SKIPPED.clear() + self.addCleanup(gsv.SKIPPED.clear) + + def test_unresolved_pr_row_is_unknown_not_stale(self) -> None: + stack = {"trunk": TRUNK, "currentBranch": "l1", "branches": [layer("l1", 1, head=A1, base=T0)]} + with mock.patch.object(gsv, "origin_sha", side_effect=lambda ref: {TRUNK: T0, "l1": A1}.get(ref)), \ + mock.patch.object(gsv, "is_ancestor", return_value=False): + rows, problems, notes = gsv.build_rows(stack, {1: {}}, fetched=True) + self.assertEqual(problems, []) + self.assertTrue(any("PR #1 could not be resolved" in n for n in notes)) + self.assertEqual(gsv.sync_icon(rows[0]), gsv.ICON_SYNC["unknown"]) + self.assertEqual(gsv.merge_icon(rows[0]), gsv.ICON_SYNC["unknown"]) + self.assertEqual(gsv.ci_icon(rows[0]), gsv.ICON_SYNC["unknown"]) + + def test_needs_rebase_on_behind_trunk_bottom_is_a_note(self) -> None: + stack = {"trunk": TRUNK, "currentBranch": "l1", "branches": [layer("l1", 1, head=A1, base=OLD, needs_rebase=True)]} + with mock.patch.object(gsv, "origin_sha", side_effect=lambda ref: {TRUNK: T0, "l1": A1}.get(ref)), \ + mock.patch.object(gsv, "is_ancestor", side_effect=lambda a, b: (a, b) == (OLD, T0)): + rows, problems, notes = gsv.build_rows(stack, {1: pr(A1, TRUNK)}, fetched=True) + self.assertEqual(problems, []) + self.assertTrue(any("needsRebase, but only against a trunk that moved" in n for n in notes)) + self.assertEqual(gsv.sync_icon(rows[0]), gsv.ICON_SYNC["behind"]) + + def test_skipped_worktree_makes_report_exit_1_and_landing_unjudged(self) -> None: + import io, subprocess + stack = {"trunk": TRUNK, "currentBranch": "l1", "worktree": "/wt/l1", + "branches": [layer("l1", 1, head=A1, base=T0)]} + gsv.SKIPPED.append("/wt/top: gh stack view exit 8: locked") + args = gsv.argparse.Namespace(trunk=None, phase=None, all=False, no_fetch=True, no_pr=True, json=True) + with mock.patch.object(gsv, "preflight"), \ + mock.patch.object(gsv, "discover_stacks", return_value=([stack], 0)), \ + mock.patch.object(gsv, "origin_sha", return_value=None), \ + mock.patch("builtins.print") as out, \ + mock.patch.object(gsv.sys, "stderr", new=io.StringIO()) as err: + rc = gsv.run_report(args, None) + self.assertEqual(rc, 1) + payload = __import__("json").loads(out.call_args[0][0]) + self.assertFalse(payload["coherent"]) + self.assertIn("discovery incomplete", payload["stacks"][0]["problems"][0]) + self.assertIsNone(payload["stacks"][0]["landing"]["landable"]) + self.assertIn("skipped worktree /wt/top", err.getvalue()) + + def test_fetch_timeout_degrades_to_unknown(self) -> None: + import io + stack = {"trunk": TRUNK, "currentBranch": "l1", "worktree": "/wt/l1", + "branches": [layer("l1", 1, head=A1, base=T0)]} + args = gsv.argparse.Namespace(trunk=None, phase=None, all=False, no_fetch=False, no_pr=True, json=False) + with mock.patch.object(gsv, "preflight"), \ + mock.patch.object(gsv, "discover_stacks", return_value=([stack], 0)), \ + mock.patch.object(gsv, "run", side_effect=gsv.ToolError("git fetch timed out after 60s")), \ + mock.patch("builtins.print"), \ + mock.patch.object(gsv.sys, "stderr", new=io.StringIO()) as err: + rc = gsv.run_report(args, None) + self.assertEqual(rc, 0) + self.assertIn("git fetch origin failed (git fetch timed out", err.getvalue()) diff --git a/pytest.ini b/pytest.ini index 6e56a3d75..47eb6ff43 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,7 +2,7 @@ markers = integration: requires external services or tokens -testpaths = tests packages/sc-docling-pdf/tests +testpaths = tests packages/sc-docling-pdf/tests packages/sc-gh-stack/tests python_files = test_*.py python_classes = Test* python_functions = test_* diff --git a/requirements-dev.txt b/requirements-dev.txt index 8b6390f20..86d98c772 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,6 +8,9 @@ pydantic>=2.7 # Data validation and type safety pyyaml>=6.0 # YAML parsing for validation jsonschema>=4.22 # Schema validation +# PDF handling (packages/sc-docling-pdf tests) +pypdf>=4 # PDF read/write for docling integration test fixtures + # Reporting tabulate>=0.9.0 # Table formatting for reports jinja2>=3.1.0 # HTML template rendering diff --git a/tests/test_ai_cli_task_runner.py b/tests/test_ai_cli_task_runner.py index ba6fd43f2..c4a3ed18e 100644 --- a/tests/test_ai_cli_task_runner.py +++ b/tests/test_ai_cli_task_runner.py @@ -208,7 +208,7 @@ def test_run_pretool_hooks_success(tmp_path: Path) -> None: " - matcher: \"Bash\"\n" " hooks:\n" " - type: command\n" - " command: \"python -c \\\"import json,sys; json.load(sys.stdin); sys.exit(0)\\\"\"\n" + " command: \"python3 -c \\\"import json,sys; json.load(sys.stdin); sys.exit(0)\\\"\"\n" "---\n", encoding="utf-8", ) @@ -229,7 +229,7 @@ def test_run_pretool_hooks_failure(tmp_path: Path) -> None: " - matcher: \"Bash\"\n" " hooks:\n" " - type: command\n" - " command: \"python -c \\\"import sys; sys.exit(2)\\\"\"\n" + " command: \"python3 -c \\\"import sys; sys.exit(2)\\\"\"\n" "---\n", encoding="utf-8", )