From 466f17f12bca1874466740309348afd87de2c81d Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Tue, 22 Sep 2026 22:56:19 -0700 Subject: [PATCH 1/2] feat(sc-git-worktree): stack-aware worktree creation and guards (0.14.0) New `sc-worktree-create-stacked` agent and `--create-stacked [--above ]`: cut a gh-stack layer from the parent's PUSHED head with --no-track, refuse bad cuts before any mutation (layer exists, parent not pushed / landed / off trunk, insert target not stacked on the parent), record parent_sha in tracking, and return a stack_handoff block (push, PR base, link or mid-stack insert sequence, writer rules) for the agent that works in the worktree. The plain create agent is untouched; the skill and command route stack layers to the new agent. Cleanup/abort refuse to delete a branch that live layers were cut from unless git shows a merge commit landing (STACK.HAS_CHILDREN, batch `stack_blocked`). Scan reports stack_parent_advanced / stack_parent_landed on layer rows. Fixes: remote-ref bases no longer become the new branch's upstream (--no-track); merge detection recognises the `+` marker for branches checked out in other worktrees. Co-Authored-By: Claude Fable 5.1 --- .claude-plugin/marketplace.json | 5 +- .claude-plugin/registry.json | 50 +-- docs/registries/nuget/registry.json | 9 +- .../.claude-plugin/plugin.json | 3 +- packages/sc-git-worktree/CHANGELOG.md | 13 + packages/sc-git-worktree/DESIGN.md | 14 + packages/sc-git-worktree/README.md | 5 +- .../agents/sc-git-worktree-abort.md | 3 +- .../agents/sc-git-worktree-cleanup.md | 4 +- .../agents/sc-git-worktree-create-stacked.md | 120 ++++++ .../agents/sc-git-worktree-create.md | 2 +- .../agents/sc-git-worktree-scan.md | 9 +- .../agents/sc-git-worktree-update.md | 2 +- .../commands/sc-git-worktree.md | 25 +- packages/sc-git-worktree/manifest.yaml | 5 +- packages/sc-git-worktree/scripts/envelope.py | 8 + .../sc-git-worktree/scripts/worktree_abort.py | 32 ++ .../scripts/worktree_cleanup.py | 59 +++ .../scripts/worktree_create.py | 322 ++++++++++++++- .../sc-git-worktree/scripts/worktree_scan.py | 47 +++ .../scripts/worktree_shared.py | 56 ++- .../skills/sc-git-worktree/SKILL.md | 18 +- .../references/stack-layers.md | 71 ++++ .../tests/test_git_operations.py | 10 + .../sc-git-worktree/tests/test_stack_aware.py | 389 ++++++++++++++++++ tests/test_sc_prefix_validation.py | 6 +- 26 files changed, 1222 insertions(+), 65 deletions(-) create mode 100644 packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md create mode 100644 packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md create mode 100644 packages/sc-git-worktree/tests/test_stack_aware.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 62dd2f2cf..fce16a1e5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -166,7 +166,7 @@ "name": "sc-git-worktree", "source": "./packages/sc-git-worktree", "description": "Manage git worktrees with optional tracking and protected branch safeguards: create, scan, cleanup, abort, and update worktrees using a standard sibling folder layout with tracking documents. Prevents accidental deletion of protected branches.\n", - "version": "0.13.0", + "version": "0.14.0", "author": { "name": "randlee" }, @@ -176,7 +176,8 @@ "worktree", "workflow", "agents", - "branching" + "branching", + "gh-stack" ], "category": "tools" }, diff --git a/.claude-plugin/registry.json b/.claude-plugin/registry.json index f518b5732..1a4cfa27b 100644 --- a/.claude-plugin/registry.json +++ b/.claude-plugin/registry.json @@ -21,7 +21,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.711653+00:00" + "lastUpdated": "2026-09-23T05:51:05.684219+00:00" }, { "name": "sc-ci-automation", @@ -38,7 +38,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.712662+00:00" + "lastUpdated": "2026-09-23T05:51:05.685441+00:00" }, { "name": "sc-codex", @@ -55,7 +55,7 @@ "scripts": 2, "schemas": 2 }, - "lastUpdated": "2026-09-23T05:29:06.713425+00:00" + "lastUpdated": "2026-09-23T05:51:05.686459+00:00" }, { "name": "sc-coding-agent-hardening", @@ -72,7 +72,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.713874+00:00" + "lastUpdated": "2026-09-23T05:51:05.686917+00:00" }, { "name": "sc-commit-push-pr", @@ -89,7 +89,7 @@ "scripts": 9, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.714491+00:00" + "lastUpdated": "2026-09-23T05:51:05.687775+00:00" }, { "name": "sc-delay-tasks", @@ -106,7 +106,7 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.715065+00:00" + "lastUpdated": "2026-09-23T05:51:05.688501+00:00" }, { "name": "sc-docling-pdf", @@ -123,7 +123,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.715902+00:00" + "lastUpdated": "2026-09-23T05:51:05.689432+00:00" }, { "name": "sc-gh-stack", @@ -140,11 +140,11 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.716818+00:00" + "lastUpdated": "2026-09-23T05:51:05.690494+00:00" }, { "name": "sc-git-worktree", - "version": "0.13.0", + "version": "0.14.0", "description": "Manage git worktrees with optional tracking and protected branch safeguards: create, scan, cleanup, abort, and update worktrees using a standard sibling folder layout with tracking documents. Prevents accidental deletion of protected branches.\n", "author": "randlee", "license": "MIT", @@ -153,11 +153,11 @@ "artifacts": { "commands": 1, "skills": 1, - "agents": 5, + "agents": 6, "scripts": 7, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.717743+00:00" + "lastUpdated": "2026-09-23T05:51:05.691470+00:00" }, { "name": "sc-github-issue", @@ -174,7 +174,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.718745+00:00" + "lastUpdated": "2026-09-23T05:51:05.692717+00:00" }, { "name": "sc-just", @@ -191,7 +191,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.719756+00:00" + "lastUpdated": "2026-09-23T05:51:05.693694+00:00" }, { "name": "sc-kanban", @@ -208,7 +208,7 @@ "scripts": 5, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.720422+00:00" + "lastUpdated": "2026-09-23T05:51:05.694707+00:00" }, { "name": "sc-launch-term", @@ -225,7 +225,7 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.721076+00:00" + "lastUpdated": "2026-09-23T05:51:05.695602+00:00" }, { "name": "sc-launchpad", @@ -242,7 +242,7 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.721743+00:00" + "lastUpdated": "2026-09-23T05:51:05.696388+00:00" }, { "name": "sc-manage", @@ -259,7 +259,7 @@ "scripts": 8, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.722385+00:00" + "lastUpdated": "2026-09-23T05:51:05.697350+00:00" }, { "name": "sc-refactory", @@ -276,7 +276,7 @@ "scripts": 10, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.723454+00:00" + "lastUpdated": "2026-09-23T05:51:05.698768+00:00" }, { "name": "sc-repomix-nuget", @@ -293,7 +293,7 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.724105+00:00" + "lastUpdated": "2026-09-23T05:51:05.699612+00:00" }, { "name": "sc-roslyn-diff", @@ -310,7 +310,7 @@ "scripts": 6, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.724765+00:00" + "lastUpdated": "2026-09-23T05:51:05.700490+00:00" }, { "name": "sc-rust", @@ -327,7 +327,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.725868+00:00" + "lastUpdated": "2026-09-23T05:51:05.701943+00:00" }, { "name": "sc-startup", @@ -344,17 +344,17 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:29:06.726546+00:00" + "lastUpdated": "2026-09-23T05:51:05.702888+00:00" } ], "metadata": { "totalPackages": 20, "totalCommands": 17, "totalSkills": 29, - "totalAgents": 51, + "totalAgents": 52, "totalScripts": 63, "totalSchemas": 2 }, - "generated": "2026-09-23T05:29:06.726555+00:00", - "lastUpdated": "2026-09-23T05:29:06.726556+00:00" + "generated": "2026-09-23T05:51:05.702899+00:00", + "lastUpdated": "2026-09-23T05:51:05.702900+00:00" } diff --git a/docs/registries/nuget/registry.json b/docs/registries/nuget/registry.json index f4af7ebed..f14252608 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-09-23T05:29:06Z", + "generated": "2026-09-23T05:51:05Z", "repo": "randlee/synaptic-canvas", "marketplace": { "name": "Synaptic Canvas", @@ -167,7 +167,7 @@ }, "sc-git-worktree": { "name": "sc-git-worktree", - "version": "0.13.0", + "version": "0.14.0", "status": "beta", "tier": 1, "description": "Manage git worktrees with optional tracking and protected branch safeguards: create, scan, cleanup, abort, and update worktrees using a standard sibling folder layout with tracking documents. Prevents accidental deletion of protected branches.\n", @@ -184,12 +184,13 @@ "worktree", "workflow", "agents", - "branching" + "branching", + "gh-stack" ], "artifacts": { "commands": 1, "skills": 1, - "agents": 5, + "agents": 6, "scripts": 7, "schemas": 0 }, diff --git a/packages/sc-git-worktree/.claude-plugin/plugin.json b/packages/sc-git-worktree/.claude-plugin/plugin.json index 20a2c72e8..fe938b013 100644 --- a/packages/sc-git-worktree/.claude-plugin/plugin.json +++ b/packages/sc-git-worktree/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-git-worktree", "description": "Create, manage, scan, and clean up git worktrees for parallel development. Maintains tracking of worktree status, branch details, and cleanup history with optional tracking document.", - "version": "0.13.0", + "version": "0.14.0", "author": { "name": "randlee" }, @@ -18,6 +18,7 @@ ], "agents": [ "./agents/sc-git-worktree-create.md", + "./agents/sc-git-worktree-create-stacked.md", "./agents/sc-git-worktree-scan.md", "./agents/sc-git-worktree-cleanup.md", "./agents/sc-git-worktree-abort.md", diff --git a/packages/sc-git-worktree/CHANGELOG.md b/packages/sc-git-worktree/CHANGELOG.md index d5216aa06..622f0d721 100644 --- a/packages/sc-git-worktree/CHANGELOG.md +++ b/packages/sc-git-worktree/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to the **sc-git-worktree** package will be documented in thi 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.14.0] - 2026-09-23 + +### Added +- `sc-worktree-create-stacked` agent and `--create-stacked [--above ]`: cut a gh-stack layer worktree from the parent's **pushed** head with `--no-track`, validate the cut before any mutation (`STACK.LAYER_EXISTS`, `STACK.PARENT_NOT_PUSHED`, `STACK.PARENT_LANDED`, `STACK.PARENT_OFF_TRUNK`, `STACK.ABOVE_INVALID`), record `parent_sha`, and return a `stack_handoff` block (push, PR base, link or mid-stack insert sequence, writer rules) for the agent that works in the worktree. +- Tracking entries carry an optional `stack` object (`trunk`, `parent`, `parent_sha`, `above`, `position`); older rows load unchanged. +- Cleanup and abort refuse to delete a branch that live stack layers were cut from unless git shows it landed in the trunk (`STACK.HAS_CHILDREN`; batch cleanup reports `stack_blocked`). +- Scan reports `stack_parent_advanced` and `stack_parent_landed` on layer rows, with recommendations. +- `references/stack-layers.md`: the worktree side of the stack model and the writer contract. + +### Fixed +- Plain create from a remote-only base no longer sets that base as the new branch's upstream (`--no-track`), so `git push` / `--force-with-lease` cannot target the base branch. +- Merge detection now recognises the `+` marker git prints for branches checked out in other worktrees; single-branch cleanup of a merged branch that still has a worktree no longer fails as "unmerged". + ## [0.10.0] - 2026-04-18 ### Added diff --git a/packages/sc-git-worktree/DESIGN.md b/packages/sc-git-worktree/DESIGN.md index 657b8d278..5d5187c51 100644 --- a/packages/sc-git-worktree/DESIGN.md +++ b/packages/sc-git-worktree/DESIGN.md @@ -112,6 +112,20 @@ Force-remove a worktree, discarding uncommitted changes: 3. Update JSONL entry (local_worktree=false) 4. Remote deletion handled by subsequent cleanup +## Stack Layers (gh-stack) + +A worktree may be a layer of a `gh stack`. Its tracking entry carries: + +```json +"stack": {"trunk": "develop", "parent": "sprint-6", "parent_sha": "", "above": null, "position": "top"} +``` + +- **Create (stacked)**: cut from `origin/` with `--no-track`, never from the local ref. Refuse before mutation when the parent is not pushed, is already landed in the trunk, shares no history with it, or (insert) `above` does not contain the parent. Return a `stack_handoff` (push, PR base, link or insert sequence, writer rules). +- **Cleanup / abort**: a branch with live children (`stack.parent == branch`, child has worktree or remote) is deleted only when git shows it merged into the trunk. Otherwise `STACK.HAS_CHILDREN` / `stack_blocked`. +- **Scan**: reports `stack_parent_advanced` and `stack_parent_landed` per layer. + +The stack model and gh-stack recipes live in the `sc-gh-stack` package; this package never runs `gh`. + ## Safety Guards ### Protected Branches diff --git a/packages/sc-git-worktree/README.md b/packages/sc-git-worktree/README.md index 106b30fdc..440e9efea 100644 --- a/packages/sc-git-worktree/README.md +++ b/packages/sc-git-worktree/README.md @@ -32,6 +32,7 @@ Create, scan, clean up, and abort worktrees using predictable paths and safe def ## Usage - `/sc-git-worktree --list` or `--status` - `/sc-git-worktree --create ` +- `/sc-git-worktree --create-stacked [--above ]` (gh-stack layer: cut from the parent's pushed head, returns a `stack_handoff` for the writer) - `/sc-git-worktree --cleanup ` - `/sc-git-worktree --abort ` @@ -44,6 +45,7 @@ Safety - Never delete remote branches that are ahead of local (unpulled commits) - Never delete protected branches (main, master, develop) - Never modify dirty worktrees without explicit approval +- Never delete a branch that live stack layers were cut from unless git shows it landed (`STACK.HAS_CHILDREN`) Shared Settings - Protected branches are read from `.sc/shared-settings.yaml` (`git.protected_branches`) @@ -74,7 +76,8 @@ See [DESIGN.md](DESIGN.md) for detailed requirements including: ## Components - Command: `commands/sc-git-worktree.md` - Skill: `skills/sc-managing-worktrees/SKILL.md` -- Agents: `sc-worktree-create`, `sc-worktree-scan`, `sc-worktree-cleanup`, `sc-worktree-abort` +- Agents: `sc-worktree-create`, `sc-worktree-create-stacked`, `sc-worktree-scan`, `sc-worktree-cleanup`, `sc-worktree-abort`, `sc-worktree-update` +- Stack layers: `skills/sc-git-worktree/references/stack-layers.md` (pairs with the `sc-gh-stack` package) ## Version & Changelog - 0.4.0 — Initial v0.x publication diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-abort.md b/packages/sc-git-worktree/agents/sc-git-worktree-abort.md index 3a3dcbb3d..8d6e3a217 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-abort.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-abort.md @@ -1,6 +1,6 @@ --- name: sc-worktree-abort -version: 0.13.0 +version: 0.14.0 description: Abandon a worktree and discard work with protected branch safeguards. Remove worktree; for non-protected branches, delete branch (local/remote) only with explicit approval; for protected branches, never delete branch. Update tracking when enabled. model: haiku color: red @@ -34,6 +34,7 @@ Abandon a worktree and discard work safely. - **Protected branches:** Remote branch must never be deleted. Remove worktree; local branch may be removed only if explicitly approved for abort. Default is preserve. - If dirty and no approval, stop and report. - For **non-protected branches**: Only delete branches (local/remote) with explicit approval. If remote delete fails because it doesn't exist, note and continue. +- **Stack parents**: with `allow_delete_branch`, a branch that live stack layers were cut from is refused with `STACK.HAS_CHILDREN` before anything is removed. Abort the children first, or abort without `allow_delete_branch` to remove only the worktree. - Always update tracking when enabled. ## Execution diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md b/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md index 7d6717821..1efa5267e 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md @@ -1,6 +1,6 @@ --- name: sc-worktree-cleanup -version: 0.13.0 +version: 0.14.0 description: Clean up a completed/merged worktree with protected branch safeguards. Remove worktree; for non-protected branches, delete branch (local+remote) by default if merged/no unique commits; for protected branches, preserve branch. Update tracking when enabled. Stop on dirty/unmerged without approval. model: haiku color: orange @@ -59,6 +59,7 @@ Wrap the script output in `` tags with a fenced JSON block. Do not | `WORKTREE.NOT_FOUND` | Worktree path doesn't exist | No | | `WORKTREE.DIRTY` | Uncommitted changes (single branch mode) | Yes | | `WORKTREE.UNMERGED` | Branch has unmerged commits | Yes | +| `STACK.HAS_CHILDREN` | Branch is the stack parent of live layer(s) and git does not show it landed in the trunk (single branch mode) | Yes | | `GIT.ERROR` | Git command failed | No | ## Rules @@ -67,6 +68,7 @@ Wrap the script output in `` tags with a fenced JSON block. Do not - **Merged + clean**: Auto-cleaned in batch mode - **Dirty**: Reported back, requires explicit `require_clean: false` to force - **Unmerged**: Never auto-deleted. User must merge first or use `--abort` to discard. +- **Stack parents**: A branch that live stack layers were cut from (tracking `stack.parent`) is deleted only when git shows it merged into the trunk. Batch mode lists such branches under `stack_blocked`; single mode with `merged: true` refuses with `STACK.HAS_CHILDREN`. Land the stack (`gh stack merge`) or clean the children first. ## Constraints diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md b/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md new file mode 100644 index 000000000..07ced327b --- /dev/null +++ b/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md @@ -0,0 +1,120 @@ +--- +name: sc-worktree-create-stacked +version: 0.14.0 +description: Create a worktree for a new gh-stack layer cut from its parent's PUSHED head (never a local ref, never tracking the parent). Validates the cut (parent pushed, on the trunk, not landed; insert target really stacked on the parent), records the parent SHA in tracking, and returns a stack_handoff block the layer's writer needs to push, open the PR with the right base, and link or insert the layer. +model: haiku +color: green +--- + +# Worktree Create Agent (stack layer) + +## Invocation + +This agent is invoked via the Claude Task tool by the `/sc-git-worktree` skill for `--create-stacked`. Do not invoke directly. Ordinary (non-stack) worktrees use `sc-worktree-create`. + +## Input Protocol + +Read inputs from `` (JSON object). If omitted, treat as `{}`. + +## Purpose + +Cut a new stack layer as a worktree by calling `worktree_create.py` with a `stack` block. The script enforces the gh-stack field rules so the layer can be linked (top) or inserted (mid-stack) without rework: + +- The layer is a **new** branch cut from `origin/` with `--no-track`; the local `` ref is ignored because it may be stale or another writer's unpushed state. +- The parent must be pushed, must share history with the trunk, and must not already be landed in the trunk. +- When inserting, `above` must be a pushed layer that contains the parent. +- The parent SHA at cut time is recorded in the tracking row (`stack.parent_sha`) and in the handoff; it is the `` for the writer's one rebase at task start. + +## Inputs + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `branch` | string | Yes | New layer name (must not exist locally or on origin) | +| `base` | string | Yes | Parent layer: the current stack top from `/sc-gh-stack-view`, or the trunk for the bottom layer, or a mid-stack layer when inserting | +| `stack.trunk` | string | Yes | Branch the stack's bottom PR targets (for example `develop`) | +| `stack.above` | string | No | Layer currently stacked directly on `base`; set **only** when inserting mid-stack | +| `purpose` | string | Yes | Task or sprint id; goes into the PR body placeholder | +| `owner` | string | Yes | The layer's single writer (agent or user handle) | +| `repo_root`, `worktree_base`, `tracking_enabled`, `tracking_path` | | No | As for `sc-worktree-create` | + +## Execution + +Run the create script once with the input JSON: + +```bash +python3 .claude/scripts/worktree_create.py '' +``` + +Example input (append on top of the stack): + +```json +{"branch": "sprint-7", "base": "sprint-6", "purpose": "sprint 7", "owner": "writer-b", + "stack": {"trunk": "develop"}} +``` + +Example input (insert under `sprint-6`): + +```json +{"branch": "hotfix-under-6", "base": "sprint-5", "purpose": "fix flaky test", "owner": "writer-c", + "stack": {"trunk": "develop", "above": "sprint-6"}} +``` + +## Output + +The script returns fenced JSON. Forward it verbatim. On success `data` carries two stack fields in addition to the ordinary create fields: + +- `stack`: `{trunk, parent, parent_sha, above, position}` (`position` is `top` or `insert`), also stored in the tracking row. +- `stack_handoff`: `pr_base`, `pr_body`, `rules[]`, `commands[]` (ordered, with `` the writer fills from `gh pr create` and `/sc-gh-stack-view`), `chain_check_available`, `reference`. + +The caller pastes `stack_handoff` verbatim into the prompt of whichever agent works in the worktree. It is the writer's contract: first push with `-u`, PR base = parent (never the trunk), stack on the first push, one rebase at task start at most, no edits to lower layers. + +**Success example (top):** +```json +{ + "success": true, + "data": { + "action": "create", + "branch": "sprint-7", + "base": "sprint-6", + "path": "/path/to/worktrees/sprint-7", + "branch_created": true, + "tracking_updated": true, + "stack": {"trunk": "develop", "parent": "sprint-6", "parent_sha": "3f9c...e1", "above": null, "position": "top"}, + "stack_handoff": { + "pr_base": "sprint-6", + "rules": ["You are the only writer of sprint-7. Never edit, rebase, or force-push any layer below it.", "..."], + "commands": [ + "git -C /path/to/worktrees/sprint-7 push -u origin sprint-7", + "gh pr create --base sprint-6 --head sprint-7 --title \"\" --body \"Parent: sprint-6 @ 3f9c...e1\\nTask: sprint 7\\nFence: <paths this layer may touch>\"", + "gh stack link <stack#> <pr# of sprint-7> # append to an existing stack", + "gh stack link --base develop <bottom-pr#> ... <pr# of sprint-6> <pr# of sprint-7> # first link, or full relink", + "/sc-gh-stack-view" + ] + } + } +} +``` + +## Output Protocol + +Wrap the script output in `<output_json>` tags with a fenced JSON block. Do not add prose outside the tags. + +## Error Codes + +All stack refusals happen before anything is created; the message says what is wrong and `suggested_action` says what to do. + +| Code | Meaning | Recoverable | +|------|---------|-------------| +| `STACK.LAYER_EXISTS` | `branch` already exists; a layer is always new (use `sc-worktree-create` to open an existing branch) | Yes | +| `STACK.PARENT_NOT_PUSHED` | `origin/<base>` missing; push the parent first | Yes | +| `STACK.PARENT_LANDED` | Parent already contained in the trunk; cut from the trunk or the current top | Yes | +| `STACK.PARENT_OFF_TRUNK` | Parent and trunk share no history; wrong `stack.trunk` | Yes | +| `STACK.ABOVE_INVALID` | `above` is not pushed, does not contain the parent, or names the parent/new layer | Yes | +| `BRANCH.NOT_FOUND` | Trunk not on origin | No | +| `WORKTREE.EXISTS`, `WORKTREE.BRANCH_IN_USE`, `WORKTREE.DIRTY`, `GIT.NOT_REPO`, `GIT.ERROR` | As for `sc-worktree-create` | No | + +## Constraints + +- Run the script ONCE - it handles everything +- Do NOT run manual git commands; use the script only +- Do NOT push, open the PR, or run `gh stack` here: that is the writer's job, in the worktree, following `stack_handoff` diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-create.md b/packages/sc-git-worktree/agents/sc-git-worktree-create.md index 3957806e3..ec8e5db4e 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-create.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-create.md @@ -1,6 +1,6 @@ --- name: sc-worktree-create -version: 0.13.0 +version: 0.14.0 description: Create a git worktree (and branch if needed) using the mandated layout and update tracking. Use for new feature/hotfix/release worktrees; obey branch protections and dirty-worktree safeguards. model: haiku color: green diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-scan.md b/packages/sc-git-worktree/agents/sc-git-worktree-scan.md index 04157ae9d..cbf844530 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-scan.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-scan.md @@ -1,6 +1,6 @@ --- name: sc-worktree-scan -version: 0.13.0 +version: 0.14.0 description: Scan git worktrees vs tracking; report status (clean/dirty), missing/stale tracking rows, and recommended actions. No mutations. model: haiku color: cyan @@ -99,6 +99,13 @@ On error (e.g., tracking file missing): Wrap the script output in `<output_json>` tags with a fenced JSON block. Do not add prose outside the tags. +## Stack layers + +Rows for gh-stack layers carry `tracking_entry.stack` (`trunk`, `parent`, `parent_sha`, `above`, `position`) and may list: + +- `stack_parent_advanced: origin/<parent> <old> -> <new>`: the layer's writer rebases once at task start (`git rebase --onto origin/<parent> <parent_sha> <layer>`), only while the layer has no children. +- `stack_parent_landed: ...`: the parent merged into the trunk or its branch is gone; the PR should now target the trunk (`/sc-gh-stack-view`). + ## Constraints - Do NOT modify anything; read-only scan diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-update.md b/packages/sc-git-worktree/agents/sc-git-worktree-update.md index 1c27f6e3d..bef1ac5bb 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-update.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-update.md @@ -1,6 +1,6 @@ --- name: sc-worktree-update -version: 0.13.0 +version: 0.14.0 description: Update a protected branch in its worktree by pulling latest changes. Handle merge conflicts by returning control to main agent for user coordination. model: haiku color: blue diff --git a/packages/sc-git-worktree/commands/sc-git-worktree.md b/packages/sc-git-worktree/commands/sc-git-worktree.md index 304396906..c2d62e2bc 100644 --- a/packages/sc-git-worktree/commands/sc-git-worktree.md +++ b/packages/sc-git-worktree/commands/sc-git-worktree.md @@ -1,7 +1,7 @@ --- name: sc-git-worktree description: Manage git worktrees for this repo (create, list/status, update, cleanup, abort) while enforcing the repo's worktree/tracking rules and protected branch safeguards. -version: 0.13.0 +version: 0.14.0 options: - name: --list description: List worktrees and show status/notes. @@ -14,6 +14,20 @@ options: - name: base description: Base branch to start from (e.g., master, develop, release/x.y, hotfix/...). description: Create a worktree (and branch if needed) using the mandated layout and update tracking. + - name: --create-stacked + args: + - name: branch + description: New gh-stack layer name (must not exist yet). + - name: parent + description: Layer to cut from - the current stack top from /sc-gh-stack-view, or the trunk for the bottom layer, or a mid-stack layer when inserting. + - name: trunk + description: Branch the stack's bottom PR targets (e.g., develop, integrate/phase-x). + description: Cut a new stack layer worktree from the parent's PUSHED head (--no-track), validate the cut, record the parent SHA, and return a stack_handoff for the layer's writer. Add `--above <layer>` when inserting under an existing layer. + - name: --above + args: + - name: layer + description: With --create-stacked only - the layer currently stacked directly on <parent>, when inserting mid-stack. + description: Modifier for --create-stacked; marks the cut as a mid-stack insert and adds the insert steps to the handoff. - name: --update args: - name: branch @@ -62,7 +76,7 @@ git: - If not configured, protected branches are auto-detected from git-flow and cached to `.sc/shared-settings.yaml` - **Required**: Operations fail if protected branches cannot be determined -If run with no options or `--help`: print a concise list of options (no git status) and prompt with a numbered choice for list/status, create, cleanup, or abort; then gather required inputs. +If run with no options or `--help`: print a concise list of options (no git status) and prompt with a numbered choice for list/status, create, create-stacked, cleanup, or abort; then gather required inputs. ## Behavior @@ -93,6 +107,13 @@ MUST invoke `sc-worktree-scan` and render its `<output_json>` summary and recomm ### --create MUST invoke `sc-worktree-create` with `branch`, `base`, `purpose`, `owner`, and optional tracking inputs. Render the `<output_json>` summary. +### --create-stacked +MUST invoke `sc-worktree-create-stacked` with `branch`, `base` (= `<parent>`), `stack: {"trunk": "<trunk>", "above": "<layer>" | null}`, `purpose`, `owner`, and optional tracking inputs. Never route a stack layer to `sc-worktree-create`: it branches from the local ref. + +Before invoking, when `sc-gh-stack` is installed, run `/sc-gh-stack-view` so `<parent>` is the pushed top (or, for an insert, so `--above` names the real layer above the parent). Without it, the script still verifies the cut from git. + +Render the `<output_json>` summary **and the full `stack_handoff` block verbatim**. Hand that block, unchanged, to whichever agent will work in the worktree; it contains the push, PR-base, link/insert commands and the writer rules. See `skills/sc-git-worktree/references/stack-layers.md`. + ### --update MUST invoke `sc-worktree-update` for protected branches only. Render conflicts or success from `<output_json>`. diff --git a/packages/sc-git-worktree/manifest.yaml b/packages/sc-git-worktree/manifest.yaml index 9c24357ab..f0fca621d 100644 --- a/packages/sc-git-worktree/manifest.yaml +++ b/packages/sc-git-worktree/manifest.yaml @@ -1,5 +1,5 @@ name: sc-git-worktree -version: 0.13.0 +version: 0.14.0 description: > Manage git worktrees with optional tracking and protected branch safeguards: create, scan, cleanup, abort, and update worktrees using a standard sibling folder layout with tracking documents. Prevents accidental deletion of protected branches. @@ -11,6 +11,7 @@ tags: - workflow - agents - branching + - gh-stack # Files to install (relative to package root) artifacts: @@ -18,8 +19,10 @@ artifacts: - commands/sc-git-worktree.md skills: - skills/sc-git-worktree/SKILL.md + - skills/sc-git-worktree/references/stack-layers.md agents: - agents/sc-git-worktree-create.md + - agents/sc-git-worktree-create-stacked.md - agents/sc-git-worktree-scan.md - agents/sc-git-worktree-cleanup.md - agents/sc-git-worktree-abort.md diff --git a/packages/sc-git-worktree/scripts/envelope.py b/packages/sc-git-worktree/scripts/envelope.py index 5840acfb8..f9f9ed704 100644 --- a/packages/sc-git-worktree/scripts/envelope.py +++ b/packages/sc-git-worktree/scripts/envelope.py @@ -246,3 +246,11 @@ class ErrorCodes: # Input errors INPUT_INVALID = "INPUT.INVALID" INPUT_MISSING = "INPUT.MISSING" + + # Stack layers (gh-stack): a layer is a branch cut from a pushed parent head + STACK_LAYER_EXISTS = "STACK.LAYER_EXISTS" + STACK_PARENT_NOT_PUSHED = "STACK.PARENT_NOT_PUSHED" + STACK_PARENT_LANDED = "STACK.PARENT_LANDED" + STACK_PARENT_OFF_TRUNK = "STACK.PARENT_OFF_TRUNK" + STACK_ABOVE_INVALID = "STACK.ABOVE_INVALID" + STACK_HAS_CHILDREN = "STACK.HAS_CHILDREN" diff --git a/packages/sc-git-worktree/scripts/worktree_abort.py b/packages/sc-git-worktree/scripts/worktree_abort.py index 1fddc347a..1291595ea 100755 --- a/packages/sc-git-worktree/scripts/worktree_abort.py +++ b/packages/sc-git-worktree/scripts/worktree_abort.py @@ -28,6 +28,8 @@ check_remote_branch_exists, delete_local_branch, delete_remote_branch, + find_stack_children, + load_tracking_jsonl, get_default_tracking_path, get_protected_branches, get_repo_root, @@ -42,6 +44,8 @@ check_remote_branch_exists, delete_local_branch, delete_remote_branch, + find_stack_children, + load_tracking_jsonl, get_default_tracking_path, get_protected_branches, get_repo_root, @@ -182,6 +186,34 @@ def abort_worktree_main(input_data: AbortInput) -> Envelope: transcript=transcript, ) + # Stack guard: never delete a layer that live layers were cut from + if input_data.allow_delete_branch and not is_protected and input_data.tracking_enabled: + guard_path = ( + Path(input_data.tracking_path).resolve() + if input_data.tracking_path + else get_default_tracking_path(worktree_base) + ) + stack_children = find_stack_children(load_tracking_jsonl(guard_path), input_data.branch) + if stack_children: + transcript.step_failed( + step="stack guard", + error=f"live stack children: {', '.join(stack_children)}", + ) + return Envelope.error_response( + code=ErrorCodes.STACK_HAS_CHILDREN, + message=( + f"Branch '{input_data.branch}' is the stack parent of live layer(s) " + f"{', '.join(stack_children)}; deleting it breaks their PR base" + ), + recoverable=True, + suggested_action=( + "Abort or clean up the child layers first, or re-run with allow_delete_branch: false " + "to remove only the worktree and keep the branch" + ), + data={"stack_children": stack_children}, + transcript=transcript, + ) + # Remove worktree force_flag = " --force" if input_data.allow_force else "" with transcript.timed_step(f"git worktree remove{force_flag} {worktree_path}") as t: diff --git a/packages/sc-git-worktree/scripts/worktree_cleanup.py b/packages/sc-git-worktree/scripts/worktree_cleanup.py index db5fa989c..e9476a5c4 100755 --- a/packages/sc-git-worktree/scripts/worktree_cleanup.py +++ b/packages/sc-git-worktree/scripts/worktree_cleanup.py @@ -30,6 +30,8 @@ count_unique_commits, delete_local_branch, delete_remote_branch, + find_stack_children, + is_landed_by_merge, get_default_tracking_path, get_protected_branches, get_remote_ahead_count, @@ -54,6 +56,8 @@ count_unique_commits, delete_local_branch, delete_remote_branch, + find_stack_children, + is_landed_by_merge, get_default_tracking_path, get_protected_branches, get_remote_ahead_count, @@ -322,6 +326,7 @@ def cleanup_all_merged(input_data: CleanupInput) -> Envelope: cleaned = [] dirty = [] unmerged = [] + stack_blocked: List[Dict[str, Any]] = [] protected_skipped = [] orphaned_remotes = [] @@ -416,6 +421,24 @@ def cleanup_all_merged(input_data: CleanupInput) -> Envelope: ) continue + # Stack guard: a layer with live children is deleted only when git shows it + # landed with a merge commit (GitHub then retargets the children onto the + # trunk). An empty layer parked on a trunk commit is not landed. + if not is_landed_by_merge(branch, merge_base, cwd=repo_root): + stack_children = find_stack_children(tracking_entries, branch) + if stack_children: + stack_blocked.append({ + "branch": branch, + "path": str(wt_path), + "stack_children": stack_children, + "reason": "stack parent with live child layers and no verifiable landing", + }) + transcript.step_ok( + step="stack guard", + message=f"{branch}: live children {', '.join(stack_children)} - preserved", + ) + continue + # Clean + merged → auto-cleanup transcript.step_ok( step=f"git -C {wt_path} status --porcelain", @@ -510,6 +533,7 @@ def cleanup_all_merged(input_data: CleanupInput) -> Envelope: "cleaned": cleaned, "dirty": dirty, "unmerged": unmerged, + "stack_blocked": stack_blocked if stack_blocked else None, "orphaned_remotes": orphaned_remotes if orphaned_remotes else None, "protected_skipped": protected_skipped if protected_skipped else None, "removed_directories": removed_dirs if removed_dirs else None, @@ -517,6 +541,7 @@ def cleanup_all_merged(input_data: CleanupInput) -> Envelope: "cleaned": len(cleaned), "dirty": len(dirty), "unmerged": len(unmerged), + "stack_blocked": len(stack_blocked), "orphaned_remotes": len(orphaned_remotes), "protected_skipped": len(protected_skipped), "empty_dirs_removed": len(removed_dirs), @@ -707,6 +732,40 @@ def cleanup_single_branch(input_data: CleanupInput) -> Envelope: transcript=transcript, ) + # Stack guard: the branch is about to be deleted unless protected. A merge commit + # visible in git is safe (GitHub retargets child PRs onto the trunk); a caller + # override (`merged: true`) or an empty layer is not. + if ( + not is_protected + and input_data.tracking_enabled + and not is_landed_by_merge(input_data.branch, merge_base, cwd=repo_root) + ): + guard_path = ( + Path(input_data.tracking_path).resolve() + if input_data.tracking_path + else get_default_tracking_path(worktree_base) + ) + stack_children = find_stack_children(load_tracking_jsonl(guard_path), input_data.branch) + if stack_children: + transcript.step_failed( + step="stack guard", + error=f"live stack children: {', '.join(stack_children)}", + ) + return Envelope.error_response( + code=ErrorCodes.STACK_HAS_CHILDREN, + message=( + f"Branch '{input_data.branch}' is the stack parent of live layer(s) " + f"{', '.join(stack_children)} and is not verifiably landed; deleting it breaks their PR base" + ), + recoverable=True, + suggested_action=( + "Land the stack first (gh stack merge) so the merge is visible in git, " + "or clean up / abort the child layers first" + ), + data={"stack_children": stack_children}, + transcript=transcript, + ) + # Remove worktree force = not input_data.require_clean force_flag = " --force" if force else "" diff --git a/packages/sc-git-worktree/scripts/worktree_create.py b/packages/sc-git-worktree/scripts/worktree_create.py index fe9b7ee03..d0612995b 100755 --- a/packages/sc-git-worktree/scripts/worktree_create.py +++ b/packages/sc-git-worktree/scripts/worktree_create.py @@ -17,9 +17,17 @@ "repo_root": "/path/to/repo", # optional, defaults to cwd "tracking_enabled": true, # optional, defaults to true "worktree_base": null, # optional, derived from repo name - "tracking_path": null # optional, derived from worktree_base + "tracking_path": null, # optional, derived from worktree_base + "stack": { # optional: cut a gh-stack layer from a PUSHED parent + "trunk": "develop", # the branch the stack's bottom PR targets + "above": null # layer currently stacked on `base` (mid-stack insert only) + } } +Stack mode (`stack` present) cuts `branch` from `origin/<base>` with --no-track, +records the parent SHA, and returns a `stack_handoff` block for the worktree's +writer. Plain mode branches from the local base ref when one exists. + Exit Codes: 0: Worktree created successfully 1: Error during creation @@ -48,6 +56,8 @@ get_default_tracking_path, get_repo_root, get_worktree_status, + is_ancestor, + rev_parse, run_git, ) except ImportError: @@ -62,6 +72,8 @@ get_default_tracking_path, get_repo_root, get_worktree_status, + is_ancestor, + rev_parse, run_git, ) @@ -71,6 +83,31 @@ # ============================================================================= +class StackInput(BaseModel): + """Stack-layer inputs: the worktree is a gh-stack layer cut from a pushed parent (`base`).""" + + trunk: str = Field(..., description="Stack trunk: the branch the bottom PR of the stack targets") + above: Optional[str] = Field( + None, + description="Layer currently stacked directly on `base`; set only when inserting mid-stack", + ) + + @field_validator("trunk") + @classmethod + def validate_trunk(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("stack.trunk cannot be empty") + return v.strip() + + @field_validator("above") + @classmethod + def validate_above(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + v = v.strip() + return v or None + + class CreateInput(BaseModel): """Input schema for worktree creation.""" @@ -82,6 +119,7 @@ class CreateInput(BaseModel): tracking_enabled: bool = Field(True, description="Whether to update tracking doc") worktree_base: Optional[str] = Field(None, description="Base directory for worktrees") tracking_path: Optional[str] = Field(None, description="Path to tracking document") + stack: Optional[StackInput] = Field(None, description="Present when the worktree is a gh-stack layer") @field_validator("branch") @classmethod @@ -107,6 +145,220 @@ def validate_base(cls, v: str) -> str: +# ============================================================================= +# Stack layers (gh-stack) +# ============================================================================= + + +def check_stack_preconditions( + input_data: CreateInput, + repo_root: Path, + transcript: Transcript, + *, + branch_exists_local: bool, + branch_exists_remote: bool, +) -> tuple: + """Validate a stack cut. Returns (stack_info, None) or (None, error Envelope). + + Rules (from the gh-stack field playbook): a layer is always a new branch cut + from its parent's PUSHED head; the parent sits on the trunk and is not already + landed; when inserting, the layer above must actually be stacked on the parent. + """ + stack = input_data.stack + base = input_data.base + trunk = stack.trunk + above = stack.above + + def fail(code: str, message: str, action: str, **data: Any) -> tuple: + transcript.step_failed(step="stack preconditions", error=message) + return None, Envelope.error_response( + code=code, + message=message, + recoverable=True, + suggested_action=action, + data=data or None, + transcript=transcript, + ) + + if branch_exists_local or branch_exists_remote: + return fail( + ErrorCodes.STACK_LAYER_EXISTS, + f"Branch '{input_data.branch}' already exists; a stack layer is always a new branch cut from its parent's pushed head", + "Choose a new layer name, or omit `stack` to open a worktree on the existing branch", + ) + + parent_sha = rev_parse(f"origin/{base}", cwd=repo_root) + if not parent_sha: + return fail( + ErrorCodes.STACK_PARENT_NOT_PUSHED, + f"Stack parent '{base}' is not on origin; layers are cut from pushed heads only", + f"Push the parent first (git push -u origin {base}) and retry", + ) + + trunk_sha = rev_parse(f"origin/{trunk}", cwd=repo_root) + if not trunk_sha: + return fail( + ErrorCodes.BRANCH_NOT_FOUND, + f"Stack trunk '{trunk}' not found on origin", + "Pass the branch the stack's bottom PR targets (for example develop) as stack.trunk", + ) + + if above is not None and above in (base, input_data.branch): + return fail( + ErrorCodes.STACK_ABOVE_INVALID, + f"stack.above '{above}' must name the layer currently stacked on '{base}', not the parent or the new layer", + "Read the layer above the parent from /sc-gh-stack-view and pass that name", + ) + + parent_behind_trunk = 0 + if base != trunk: + if is_ancestor(f"origin/{base}", f"origin/{trunk}", cwd=repo_root): + return fail( + ErrorCodes.STACK_PARENT_LANDED, + f"Stack parent '{base}' is already contained in trunk '{trunk}'; cutting from it would start the layer behind the trunk", + f"Cut from '{trunk}' (bottom layer) or from the current top shown by /sc-gh-stack-view", + ) + mb = run_git(["merge-base", f"origin/{trunk}", f"origin/{base}"], cwd=repo_root, check=False) + if mb.returncode != 0: + return fail( + ErrorCodes.STACK_PARENT_OFF_TRUNK, + f"origin/{base} and origin/{trunk} share no history", + "Check stack.trunk; the parent must be a layer stacked on that trunk", + ) + if not is_ancestor(f"origin/{trunk}", f"origin/{base}", cwd=repo_root): + behind = run_git(["rev-list", "--count", f"origin/{base}..origin/{trunk}"], cwd=repo_root, check=False) + parent_behind_trunk = int(behind.stdout.strip() or 0) if behind.returncode == 0 else -1 + transcript.step_ok( + step=f"git merge-base --is-ancestor origin/{trunk} origin/{base}", + message=f"parent behind trunk by {parent_behind_trunk} commit(s); allowed (stack bottom note, not a blocker)", + ) + + if above is not None: + above_sha = rev_parse(f"origin/{above}", cwd=repo_root) + if not above_sha: + return fail( + ErrorCodes.STACK_ABOVE_INVALID, + f"stack.above '{above}' is not on origin", + "Pass the pushed layer that is stacked on the parent, or omit `above` when cutting from the top", + ) + if not is_ancestor(f"origin/{base}", f"origin/{above}", cwd=repo_root): + return fail( + ErrorCodes.STACK_ABOVE_INVALID, + f"origin/{above} does not contain origin/{base}, so it is not stacked on '{base}'", + "Read the real layer above the parent from /sc-gh-stack-view, or omit `above`", + ) + + local_base_sha = rev_parse(base, cwd=repo_root) + if local_base_sha and local_base_sha != parent_sha: + transcript.step_ok( + step=f"git rev-parse {base}", + message=f"local {base} ({local_base_sha[:8]}) differs from origin ({parent_sha[:8]}); cutting from origin", + ) + + transcript.step_ok( + step=f"git rev-parse origin/{base}", + message=f"parent_sha={parent_sha}", + ) + + stack_info = { + "trunk": trunk, + "parent": base, + "parent_sha": parent_sha, + "above": above, + "position": "insert" if above else "top", + "parent_behind_trunk": parent_behind_trunk, + } + return stack_info, None + + +def build_stack_handoff( + branch: str, + purpose: str, + worktree_path: Path, + stack_info: Dict[str, Any], + repo_root: Path, +) -> Dict[str, Any]: + """The information a worker needs to stack this layer successfully. + + Everything here is derived from the gh-stack field playbook (sc-gh-stack). + Placeholders in angle brackets are values the worker reads from + /sc-gh-stack-view or from `gh pr create` output. + """ + trunk = stack_info["trunk"] + parent = stack_info["parent"] + sha = stack_info["parent_sha"] + above = stack_info.get("above") + chain_check = repo_root / ".claude" / "scripts" / "gh_stack_chain_check.py" + chain_check_available = chain_check.exists() + # Layers below the parent, as PR-number and branch-name placeholders. A layer cut + # from the trunk is the stack bottom: nothing sits below it. + lower_prs = "" if parent == trunk else f"<bottom-pr#> ... <pr# of {parent}> " + lower_names = "" if parent == trunk else f"<bottom> ... {parent} " + + pr_body = f"Parent: {parent} @ {sha}\nTask: {purpose}\nFence: <paths this layer may touch>" + + rules = [ + f"You are the only writer of {branch}. Never edit, rebase, or force-push any layer below it.", + f"Push a WIP commit within minutes, then open the PR with base {parent} (never {trunk}).", + f"Stack on the first push, never 'once it is green'. Pass PR numbers to gh stack link, not branch names.", + ( + f"Rebase at most once per task, at the start, only while this layer has no children: " + f"git fetch origin && git rebase --onto origin/{parent} {sha} {branch} && git push --force-with-lease; " + f"then record the new parent SHA." + ), + f"The next layer is cut from origin/{branch} after it is pushed, never from a local ref.", + ] + + commands = [ + f"git -C {worktree_path} push -u origin {branch}", + f'gh pr create --base {parent} --head {branch} --title "<title>" --body "{pr_body}"', + ] + if above: + commands += [ + f"# writer of {above} carries this layer forward with ONE merge commit (never a rebase, never a force-push):", + f"git -C <worktree of {above}> fetch origin && git -C <worktree of {above}> merge --no-ff origin/{branch} && git -C <worktree of {above}> push", + "gh stack unstack <stack#> # PRs and branches untouched; confirm with the user first", + f"gh pr edit <pr# of {above}> --base {branch}", + ] + if chain_check_available: + commands.append( + f"python3 .claude/scripts/gh_stack_chain_check.py --trunk {trunk} {lower_names}{branch} {above} ... <top> # must print LINKABLE" + ) + commands += [ + f"gh stack link --base {trunk} {lower_prs}<pr# of {branch}> <pr# of {above}> ... <top-pr#>", + "# every other stack worktree: gh stack unstack --local && gh stack checkout <new stack#>", + "/sc-gh-stack-view", + ] + else: + if chain_check_available: + commands.append( + f"python3 .claude/scripts/gh_stack_chain_check.py --trunk {trunk} {lower_names}{branch} # must print LINKABLE" + ) + commands += [ + f"gh stack link <stack#> <pr# of {branch}> # append to an existing stack", + f"gh stack link --base {trunk} {lower_prs}<pr# of {branch}> # first link, or full relink", + "/sc-gh-stack-view", + ] + + return { + "position": stack_info["position"], + "trunk": trunk, + "parent": parent, + "parent_sha": sha, + "above": above, + "pr_base": parent, + "pr_body": pr_body, + "rules": rules, + "commands": commands, + "chain_check_available": chain_check_available, + "reference": ( + "sc-gh-stack references/recipe-restack.md section 1 (insert)" + if above + else "sc-gh-stack references/recipe-link.md section A (first push)" + ), + } + + # ============================================================================= # Main Logic # ============================================================================= @@ -237,6 +489,19 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: message=f"local={branch_exists_local} remote={branch_exists_remote}", ) + # Stack layer: validate the cut before touching anything + stack_info: Optional[Dict[str, Any]] = None + if input_data.stack is not None: + stack_info, stack_error = check_stack_preconditions( + input_data, + repo_root, + transcript, + branch_exists_local=branch_exists_local, + branch_exists_remote=branch_exists_remote, + ) + if stack_error is not None: + return stack_error + # Determine creation strategy if branch_exists_local: # Branch exists locally, just add worktree @@ -265,7 +530,14 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: else: # New branch, create from base # Determine the actual base ref to use (local or remote) - if base_exists_local: + if stack_info is not None: + # Stack layers are always cut from the parent's PUSHED head + base_ref = f"origin/{input_data.base}" + transcript.step_ok( + step="resolve base", + message=f"stack layer: cutting from pushed head {base_ref}", + ) + elif base_exists_local: base_ref = input_data.base elif base_exists_remote: base_ref = f"origin/{input_data.base}" @@ -277,9 +549,15 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: # Neither local nor remote base exists - error handled earlier base_ref = input_data.base - git_cmd = f"git worktree add -b {input_data.branch} {worktree_path} {base_ref}" + # A branch started from a remote-tracking ref must not inherit it as + # upstream: otherwise `git push` / `--force-with-lease` target the base. + add_args = ["worktree", "add"] + if base_ref.startswith("origin/"): + add_args.append("--no-track") + add_args += ["-b", input_data.branch, str(worktree_path), base_ref] + git_cmd = "git " + " ".join(add_args) with transcript.timed_step(git_cmd) as t: - run_git(["worktree", "add", "-b", input_data.branch, str(worktree_path), base_ref], cwd=repo_root) + run_git(add_args, cwd=repo_root) t.message = f"Preparing worktree ({worktree_path})" needs_new_branch = True @@ -316,6 +594,11 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: remote_exists=remote_exists, local_worktree=True, remote_ahead=0, # Just created, local is up to date + stack=( + {k: stack_info[k] for k in ("trunk", "parent", "parent_sha", "above", "position")} + if stack_info + else None + ), ) # Update tracking document (JSONL) @@ -331,20 +614,23 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: transcript.step_skipped(step="update_tracking", message="disabled") # Build response - return Envelope.success_response( - data={ - "action": "create", - "branch": input_data.branch, - "base": input_data.base, - "path": str(worktree_path), - "repo_name": repo_name, - "status": "clean", - "branch_created": needs_new_branch, - "tracking_entry": tracking_entry.model_dump(), - "tracking_updated": tracking_updated, - }, - transcript=transcript, - ) + data: Dict[str, Any] = { + "action": "create", + "branch": input_data.branch, + "base": input_data.base, + "path": str(worktree_path), + "repo_name": repo_name, + "status": "clean", + "branch_created": needs_new_branch, + "tracking_entry": tracking_entry.model_dump(), + "tracking_updated": tracking_updated, + } + if stack_info is not None: + data["stack"] = tracking_entry.stack + data["stack_handoff"] = build_stack_handoff( + input_data.branch, input_data.purpose, worktree_path, stack_info, repo_root + ) + return Envelope.success_response(data=data, transcript=transcript) except subprocess.CalledProcessError as e: cmd = " ".join(e.cmd) if isinstance(e.cmd, list) else str(e.cmd) diff --git a/packages/sc-git-worktree/scripts/worktree_scan.py b/packages/sc-git-worktree/scripts/worktree_scan.py index 743f826f2..244440bd6 100644 --- a/packages/sc-git-worktree/scripts/worktree_scan.py +++ b/packages/sc-git-worktree/scripts/worktree_scan.py @@ -32,6 +32,8 @@ get_protected_branches, get_repo_name, get_repo_root, + is_ancestor, + rev_parse, load_tracking_jsonl, reconcile_tracking, run_git, @@ -44,6 +46,8 @@ get_protected_branches, get_repo_name, get_repo_root, + is_ancestor, + rev_parse, load_tracking_jsonl, reconcile_tracking, run_git, @@ -255,6 +259,28 @@ def batch_get_worktree_statuses(worktrees: List[WorktreeInfo]) -> Dict[str, Tupl # ============================================================================= +def stack_issues(stack_meta: Optional[Dict[str, Any]], repo_root: Path) -> List[str]: + """Issues for a stack layer: parent landed (gone or merged into trunk) or parent advanced.""" + if not stack_meta: + return [] + parent = stack_meta.get("parent") + trunk = stack_meta.get("trunk") + recorded = stack_meta.get("parent_sha") or "" + if not parent or not trunk: + return [] + if parent == trunk: + # Bottom layer: the trunk moving is expected and is not a rebase trigger + return [] + current = rev_parse(f"origin/{parent}", cwd=repo_root) + if current is None: + return [f"stack_parent_landed: origin/{parent} no longer exists"] + if is_ancestor(f"origin/{parent}", f"origin/{trunk}", cwd=repo_root): + return [f"stack_parent_landed: {parent} is contained in {trunk}"] + if recorded and current != recorded: + return [f"stack_parent_advanced: origin/{parent} {recorded[:8]} -> {current[:8]}"] + return [] + + def scan_worktrees( worktree_base: Optional[str] = None, tracking_enabled: bool = True, @@ -440,6 +466,7 @@ def scan_worktrees( # Find matching tracking entry tracking_entry_data = None remote_ahead = 0 + stack_meta = None for entry in tracking_entries: if entry.branch == wt.branch: tracking_entry_data = { @@ -455,8 +482,10 @@ def scan_worktrees( "remote_exists": entry.remote_exists, "local_worktree": entry.local_worktree, "remote_ahead": entry.remote_ahead, + "stack": entry.stack, } remote_ahead = entry.remote_ahead + stack_meta = entry.stack break issues = [] @@ -470,6 +499,7 @@ def scan_worktrees( issues.append(f"prunable: {wt.prunable_reason or 'worktree may be stale'}") if remote_ahead > 0: issues.append(f"remote_ahead: {remote_ahead} commit(s)") + issues.extend(stack_issues(stack_meta, repo_root)) worktree_results.append({ "branch": wt.branch, @@ -506,6 +536,23 @@ def scan_worktrees( if remote_ahead_count > 0: recommendations.append(f"pull changes in {remote_ahead_count} branch(es) where remote is ahead") + # Stack layers whose parent moved or landed + parent_advanced = sum( + 1 for wt in worktree_results if any(i.startswith("stack_parent_advanced") for i in (wt["issues"] or [])) + ) + parent_landed = sum( + 1 for wt in worktree_results if any(i.startswith("stack_parent_landed") for i in (wt["issues"] or [])) + ) + if parent_advanced: + recommendations.append( + f"{parent_advanced} stack layer(s) whose parent advanced: the layer's writer rebases once at task start " + f"(git rebase --onto origin/<parent> <parent_sha> <layer>) only while it has no children" + ) + if parent_landed: + recommendations.append( + f"{parent_landed} stack layer(s) whose parent landed: confirm the PR now targets the trunk (/sc-gh-stack-view)" + ) + return Envelope.success_response( data={ "action": "scan", diff --git a/packages/sc-git-worktree/scripts/worktree_shared.py b/packages/sc-git-worktree/scripts/worktree_shared.py index 0a75eee0f..c896ee6af 100644 --- a/packages/sc-git-worktree/scripts/worktree_shared.py +++ b/packages/sc-git-worktree/scripts/worktree_shared.py @@ -412,6 +412,40 @@ def resolve_merge_base( return None +def is_ancestor(ancestor: str, descendant: str, cwd: Optional[Path] = None) -> bool: + """True when `ancestor` is reachable from `descendant` (git merge-base --is-ancestor). + + Returns False for unknown refs or unrelated histories instead of raising. + """ + result = run_git(["merge-base", "--is-ancestor", ancestor, descendant], cwd=cwd, check=False) + return result.returncode == 0 + + +def rev_parse(ref: str, cwd: Optional[Path] = None) -> Optional[str]: + """Full SHA of `ref`, or None when it does not resolve.""" + result = run_git(["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], cwd=cwd, check=False) + if result.returncode != 0: + return None + sha = result.stdout.strip() + return sha or None + + +def is_landed_by_merge(branch: str, base: str, cwd: Optional[Path] = None, limit: int = 5000) -> bool: + """True when `branch` was merged into `base` with a merge commit. + + A layer landed with `--merge` is reachable from the base but sits off its + first-parent line; an empty branch parked on a base commit is on that line. + Only the last `limit` first-parent commits of the base are inspected. + """ + head = rev_parse(branch, cwd=cwd) + if not head or not is_ancestor(head, base, cwd=cwd): + return False + result = run_git(["rev-list", "--first-parent", f"-n{limit}", base], cwd=cwd, check=False) + if result.returncode != 0: + return False + return head not in result.stdout.split() + + def check_branch_exists_local(branch: str, cwd: Optional[Path] = None) -> bool: """Check if a branch exists locally.""" result = run_git(["branch", "--list", branch], cwd=cwd, check=False) @@ -465,7 +499,8 @@ def is_branch_merged(branch: str, base: str = "HEAD", cwd: Optional[Path] = None result = run_git(["branch", "--merged", base], cwd=cwd, check=False) if result.returncode != 0: return False - merged_branches = [b.strip().lstrip("* ") for b in result.stdout.strip().split("\n")] + # "* " marks the current branch, "+ " a branch checked out in another worktree + merged_branches = [b.strip().lstrip("*+ ") for b in result.stdout.strip().split("\n")] return branch in merged_branches @@ -823,6 +858,25 @@ class TrackingEntry(BaseModel): remote_exists: bool = Field(False, description="Whether branch exists on remote") local_worktree: bool = Field(True, description="Whether local worktree exists") remote_ahead: int = Field(0, description="Number of commits remote has that local doesn't") + # Stack layer metadata (gh-stack). None for ordinary worktrees. Keys: + # trunk, parent, parent_sha (origin/<parent> at cut time), above (insert only), position + stack: Optional[Dict[str, Any]] = Field(None, description="Stack layer metadata when the worktree is a gh-stack layer") + + +def find_stack_children(entries: list["TrackingEntry"], branch: str) -> list[str]: + """Return branches recorded as stack layers cut from `branch` that are still live. + + A child is live while it has a local worktree or a remote branch. Deleting a + parent under a live child breaks the stack chain (the child PR loses its base). + """ + children = [] + for entry in entries: + stack = entry.stack or {} + if stack.get("parent") != branch: + continue + if entry.local_worktree or entry.remote_exists: + children.append(entry.branch) + return children def get_default_tracking_path(worktree_base: Path) -> Path: diff --git a/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md b/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md index ec93fa5d7..c037e52a6 100644 --- a/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md +++ b/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md @@ -3,8 +3,9 @@ name: sc-managing-worktrees description: Create, manage, scan, update, and clean up git worktrees for parallel development with protected branch safeguards. Use when working on multiple branches simultaneously, isolating experiments, updating protected branches (main/develop), - or when user mentions "worktree", "parallel branches", "feature isolation", "branch cleanup", "worktree status", or "update main/develop". -version: 0.13.0 + cutting a gh-stack layer as a worktree (top or mid-stack insert), or when user mentions "worktree", "parallel branches", + "feature isolation", "branch cleanup", "worktree status", "update main/develop", "stack layer worktree", or "stacked worktree". +version: 0.14.0 entry_point: /sc-git-worktree --- @@ -37,6 +38,7 @@ $INPUT_JSON | Operation | Agent | Returns | |-----------|-------|---------| | Create | `sc-worktree-create` | JSON: success, path, branch, tracking_entry | +| Create stack layer | `sc-worktree-create-stacked` | JSON: success, path, branch, stack, stack_handoff | | Scan | `sc-worktree-scan` | JSON: success, worktrees list, recommendations | | Cleanup | `sc-worktree-cleanup` | JSON: success, branch_deleted, tracking_update | | Abort | `sc-worktree-abort` | JSON: success, worktree_removed, tracking_update | @@ -44,6 +46,18 @@ $INPUT_JSON To invoke an agent, use the Task tool with the agent prompt and pass parameters exactly as documented in the agent Inputs section. +**Routing rule:** a worktree that is a gh-stack layer (the user says "stack", "layer", "on top of <branch>", "insert under", or the branch will be linked with `gh stack`) goes to `sc-worktree-create-stacked`. Everything else goes to `sc-worktree-create`. + +## Stack Layers (gh-stack) + +A stack layer is a worktree whose branch will be a PR in a `gh stack`. The plain create is wrong for it: it branches from the **local** base ref (possibly stale or another writer's unpushed state) and, from a remote ref, would leave the parent as upstream. `sc-worktree-create-stacked` cuts from `origin/<parent>` with `--no-track`, refuses bad cuts before touching anything (parent not pushed, already landed, insert target not stacked on the parent), records the parent SHA in tracking, and returns a `stack_handoff` block. + +- Pass `stack_handoff` verbatim to the agent that will work in the worktree. It is the writer's contract: first push with `-u`, PR base = parent, link on the first push, at most one rebase at task start, no edits to lower layers, and for an insert the merge-forward / unstack / `gh pr edit --base` / full relink sequence. +- Cleanup and abort refuse to delete a branch that live layers were cut from unless git shows it landed in the trunk (`STACK.HAS_CHILDREN`); batch cleanup reports such branches under `stack_blocked`. +- Scan reports `stack_parent_advanced` (rebase once at task start) and `stack_parent_landed` (PR should now target the trunk) on layer rows. + +Details and the handoff contract: `references/stack-layers.md`. The stack model, recipes and the view tool live in the `sc-gh-stack` package (`/sc-gh-stack`, `/sc-gh-stack-view`); this skill only makes the worktree side of that model safe. + ## Standards and Paths - Repo root: current directory. - Default worktree base: `../{{REPO_NAME}}-worktrees`. diff --git a/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md b/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md new file mode 100644 index 000000000..da9e0bb2e --- /dev/null +++ b/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md @@ -0,0 +1,71 @@ +# Stack layers: what the worktree skill guarantees and what the writer must do + +Read this when a worktree is a gh-stack layer. The stack model itself (append-only +linear stack, frozen layers, one writer per branch, one stack writer) is documented +in the `sc-gh-stack` package; this file covers only the worktree side. + +## Why a layer needs its own create path + +| Plain `--create <branch> <base>` | `--create-stacked <branch> <parent> <trunk>` | +|---|---| +| Branches from the **local** `<base>` ref when one exists | Always cuts from `origin/<parent>` after a fetch | +| Local ref may be stale or another writer's unpushed state | The pushed head is the only truth for a stack | +| No record of where the branch started | Records `parent_sha` in tracking and in the handoff | +| No check that `<base>` is a valid parent | Refuses: parent not pushed, parent already landed, parent off the trunk, bad insert target, branch already exists | +| Any remote-ref base is created with `--no-track` (fixed in 0.14.0) | Same; the layer never has the parent as upstream | + +All refusals happen before any worktree or branch is created and return a +`suggested_action`. + +## Inputs + +- `branch`: new layer name. Must not exist locally or on origin. +- `parent` (`base` in JSON): the branch to cut from. + - Append on top: the current top from `/sc-gh-stack-view`. + - Bottom of a new stack: the trunk itself. + - Insert: the layer *below* the insertion point, plus `--above <layer>` naming the layer currently stacked on it. +- `trunk` (`stack.trunk`): the branch the stack's bottom PR targets. +- `purpose`, `owner`: the writer's task id and handle. + +## Output the caller must forward + +`data.stack` is stored in the tracking row: + +```json +{"trunk": "develop", "parent": "sprint-6", "parent_sha": "<40-char sha>", "above": null, "position": "top"} +``` + +`data.stack_handoff` is for the agent that will work in the worktree. Paste it +into that agent's prompt unchanged. Its `commands[]` are ordered; angle-bracket +placeholders are filled from `gh pr create` output and `/sc-gh-stack-view`. + +## Writer contract (what the handoff says) + +1. You are the only writer of the layer. Never edit, rebase or force-push any layer below it. +2. Push a WIP commit within minutes: `git push -u origin <layer>`. Then open the PR with `--base <parent>` (never the trunk unless the parent *is* the trunk). The PR body records `Parent: <parent> @ <parent_sha>`. +3. Stack on the first push, never "once it is green": `gh stack link <stack#> <pr#>` to append, or the full `gh stack link --base <trunk> <ordered pr#s>`. PR numbers, not branch names. +4. Rebase at most once per task, at the start, only while the layer has no children: `git rebase --onto origin/<parent> <parent_sha> <layer>` then `git push --force-with-lease`; record the new parent SHA. +5. The next layer is cut from `origin/<layer>` once it is pushed, never from a local ref. + +## Insert mid-stack (`position: insert`) + +`gh stack link` cannot insert. The handoff adds, in order: + +1. Push and open the PR with base `<parent>` as above. +2. The writer of `<above>` carries the new layer forward with **one** merge commit (`git merge --no-ff origin/<layer>`), never a rebase; otherwise the stack is non-linear and cannot land. +3. `gh stack unstack <stack#>` (PRs and branches untouched; confirm with the user, it is outward-facing). +4. `gh pr edit <pr# of above> --base <layer>`. +5. Chain check (`gh_stack_chain_check.py`, present when `sc-gh-stack` is installed), then the full `gh stack link --base <trunk> ...` with the new layer in place. +6. Every other stack worktree: `gh stack unstack --local && gh stack checkout <new stack#>`; then `/sc-gh-stack-view`. + +## What the other operations do with layers + +- **Cleanup / abort**: a branch with live child layers in tracking (child has a worktree or a remote branch) is never deleted unless git shows the branch merged into the trunk. Error `STACK.HAS_CHILDREN` names the children; batch cleanup lists them under `stack_blocked`. A landed parent is safe to delete: GitHub retargets the children onto the trunk. +- **Scan**: layer rows carry `tracking_entry.stack` and may report `stack_parent_advanced: origin/<parent> <old> -> <new>` (the writer's one rebase applies) or `stack_parent_landed` (the PR should now target the trunk; confirm with `/sc-gh-stack-view`). The trunk moving under a bottom layer is not reported; it is expected. +- **Update**: unchanged; trunks are protected branches and are updated with `--update`. + +## Not done here + +Pushing, opening PRs, linking, unstacking and merging are the writer's and the +stack writer's jobs, in the worktree, with `sc-gh-stack`. This skill never runs +`gh`. diff --git a/packages/sc-git-worktree/tests/test_git_operations.py b/packages/sc-git-worktree/tests/test_git_operations.py index 141015437..494bc7a29 100644 --- a/packages/sc-git-worktree/tests/test_git_operations.py +++ b/packages/sc-git-worktree/tests/test_git_operations.py @@ -215,6 +215,16 @@ def test_current_branch_marker(self, mock_run_git): assert result == True + @patch('worktree_shared.run_git') + def test_worktree_branch_marker(self, mock_run_git): + """Branches checked out in another worktree are listed with a '+' marker.""" + mock_run_git.return_value = MagicMock( + returncode=0, + stdout="* develop\n+ feature/in-worktree\n other-branch\n" + ) + + assert is_branch_merged("feature/in-worktree") == True + @patch('worktree_shared.run_git') def test_git_error(self, mock_run_git): """Test handling of git errors.""" diff --git a/packages/sc-git-worktree/tests/test_stack_aware.py b/packages/sc-git-worktree/tests/test_stack_aware.py new file mode 100644 index 000000000..123b132da --- /dev/null +++ b/packages/sc-git-worktree/tests/test_stack_aware.py @@ -0,0 +1,389 @@ +"""Stack-aware behaviour: gh-stack layer worktrees. + +Real git repositories (a bare origin plus a clone) exercise the create script's +stack mode, the --no-track fix, the cleanup/abort stack guards, and the scan +issues for layers whose parent moved or landed. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).parent.parent / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from worktree_shared import ( # noqa: E402 + TrackingEntry, + find_stack_children, + is_landed_by_merge, + load_tracking_jsonl, +) + + +def git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout.strip() + + +def run_script(name, payload=None, *, cwd, args=()): + cmd = [sys.executable, str(SCRIPTS / name)] + if payload is not None: + cmd.append(json.dumps(payload)) + cmd.extend(args) + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + text = proc.stdout + body = text[text.find("{"): text.rfind("}") + 1] + assert body, f"no JSON in output:\nstdout={proc.stdout}\nstderr={proc.stderr}" + return json.loads(body) + + +@pytest.fixture +def stack_repo(tmp_path): + """origin with develop (trunk) and a pushed layer l1 on top of it.""" + origin = tmp_path / "origin.git" + repo = tmp_path / "repo" + git("init", "-q", "--bare", str(origin), cwd=tmp_path) + git("clone", "-q", str(origin), str(repo), cwd=tmp_path) + git("config", "user.email", "t@t", cwd=repo) + git("config", "user.name", "t", cwd=repo) + (repo / "a").write_text("a\n") + git("add", "a", cwd=repo) + git("commit", "-qm", "base", cwd=repo) + git("branch", "-M", "develop", cwd=repo) + git("push", "-q", "-u", "origin", "develop", cwd=repo) + git("checkout", "-qb", "l1", cwd=repo) + (repo / "l1").write_text("l1\n") + git("add", "l1", cwd=repo) + git("commit", "-qm", "l1", cwd=repo) + git("push", "-q", "-u", "origin", "l1", cwd=repo) + git("checkout", "-q", "develop", cwd=repo) + (repo / ".sc").mkdir() + (repo / ".sc" / "shared-settings.yaml").write_text("git:\n protected_branches:\n - develop\n") + return repo + + +def create(repo, branch, base, stack=None, **extra): + payload = {"branch": branch, "base": base, "purpose": f"work on {branch}", "owner": "t", "repo_root": str(repo)} + if stack is not None: + payload["stack"] = stack + payload.update(extra) + return run_script("worktree_create.py", payload, cwd=repo) + + +def upstream(worktree): + proc = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "@{u}"], cwd=worktree, capture_output=True, text=True + ) + return proc.stdout.strip() if proc.returncode == 0 else None + + +def tracking_path(repo): + return repo.parent / f"{repo.name}-worktrees" / "worktree-tracking.jsonl" + + +# ============================================================================= +# Create: stack mode +# ============================================================================= + + +class TestStackCreate: + def test_cut_from_pushed_parent_without_upstream(self, stack_repo): + # Make the local l1 ref stale so a local-ref cut would be wrong + git("branch", "-f", "l1", "develop", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert result["success"], result + data = result["data"] + wt = Path(data["path"]) + assert git("rev-parse", "HEAD", cwd=wt) == git("rev-parse", "origin/l1", cwd=stack_repo) + assert upstream(wt) is None, "a layer must not track its parent" + assert data["stack"] == { + "trunk": "develop", + "parent": "l1", + "parent_sha": git("rev-parse", "origin/l1", cwd=stack_repo), + "above": None, + "position": "top", + } + handoff = data["stack_handoff"] + assert handoff["pr_base"] == "l1" + assert handoff["commands"][0] == f"git -C {wt} push -u origin l2" + assert "--base l1 --head l2" in handoff["commands"][1] + assert any(cmd.startswith("gh stack link <stack#> <pr# of l2>") for cmd in handoff["commands"]) + assert any("gh stack link --base develop <bottom-pr#> ... <pr# of l1> <pr# of l2>" in c for c in handoff["commands"]) + assert handoff["commands"][-1] == "/sc-gh-stack-view" + assert any("rebase --onto origin/l1" in rule for rule in handoff["rules"]) + # Tracking row carries the stack metadata + entries = load_tracking_jsonl(tracking_path(stack_repo)) + assert entries[0].stack["parent_sha"] == data["stack"]["parent_sha"] + + def test_bottom_layer_handoff_has_nothing_below(self, stack_repo): + result = create(stack_repo, "l0", "develop", stack={"trunk": "develop"}) + assert result["success"], result + cmds = result["data"]["stack_handoff"]["commands"] + assert "gh stack link --base develop <pr# of l0> # first link, or full relink" in cmds + assert not any("pr# of develop" in c for c in cmds) + + def test_insert_handoff(self, stack_repo): + result = create(stack_repo, "l1b", "develop", stack={"trunk": "develop", "above": "l1"}) + assert result["success"], result + data = result["data"] + assert data["stack"]["position"] == "insert" + cmds = data["stack_handoff"]["commands"] + assert any("merge --no-ff origin/l1b" in c for c in cmds) + assert "gh pr edit <pr# of l1> --base l1b" in cmds + assert any(c.startswith("gh stack unstack <stack#>") for c in cmds) + assert any("gh stack link --base develop <pr# of l1b> <pr# of l1> ... <top-pr#>" in c for c in cmds) + assert data["stack_handoff"]["reference"].endswith("(insert)") + + def test_chain_check_command_only_when_installed(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert not result["data"]["stack_handoff"]["chain_check_available"] + assert not any("gh_stack_chain_check" in c for c in result["data"]["stack_handoff"]["commands"]) + (stack_repo / ".claude" / "scripts").mkdir(parents=True) + (stack_repo / ".claude" / "scripts" / "gh_stack_chain_check.py").write_text("# stub\n") + result = create(stack_repo, "l3", "l1", stack={"trunk": "develop"}) + handoff = result["data"]["stack_handoff"] + assert handoff["chain_check_available"] + assert any("gh_stack_chain_check.py --trunk develop <bottom> ... l1 l3" in c for c in handoff["commands"]) + + def test_parent_behind_trunk_is_a_note_not_a_block(self, stack_repo): + # Trunk moves on after l1 was cut: allowed, recorded in the transcript + git("commit", "-q", "--allow-empty", "-m", "trunk moves", cwd=stack_repo) + git("push", "-q", "origin", "develop", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert result["success"], result + steps = " ".join(s.get("message", "") or "" for s in result["metadata"]["transcript"]) + assert "parent behind trunk by 1 commit(s)" in steps + + +class TestStackCreateRefusals: + def test_layer_must_be_new(self, stack_repo): + git("branch", "-q", "l2", "l1", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert not result["success"] + assert result["error"]["code"] == "STACK.LAYER_EXISTS" + assert "omit `stack`" in result["error"]["suggested_action"] + + def test_parent_must_be_pushed(self, stack_repo): + git("branch", "-q", "local-only", "l1", cwd=stack_repo) + result = create(stack_repo, "l2", "local-only", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_NOT_PUSHED" + assert "git push -u origin local-only" in result["error"]["suggested_action"] + assert not (stack_repo.parent / "repo-worktrees" / "l2").exists() + + def test_trunk_must_exist(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "nope"}) + assert result["error"]["code"] == "BRANCH.NOT_FOUND" + assert "stack.trunk" in result["error"]["suggested_action"] + + def test_landed_parent_refused(self, stack_repo): + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=stack_repo) + git("push", "-q", "origin", "develop", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_LANDED" + assert "/sc-gh-stack-view" in result["error"]["suggested_action"] + + def test_above_must_contain_parent(self, stack_repo): + git("branch", "-q", "other", "develop", cwd=stack_repo) + git("push", "-q", "origin", "other", cwd=stack_repo) + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop", "above": "other"}) + assert result["error"]["code"] == "STACK.ABOVE_INVALID" + assert "does not contain origin/l1" in result["error"]["message"] + + def test_above_must_be_pushed(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop", "above": "ghost"}) + assert result["error"]["code"] == "STACK.ABOVE_INVALID" + assert "not on origin" in result["error"]["message"] + + def test_above_cannot_be_parent_or_self(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop", "above": "l1"}) + assert result["error"]["code"] == "STACK.ABOVE_INVALID" + + def test_empty_trunk_rejected_at_input(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": " "}) + assert not result["success"] + assert result["error"]["code"] == "CONFIG.MISSING" + + +class TestPlainCreateNoTrack: + def test_remote_only_base_does_not_become_upstream(self, stack_repo): + git("branch", "-q", "-D", "l1", cwd=stack_repo) + result = create(stack_repo, "feature/x", "l1") + assert result["success"], result + assert upstream(Path(result["data"]["path"])) is None + assert "stack" not in result["data"] + assert load_tracking_jsonl(tracking_path(stack_repo))[0].stack is None + + def test_local_base_still_used_in_plain_mode(self, stack_repo): + git("branch", "-f", "l1", "develop", cwd=stack_repo) # stale local ref + result = create(stack_repo, "feature/y", "l1") + assert result["success"], result + assert git("rev-parse", "HEAD", cwd=Path(result["data"]["path"])) == git("rev-parse", "develop", cwd=stack_repo) + + +# ============================================================================= +# Tracking schema +# ============================================================================= + + +class TestTrackingStackField: + def test_entry_without_stack_loads(self, tmp_path): + p = tmp_path / "t.jsonl" + p.write_text( + '{"branch":"f","path":"/p","base":"main","owner":"o","created":"2024-01-01T00:00:00Z","last_checked":"2024-01-01T00:00:00Z"}\n' + ) + entries = load_tracking_jsonl(p) + assert entries[0].stack is None + + def test_find_stack_children_only_live(self): + def entry(branch, parent=None, local=True, remote=False): + return TrackingEntry( + branch=branch, path="/p", base=parent or "main", owner="o", + created="2024-01-01T00:00:00Z", last_checked="2024-01-01T00:00:00Z", + local_worktree=local, remote_exists=remote, + stack={"trunk": "main", "parent": parent, "parent_sha": "x", "above": None, "position": "top"} if parent else None, + ) + entries = [ + entry("l1"), + entry("l2", parent="l1"), + entry("l3", parent="l1", local=False, remote=True), + entry("gone", parent="l1", local=False, remote=False), + entry("l4", parent="l2"), + ] + assert find_stack_children(entries, "l1") == ["l2", "l3"] + assert find_stack_children(entries, "l2") == ["l4"] + assert find_stack_children(entries, "l4") == [] + + +class TestLandedByMerge: + def test_empty_branch_on_trunk_is_not_landed(self, stack_repo): + git("branch", "-q", "empty", "develop", cwd=stack_repo) + assert not is_landed_by_merge("empty", "develop", cwd=stack_repo) + + def test_unmerged_layer_is_not_landed(self, stack_repo): + assert not is_landed_by_merge("l1", "develop", cwd=stack_repo) + + def test_merge_commit_landing_is_landed(self, stack_repo): + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=stack_repo) + assert is_landed_by_merge("l1", "develop", cwd=stack_repo) + + def test_fast_forward_is_not_landed(self, stack_repo): + # A fast-forward puts the layer head on the trunk's first-parent line: indistinguishable + # from an empty branch, so it is treated as not landed (fails closed). + git("merge", "-q", "--ff-only", "l1", cwd=stack_repo) + assert not is_landed_by_merge("l1", "develop", cwd=stack_repo) + + def test_unknown_branch(self, stack_repo): + assert not is_landed_by_merge("ghost", "develop", cwd=stack_repo) + + +# ============================================================================= +# Cleanup / abort guards +# ============================================================================= + + +class TestStackGuards: + @pytest.fixture + def layered(self, stack_repo): + """l1 has a worktree; l2 is a live layer cut from l1.""" + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + git("worktree", "add", "-q", str(stack_repo.parent / "repo-worktrees" / "l1"), "l1", cwd=stack_repo) + return stack_repo + + def test_abort_refuses_to_delete_parent(self, layered): + result = run_script( + "worktree_abort.py", {"branch": "l1", "allow_delete_branch": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["error"]["code"] == "STACK.HAS_CHILDREN" + assert result["error"]["data"]["stack_children"] == ["l2"] if "data" in result["error"] else True + assert (layered.parent / "repo-worktrees" / "l1").exists(), "nothing may be mutated before the guard" + assert git("rev-parse", "--verify", "l1", cwd=layered) + + def test_abort_without_delete_removes_worktree_only(self, layered): + result = run_script("worktree_abort.py", {"branch": "l1", "repo_root": str(layered)}, cwd=layered) + assert result["success"], result + assert result["data"]["branch_deleted_local"] is False + + def test_cleanup_override_refused(self, layered): + result = run_script( + "worktree_cleanup.py", {"branch": "l1", "merged": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["error"]["code"] == "STACK.HAS_CHILDREN" + assert "gh stack merge" in result["error"]["suggested_action"] + assert (layered.parent / "repo-worktrees" / "l1").exists() + + def test_cleanup_after_real_landing_is_allowed(self, layered): + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + result = run_script("worktree_cleanup.py", {"branch": "l1", "repo_root": str(layered)}, cwd=layered) + assert result["success"], result + assert result["data"]["branch_deleted_local"] is True + + def test_batch_cleanup_blocks_fast_forwarded_parent(self, layered): + # A fast-forward puts l1's head on the trunk's first-parent line, which git cannot + # tell apart from an empty branch: batch cleanup must fail closed while l2 is live. + git("merge", "-q", "--ff-only", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + run_script("worktree_scan.py", cwd=layered, args=("--no-cache",)) # register l1's worktree + result = run_script("worktree_cleanup.py", {"repo_root": str(layered)}, cwd=layered) + assert result["success"], result + blocked = {b["branch"]: b for b in (result["data"]["stack_blocked"] or [])} + assert "l1" in blocked and blocked["l1"]["stack_children"] == ["l2"] + assert "l1" not in {c["branch"] for c in result["data"]["cleaned"]} + assert git("rev-parse", "--verify", "l1", cwd=layered) + assert result["data"]["summary"]["stack_blocked"] == 1 + + def test_batch_cleanup_cleans_parent_landed_by_merge(self, layered): + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + run_script("worktree_scan.py", cwd=layered, args=("--no-cache",)) + result = run_script("worktree_cleanup.py", {"repo_root": str(layered)}, cwd=layered) + assert result["success"], result + assert "l1" in {c["branch"] for c in result["data"]["cleaned"]} + assert not result["data"]["stack_blocked"] + # l2 has no commits of its own, so once l1 landed it is an empty worktree and is + # swept by the pre-existing empty-branch rule; nothing stack-specific applies. + + def test_empty_parent_cannot_get_children(self, stack_repo): + # The scenario the guard would otherwise need: a pushed empty branch as a parent. + git("branch", "-q", "empty", "develop", cwd=stack_repo) + git("push", "-q", "origin", "empty", cwd=stack_repo) + result = create(stack_repo, "child", "empty", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_LANDED" + + +# ============================================================================= +# Scan +# ============================================================================= + + +class TestScanStackIssues: + def test_parent_advanced(self, stack_repo): + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + git("worktree", "add", "-q", str(stack_repo.parent / "repo-worktrees" / "l1"), "l1", cwd=stack_repo) + wt1 = stack_repo.parent / "repo-worktrees" / "l1" + git("commit", "-q", "--allow-empty", "-m", "more", cwd=wt1) + git("push", "-q", "origin", "l1", cwd=wt1) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l2 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l2") + assert any(i.startswith("stack_parent_advanced: origin/l1") for i in l2["issues"]) + assert l2["tracking_entry"]["stack"]["parent"] == "l1" + assert any("rebases once at task start" in r for r in result["data"]["recommendations"]) + + def test_parent_landed(self, stack_repo): + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=stack_repo) + git("push", "-q", "origin", "develop", cwd=stack_repo) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l2 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l2") + assert "stack_parent_landed: l1 is contained in develop" in l2["issues"] + + def test_bottom_layer_has_no_stack_issue_when_trunk_moves(self, stack_repo): + create(stack_repo, "l0", "develop", stack={"trunk": "develop"}) + git("commit", "-q", "--allow-empty", "-m", "trunk moves", cwd=stack_repo) + git("push", "-q", "origin", "develop", cwd=stack_repo) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l0 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l0") + assert not any(i.startswith("stack_") for i in (l0["issues"] or [])) diff --git a/tests/test_sc_prefix_validation.py b/tests/test_sc_prefix_validation.py index 5f1514772..0762220be 100644 --- a/tests/test_sc_prefix_validation.py +++ b/tests/test_sc_prefix_validation.py @@ -46,12 +46,12 @@ def _get_marketplace_version() -> str: }, 'sc-git-worktree': { 'package_name': 'sc-git-worktree', - 'agents': ['sc-git-worktree-create', 'sc-git-worktree-scan', 'sc-git-worktree-cleanup', 'sc-git-worktree-abort', 'sc-git-worktree-update'], + 'agents': ['sc-git-worktree-create', 'sc-git-worktree-create-stacked', 'sc-git-worktree-scan', 'sc-git-worktree-cleanup', 'sc-git-worktree-abort', 'sc-git-worktree-update'], 'commands': ['sc-git-worktree'], 'skills': ['sc-managing-worktrees'], - 'version': _CURRENT_VERSION, + 'version': '0.14.0', # stack-aware release; ahead of the marketplace-wide version 'path': 'packages/sc-git-worktree', - 'artifact_counts': {'agents': 5, 'commands': 1, 'skills': 1} + 'artifact_counts': {'agents': 6, 'commands': 1, 'skills': 1} }, 'sc-repomix-nuget': { 'package_name': 'sc-repomix-nuget', From a81d4eacfba3ccbd1312389872a266ce21dac19e Mon Sep 17 00:00:00 2001 From: Rand Lee <randlee@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:10:59 -0700 Subject: [PATCH 2/2] fix(sc-git-worktree): resolve hostile-review findings on stack-aware handoff and guards - stack_handoff split by role: `writer` (WIP commit first, push -u, one rebase rule, no gh stack write commands) and `stack_writer` (PR with base = parent, gh stack checkout in the new worktree, link on top, or the insert sequence with merge-forward by EVERY layer above) - STACK.PARENT_HAS_CHILD: refuse an append onto a parent that already has a live layer - find_stack_children checks liveness against git/filesystem and treats the layer an inserted layer sits under (`stack.above`) as its child; is_landed_by_merge fails closed when its first-parent window is exhausted - cleanup judges landing against origin/<children's trunk>, only when children exist; batch cleanup never sweeps a fresh layer with no commits; actionable reconcile hints - scan: parent_advanced now means "layer does not contain origin/<parent>" (clears after the writer's rebase or a merge-forward); landed recommendation adds the diff-before-reset rule - shell-safe handoff commands (quoted heredoc PR body, quoted paths) - docs aligned (agent, reference, skill, command, changelog); sc-gh-stack recipe-cut-layer points at --create-stacked (sc-gh-stack 0.1.1) - tests: 42 real-git stack tests; package tests added to local pytest testpaths Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/registry.json | 46 ++-- docs/registries/nuget/registry.json | 4 +- .../sc-gh-stack/.claude-plugin/plugin.json | 2 +- packages/sc-gh-stack/CHANGELOG.md | 5 + .../sc-gh-stack/commands/sc-gh-stack-view.md | 2 +- packages/sc-gh-stack/commands/sc-gh-stack.md | 2 +- packages/sc-gh-stack/manifest.yaml | 2 +- .../skills/sc-gh-stack-view/SKILL.md | 2 +- .../sc-gh-stack/skills/sc-gh-stack/SKILL.md | 2 +- .../references/recipe-cut-layer.md | 9 +- packages/sc-git-worktree/CHANGELOG.md | 4 +- .../agents/sc-git-worktree-abort.md | 2 +- .../agents/sc-git-worktree-cleanup.md | 2 +- .../agents/sc-git-worktree-create-stacked.md | 37 +++- .../agents/sc-git-worktree-scan.md | 4 +- .../commands/sc-git-worktree.md | 2 +- packages/sc-git-worktree/scripts/envelope.py | 1 + .../sc-git-worktree/scripts/worktree_abort.py | 5 +- .../scripts/worktree_cleanup.py | 93 ++++++--- .../scripts/worktree_create.py | 90 +++++--- .../sc-git-worktree/scripts/worktree_scan.py | 19 +- .../scripts/worktree_shared.py | 41 +++- .../skills/sc-git-worktree/SKILL.md | 6 +- .../references/stack-layers.md | 39 ++-- packages/sc-git-worktree/tests/__init__.py | 1 - .../sc-git-worktree/tests/test_stack_aware.py | 197 ++++++++++++++---- pytest.ini | 2 +- 28 files changed, 440 insertions(+), 183 deletions(-) delete mode 100644 packages/sc-git-worktree/tests/__init__.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fce16a1e5..e9df88f53 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -148,7 +148,7 @@ "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", + "version": "0.1.1", "author": { "name": "randlee" }, diff --git a/.claude-plugin/registry.json b/.claude-plugin/registry.json index 1a4cfa27b..e15f40ced 100644 --- a/.claude-plugin/registry.json +++ b/.claude-plugin/registry.json @@ -21,7 +21,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.684219+00:00" + "lastUpdated": "2026-09-23T06:05:07.118783+00:00" }, { "name": "sc-ci-automation", @@ -38,7 +38,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.685441+00:00" + "lastUpdated": "2026-09-23T06:05:07.119819+00:00" }, { "name": "sc-codex", @@ -55,7 +55,7 @@ "scripts": 2, "schemas": 2 }, - "lastUpdated": "2026-09-23T05:51:05.686459+00:00" + "lastUpdated": "2026-09-23T06:05:07.120619+00:00" }, { "name": "sc-coding-agent-hardening", @@ -72,7 +72,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.686917+00:00" + "lastUpdated": "2026-09-23T06:05:07.121081+00:00" }, { "name": "sc-commit-push-pr", @@ -89,7 +89,7 @@ "scripts": 9, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.687775+00:00" + "lastUpdated": "2026-09-23T06:05:07.121745+00:00" }, { "name": "sc-delay-tasks", @@ -106,7 +106,7 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.688501+00:00" + "lastUpdated": "2026-09-23T06:05:07.122357+00:00" }, { "name": "sc-docling-pdf", @@ -123,11 +123,11 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.689432+00:00" + "lastUpdated": "2026-09-23T06:05:07.123165+00:00" }, { "name": "sc-gh-stack", - "version": "0.1.0", + "version": "0.1.1", "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", @@ -140,7 +140,7 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.690494+00:00" + "lastUpdated": "2026-09-23T06:05:07.124081+00:00" }, { "name": "sc-git-worktree", @@ -157,7 +157,7 @@ "scripts": 7, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.691470+00:00" + "lastUpdated": "2026-09-23T06:05:07.125067+00:00" }, { "name": "sc-github-issue", @@ -174,7 +174,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.692717+00:00" + "lastUpdated": "2026-09-23T06:05:07.126099+00:00" }, { "name": "sc-just", @@ -191,7 +191,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.693694+00:00" + "lastUpdated": "2026-09-23T06:05:07.127102+00:00" }, { "name": "sc-kanban", @@ -208,7 +208,7 @@ "scripts": 5, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.694707+00:00" + "lastUpdated": "2026-09-23T06:05:07.127868+00:00" }, { "name": "sc-launch-term", @@ -225,7 +225,7 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.695602+00:00" + "lastUpdated": "2026-09-23T06:05:07.128494+00:00" }, { "name": "sc-launchpad", @@ -242,7 +242,7 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.696388+00:00" + "lastUpdated": "2026-09-23T06:05:07.129165+00:00" }, { "name": "sc-manage", @@ -259,7 +259,7 @@ "scripts": 8, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.697350+00:00" + "lastUpdated": "2026-09-23T06:05:07.129845+00:00" }, { "name": "sc-refactory", @@ -276,7 +276,7 @@ "scripts": 10, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.698768+00:00" + "lastUpdated": "2026-09-23T06:05:07.130913+00:00" }, { "name": "sc-repomix-nuget", @@ -293,7 +293,7 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.699612+00:00" + "lastUpdated": "2026-09-23T06:05:07.131556+00:00" }, { "name": "sc-roslyn-diff", @@ -310,7 +310,7 @@ "scripts": 6, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.700490+00:00" + "lastUpdated": "2026-09-23T06:05:07.132277+00:00" }, { "name": "sc-rust", @@ -327,7 +327,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.701943+00:00" + "lastUpdated": "2026-09-23T06:05:07.133451+00:00" }, { "name": "sc-startup", @@ -344,7 +344,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-09-23T05:51:05.702888+00:00" + "lastUpdated": "2026-09-23T06:05:07.134183+00:00" } ], "metadata": { @@ -355,6 +355,6 @@ "totalScripts": 63, "totalSchemas": 2 }, - "generated": "2026-09-23T05:51:05.702899+00:00", - "lastUpdated": "2026-09-23T05:51:05.702900+00:00" + "generated": "2026-09-23T06:05:07.134194+00:00", + "lastUpdated": "2026-09-23T06:05:07.134195+00:00" } diff --git a/docs/registries/nuget/registry.json b/docs/registries/nuget/registry.json index f14252608..3c15c145a 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-09-23T05:51:05Z", + "generated": "2026-09-23T06:05:07Z", "repo": "randlee/synaptic-canvas", "marketplace": { "name": "Synaptic Canvas", @@ -728,7 +728,7 @@ "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", + "version": "0.1.1", "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": { diff --git a/packages/sc-gh-stack/.claude-plugin/plugin.json b/packages/sc-gh-stack/.claude-plugin/plugin.json index ee74fd739..85ad2c06e 100644 --- a/packages/sc-gh-stack/.claude-plugin/plugin.json +++ b/packages/sc-gh-stack/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "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", + "version": "0.1.1", "author": { "name": "randlee" }, diff --git a/packages/sc-gh-stack/CHANGELOG.md b/packages/sc-gh-stack/CHANGELOG.md index 6c53ecfa9..1cd0265ce 100644 --- a/packages/sc-gh-stack/CHANGELOG.md +++ b/packages/sc-gh-stack/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to the **sc-gh-stack** package will be documented in this fi 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.1] - 2026-09-23 + +### Changed +- `recipe-cut-layer.md` now points to `sc-git-worktree --create-stacked <layer> <top> <trunk>` (0.14.0+), which performs the cut from `origin/<top>` with `--no-track`; the old advice to refresh the local ref and use plain `--create` is gone. + ## [0.1.0] - 2026-09-23 ### Added diff --git a/packages/sc-gh-stack/commands/sc-gh-stack-view.md b/packages/sc-gh-stack/commands/sc-gh-stack-view.md index 0ea6a8554..e8dbb7c10 100644 --- a/packages/sc-gh-stack/commands/sc-gh-stack-view.md +++ b/packages/sc-gh-stack/commands/sc-gh-stack-view.md @@ -1,7 +1,7 @@ --- 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 +version: 0.1.1 options: - name: --trunk description: Only stacks whose trunk is this branch (e.g. develop, integrate/phase-bc). diff --git a/packages/sc-gh-stack/commands/sc-gh-stack.md b/packages/sc-gh-stack/commands/sc-gh-stack.md index e7c146956..e7b5ac684 100644 --- a/packages/sc-gh-stack/commands/sc-gh-stack.md +++ b/packages/sc-gh-stack/commands/sc-gh-stack.md @@ -1,7 +1,7 @@ --- 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 +version: 0.1.1 options: - name: --status description: Run /sc-gh-stack-view and paste the coherence, mergeability, CI and LANDING table verbatim. diff --git a/packages/sc-gh-stack/manifest.yaml b/packages/sc-gh-stack/manifest.yaml index 5c2a8c1ef..ef6803efc 100644 --- a/packages/sc-gh-stack/manifest.yaml +++ b/packages/sc-gh-stack/manifest.yaml @@ -1,5 +1,5 @@ name: sc-gh-stack -version: 0.1.0 +version: 0.1.1 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 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 index a8dd4eb62..a6a120d1e 100644 --- a/packages/sc-gh-stack/skills/sc-gh-stack-view/SKILL.md +++ b/packages/sc-gh-stack/skills/sc-gh-stack-view/SKILL.md @@ -1,6 +1,6 @@ --- name: sc-gh-stack-view -version: 0.1.0 +version: 0.1.1 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 --- diff --git a/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md b/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md index 66df0fcdb..f9d5e31c4 100644 --- a/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md +++ b/packages/sc-gh-stack/skills/sc-gh-stack/SKILL.md @@ -1,6 +1,6 @@ --- name: sc-gh-stack -version: 0.1.0 +version: 0.1.1 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 --- 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 index c60fdb258..d35f228d8 100644 --- 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 @@ -41,10 +41,11 @@ docs or evidence layer. The layer is a new worktree cut from the current `push --force-with-lease` targets the parent branch. The first push is `git push -u origin <layer>`. - With `sc-git-worktree` installed: `/sc-git-worktree --create <layer> <top>` - after refreshing the local `<top>` ref - (`git fetch origin <top> && git update-ref refs/heads/<top> origin/<top>`), - because that skill branches from the local ref. + With `sc-git-worktree` 0.14.0 or later installed: + `/sc-git-worktree --create-stacked <layer> <top> <trunk>` does exactly this + cut (from `origin/<top>`, `--no-track`), records the parent SHA in tracking + and returns a `stack_handoff` split into writer and stack-writer parts. + Its plain `--create` branches from the local ref and is wrong for a layer. 4. Record the parent SHA the layer was cut from (`git rev-parse origin/<top>`). It goes in the PR body and any ledger; QA diff --git a/packages/sc-git-worktree/CHANGELOG.md b/packages/sc-git-worktree/CHANGELOG.md index 622f0d721..4faef65e4 100644 --- a/packages/sc-git-worktree/CHANGELOG.md +++ b/packages/sc-git-worktree/CHANGELOG.md @@ -10,13 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `sc-worktree-create-stacked` agent and `--create-stacked <layer> <parent> <trunk> [--above <layer>]`: cut a gh-stack layer worktree from the parent's **pushed** head with `--no-track`, validate the cut before any mutation (`STACK.LAYER_EXISTS`, `STACK.PARENT_NOT_PUSHED`, `STACK.PARENT_LANDED`, `STACK.PARENT_OFF_TRUNK`, `STACK.ABOVE_INVALID`), record `parent_sha`, and return a `stack_handoff` block (push, PR base, link or mid-stack insert sequence, writer rules) for the agent that works in the worktree. - Tracking entries carry an optional `stack` object (`trunk`, `parent`, `parent_sha`, `above`, `position`); older rows load unchanged. -- Cleanup and abort refuse to delete a branch that live stack layers were cut from unless git shows it landed in the trunk (`STACK.HAS_CHILDREN`; batch cleanup reports `stack_blocked`). +- Cleanup refuses to delete a branch that live stack layers sit on unless git shows a merge-commit landing into the children's trunk; abort always refuses (`STACK.HAS_CHILDREN`; batch cleanup reports `stack_blocked` and never sweeps a fresh layer with no commits). `STACK.PARENT_HAS_CHILD` refuses an append that would fork the stack. - Scan reports `stack_parent_advanced` and `stack_parent_landed` on layer rows, with recommendations. - `references/stack-layers.md`: the worktree side of the stack model and the writer contract. ### Fixed - Plain create from a remote-only base no longer sets that base as the new branch's upstream (`--no-track`), so `git push` / `--force-with-lease` cannot target the base branch. -- Merge detection now recognises the `+` marker git prints for branches checked out in other worktrees; single-branch cleanup of a merged branch that still has a worktree no longer fails as "unmerged". +- Merge detection now recognises the `+` marker git prints for branches checked out in other worktrees. Scope: single-branch `--cleanup` of a branch that is merged (or has no unique commits) and still has a worktree used to fail as "unmerged" and needed `merged: true`; it now proceeds and deletes the branch locally and on the remote as the agent contract states. Batch mode already behaved this way. ## [0.10.0] - 2026-04-18 diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-abort.md b/packages/sc-git-worktree/agents/sc-git-worktree-abort.md index 8d6e3a217..787aaf077 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-abort.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-abort.md @@ -34,7 +34,7 @@ Abandon a worktree and discard work safely. - **Protected branches:** Remote branch must never be deleted. Remove worktree; local branch may be removed only if explicitly approved for abort. Default is preserve. - If dirty and no approval, stop and report. - For **non-protected branches**: Only delete branches (local/remote) with explicit approval. If remote delete fails because it doesn't exist, note and continue. -- **Stack parents**: with `allow_delete_branch`, a branch that live stack layers were cut from is refused with `STACK.HAS_CHILDREN` before anything is removed. Abort the children first, or abort without `allow_delete_branch` to remove only the worktree. +- **Stack parents**: with `allow_delete_branch`, a branch that live stack layers sit on is always refused with `STACK.HAS_CHILDREN` before anything is removed (a landed parent is cleaned with `--cleanup`, not aborted). Abort the children first, or abort without `allow_delete_branch` to remove only the worktree. The guard reads the tracking file; off with `tracking_enabled: false`. - Always update tracking when enabled. ## Execution diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md b/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md index 1efa5267e..b177fed32 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-cleanup.md @@ -68,7 +68,7 @@ Wrap the script output in `<output_json>` tags with a fenced JSON block. Do not - **Merged + clean**: Auto-cleaned in batch mode - **Dirty**: Reported back, requires explicit `require_clean: false` to force - **Unmerged**: Never auto-deleted. User must merge first or use `--abort` to discard. -- **Stack parents**: A branch that live stack layers were cut from (tracking `stack.parent`) is deleted only when git shows it merged into the trunk. Batch mode lists such branches under `stack_blocked`; single mode with `merged: true` refuses with `STACK.HAS_CHILDREN`. Land the stack (`gh stack merge`) or clean the children first. +- **Stack parents**: A branch that live stack layers sit on (tracking `stack.parent`, or the layer it was inserted under) is deleted only when git shows a merge-commit landing into the children's trunk. Batch mode lists such branches under `stack_blocked`, along with fresh layers that have no commits yet (never swept); single mode refuses with `STACK.HAS_CHILDREN`. Land the stack (`gh stack merge`) or clean the children first; run `--list` if a listed child is already gone. Guards are off with `tracking_enabled: false`. ## Constraints diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md b/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md index 07ced327b..64b7fda6f 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-create-stacked.md @@ -64,9 +64,11 @@ Example input (insert under `sprint-6`): The script returns fenced JSON. Forward it verbatim. On success `data` carries two stack fields in addition to the ordinary create fields: - `stack`: `{trunk, parent, parent_sha, above, position}` (`position` is `top` or `insert`), also stored in the tracking row. -- `stack_handoff`: `pr_base`, `pr_body`, `rules[]`, `commands[]` (ordered, with `<placeholders>` the writer fills from `gh pr create` and `/sc-gh-stack-view`), `chain_check_available`, `reference`. +- `stack_handoff`: `pr_base`, `pr_body`, two role blocks, `chain_check_available`, `reference`. + - `writer`: `rules[]` and `commands[]` for the single agent that works in the worktree (WIP commit, first push with `-u`, the one rebase at task start, no gh stack write commands, no edits to lower layers). + - `stack_writer`: ordered `commands[]` for the one agent that opens PRs and runs gh stack write commands (PR with base = parent, `gh stack checkout` in the new worktree, link on top or the insert sequence: merge-forward by every layer above, unstack, `gh pr edit --base`, chain check, full relink). Angle-bracket placeholders come from `gh pr create` output and `/sc-gh-stack-view`. -The caller pastes `stack_handoff` verbatim into the prompt of whichever agent works in the worktree. It is the writer's contract: first push with `-u`, PR base = parent (never the trunk), stack on the first push, one rebase at task start at most, no edits to lower layers. +The caller pastes `stack_handoff.writer` verbatim into the worker agent's prompt and `stack_handoff.stack_writer` into the stack writer's prompt (or keeps it for itself when it holds that role). Layer writers never receive the stack-writer block. **Success example (top):** ```json @@ -82,14 +84,23 @@ The caller pastes `stack_handoff` verbatim into the prompt of whichever agent wo "stack": {"trunk": "develop", "parent": "sprint-6", "parent_sha": "3f9c...e1", "above": null, "position": "top"}, "stack_handoff": { "pr_base": "sprint-6", - "rules": ["You are the only writer of sprint-7. Never edit, rebase, or force-push any layer below it.", "..."], - "commands": [ - "git -C /path/to/worktrees/sprint-7 push -u origin sprint-7", - "gh pr create --base sprint-6 --head sprint-7 --title \"<title>\" --body \"Parent: sprint-6 @ 3f9c...e1\\nTask: sprint 7\\nFence: <paths this layer may touch>\"", - "gh stack link <stack#> <pr# of sprint-7> # append to an existing stack", - "gh stack link --base develop <bottom-pr#> ... <pr# of sprint-6> <pr# of sprint-7> # first link, or full relink", - "/sc-gh-stack-view" - ] + "writer": { + "rules": ["You are the only writer of sprint-7. Never edit, rebase, or force-push any layer below it.", "..."], + "commands": [ + "git -C /path/to/worktrees/sprint-7 add -A && git -C /path/to/worktrees/sprint-7 commit -m \"wip: sprint 7\" # first WIP commit; a PR needs at least one", + "git -C /path/to/worktrees/sprint-7 push -u origin sprint-7" + ] + }, + "stack_writer": { + "commands": [ + "# after the writer's first push of sprint-7:", + "gh pr create --base sprint-6 --head sprint-7 --title \"<title>\" --body \"$(cat <<'B'\nParent: sprint-6 @ 3f9c...e1\nTask: sprint 7\nFence: <paths this layer may touch>\nB\n)\"", + "cd /path/to/worktrees/sprint-7 && gh stack checkout <stack#> # import tracking into this new worktree; skip only when no stack exists yet", + "gh stack link <stack#> <pr# of sprint-7> # append: only if sprint-6 is the top row in /sc-gh-stack-view", + "gh stack link --base develop <bottom-pr#> ... <pr# of sprint-6> <pr# of sprint-7> # first link, or full relink", + "/sc-gh-stack-view" + ] + } } } } @@ -110,9 +121,13 @@ All stack refusals happen before anything is created; the message says what is w | `STACK.PARENT_LANDED` | Parent already contained in the trunk; cut from the trunk or the current top | Yes | | `STACK.PARENT_OFF_TRUNK` | Parent and trunk share no history; wrong `stack.trunk` | Yes | | `STACK.ABOVE_INVALID` | `above` is not pushed, does not contain the parent, or names the parent/new layer | Yes | -| `BRANCH.NOT_FOUND` | Trunk not on origin | No | +| `STACK.PARENT_HAS_CHILD` | A live layer already sits on the parent (tracking); appending would fork the stack. Cut from that layer, or pass it as `above` to insert | Yes | +| `BRANCH.NOT_FOUND` | Trunk not on origin | Yes | +| `CONFIG.MISSING` | Input failed validation (for example empty `stack.trunk`) | Yes | | `WORKTREE.EXISTS`, `WORKTREE.BRANCH_IN_USE`, `WORKTREE.DIRTY`, `GIT.NOT_REPO`, `GIT.ERROR` | As for `sc-worktree-create` | No | +Fork detection and the cleanup/abort guards read the tracking file; with `tracking_enabled: false` they are off. + ## Constraints - Run the script ONCE - it handles everything diff --git a/packages/sc-git-worktree/agents/sc-git-worktree-scan.md b/packages/sc-git-worktree/agents/sc-git-worktree-scan.md index cbf844530..67b42adda 100644 --- a/packages/sc-git-worktree/agents/sc-git-worktree-scan.md +++ b/packages/sc-git-worktree/agents/sc-git-worktree-scan.md @@ -103,8 +103,8 @@ Wrap the script output in `<output_json>` tags with a fenced JSON block. Do not Rows for gh-stack layers carry `tracking_entry.stack` (`trunk`, `parent`, `parent_sha`, `above`, `position`) and may list: -- `stack_parent_advanced: origin/<parent> <old> -> <new>`: the layer's writer rebases once at task start (`git rebase --onto origin/<parent> <parent_sha> <layer>`), only while the layer has no children. -- `stack_parent_landed: ...`: the parent merged into the trunk or its branch is gone; the PR should now target the trunk (`/sc-gh-stack-view`). +- `stack_parent_advanced: origin/<parent> <old> -> <new>, not contained in <layer>`: the layer no longer contains the parent's pushed head. The layer's writer rebases once at task start (`git rebase --onto origin/<parent> <parent_sha> <layer>`), only while the layer has no children; the issue clears once the layer contains the parent's head. +- `stack_parent_landed: ...`: the parent merged into the trunk or its branch is gone; the PR should now target the trunk (`/sc-gh-stack-view`). Diff the layer against `origin/<layer>` before any reset; never force-push over GitHub's retarget. ## Constraints diff --git a/packages/sc-git-worktree/commands/sc-git-worktree.md b/packages/sc-git-worktree/commands/sc-git-worktree.md index c2d62e2bc..374124e07 100644 --- a/packages/sc-git-worktree/commands/sc-git-worktree.md +++ b/packages/sc-git-worktree/commands/sc-git-worktree.md @@ -112,7 +112,7 @@ MUST invoke `sc-worktree-create-stacked` with `branch`, `base` (= `<parent>`), ` Before invoking, when `sc-gh-stack` is installed, run `/sc-gh-stack-view` so `<parent>` is the pushed top (or, for an insert, so `--above` names the real layer above the parent). Without it, the script still verifies the cut from git. -Render the `<output_json>` summary **and the full `stack_handoff` block verbatim**. Hand that block, unchanged, to whichever agent will work in the worktree; it contains the push, PR-base, link/insert commands and the writer rules. See `skills/sc-git-worktree/references/stack-layers.md`. +Render the `<output_json>` summary **and the full `stack_handoff` block verbatim**. Hand `stack_handoff.writer` unchanged to the agent that will work in the worktree, and `stack_handoff.stack_writer` to the stack writer (the one agent that opens PRs and runs gh stack write commands; never the layer's writer). See `skills/sc-git-worktree/references/stack-layers.md`. ### --update MUST invoke `sc-worktree-update` for protected branches only. Render conflicts or success from `<output_json>`. diff --git a/packages/sc-git-worktree/scripts/envelope.py b/packages/sc-git-worktree/scripts/envelope.py index f9f9ed704..bc006d8e9 100644 --- a/packages/sc-git-worktree/scripts/envelope.py +++ b/packages/sc-git-worktree/scripts/envelope.py @@ -253,4 +253,5 @@ class ErrorCodes: STACK_PARENT_LANDED = "STACK.PARENT_LANDED" STACK_PARENT_OFF_TRUNK = "STACK.PARENT_OFF_TRUNK" STACK_ABOVE_INVALID = "STACK.ABOVE_INVALID" + STACK_PARENT_HAS_CHILD = "STACK.PARENT_HAS_CHILD" STACK_HAS_CHILDREN = "STACK.HAS_CHILDREN" diff --git a/packages/sc-git-worktree/scripts/worktree_abort.py b/packages/sc-git-worktree/scripts/worktree_abort.py index 1291595ea..ca5cd6075 100755 --- a/packages/sc-git-worktree/scripts/worktree_abort.py +++ b/packages/sc-git-worktree/scripts/worktree_abort.py @@ -193,7 +193,7 @@ def abort_worktree_main(input_data: AbortInput) -> Envelope: if input_data.tracking_path else get_default_tracking_path(worktree_base) ) - stack_children = find_stack_children(load_tracking_jsonl(guard_path), input_data.branch) + stack_children = find_stack_children(load_tracking_jsonl(guard_path), input_data.branch, cwd=repo_root) if stack_children: transcript.step_failed( step="stack guard", @@ -208,7 +208,8 @@ def abort_worktree_main(input_data: AbortInput) -> Envelope: recoverable=True, suggested_action=( "Abort or clean up the child layers first, or re-run with allow_delete_branch: false " - "to remove only the worktree and keep the branch" + "to remove only the worktree and keep the branch; a landed parent is cleaned with --cleanup, " + "not aborted; if a child is already gone, run --list to reconcile tracking" ), data={"stack_children": stack_children}, transcript=transcript, diff --git a/packages/sc-git-worktree/scripts/worktree_cleanup.py b/packages/sc-git-worktree/scripts/worktree_cleanup.py index e9476a5c4..178249154 100755 --- a/packages/sc-git-worktree/scripts/worktree_cleanup.py +++ b/packages/sc-git-worktree/scripts/worktree_cleanup.py @@ -32,6 +32,7 @@ delete_remote_branch, find_stack_children, is_landed_by_merge, + rev_parse, get_default_tracking_path, get_protected_branches, get_remote_ahead_count, @@ -58,6 +59,7 @@ delete_remote_branch, find_stack_children, is_landed_by_merge, + rev_parse, get_default_tracking_path, get_protected_branches, get_remote_ahead_count, @@ -162,6 +164,42 @@ def get_all_worktrees(repo_root: Path) -> List[Dict[str, Any]]: # ============================================================================= +def stack_guard( + branch: str, + tracking_entries: List[TrackingEntry], + repo_root: Path, + merge_base: str, +) -> tuple: + """(children, landed): live stack layers sitting on `branch`, and whether git shows + `branch` landed with a merge commit into the children's trunk (origin/<trunk> when it + resolves, else the protected merge base). Cheap: the landing check runs only when + children exist.""" + children = find_stack_children(tracking_entries, branch, cwd=repo_root) + if not children: + return [], False + trunks = { + (e.stack or {}).get("trunk") for e in tracking_entries if e.branch in children and (e.stack or {}).get("trunk") + } + landing_base = merge_base + for trunk in sorted(trunks): + if rev_parse(f"origin/{trunk}", cwd=repo_root): + landing_base = f"origin/{trunk}" + break + return children, is_landed_by_merge(branch, landing_base, cwd=repo_root) + + +def is_merged_by_git(branch: str, merge_base: str, repo_root: Path) -> bool: + """A layer whose head is a merge parent on the base landed; a head parked on the base + line is merely empty.""" + return is_landed_by_merge(branch, merge_base, cwd=repo_root) + + +STACK_GUARD_ACTION = ( + "Land the stack first (gh stack merge) so the merge commit is visible in git, or clean up / abort " + "the child layers first; if a child is already gone, run --list to reconcile tracking" +) + + def cleanup_all_merged(input_data: CleanupInput) -> Envelope: """Clean up all merged worktrees, report dirty/unmerged for follow-up.""" transcript = Transcript() @@ -423,21 +461,29 @@ def cleanup_all_merged(input_data: CleanupInput) -> Envelope: # Stack guard: a layer with live children is deleted only when git shows it # landed with a merge commit (GitHub then retargets the children onto the - # trunk). An empty layer parked on a trunk commit is not landed. - if not is_landed_by_merge(branch, merge_base, cwd=repo_root): - stack_children = find_stack_children(tracking_entries, branch) - if stack_children: - stack_blocked.append({ - "branch": branch, - "path": str(wt_path), - "stack_children": stack_children, - "reason": "stack parent with live child layers and no verifiable landing", - }) - transcript.step_ok( - step="stack guard", - message=f"{branch}: live children {', '.join(stack_children)} - preserved", - ) - continue + # trunk). A fresh layer with no commits yet is never swept either. + stack_children, landed = stack_guard(branch, tracking_entries, repo_root, merge_base) + if stack_children and not landed: + stack_blocked.append({ + "branch": branch, + "path": str(wt_path), + "stack_children": stack_children, + "reason": "stack parent with live child layers and no verifiable landing", + }) + transcript.step_ok( + step="stack guard", + message=f"{branch}: live children {', '.join(stack_children)} - preserved", + ) + continue + if entry.stack and unique_commits == 0 and not is_merged_by_git(branch, merge_base, repo_root): + stack_blocked.append({ + "branch": branch, + "path": str(wt_path), + "stack_children": [], + "reason": "fresh stack layer with no commits yet; push its WIP or abort it explicitly", + }) + transcript.step_ok(step="stack guard", message=f"{branch}: fresh layer, no commits - preserved") + continue # Clean + merged → auto-cleanup transcript.step_ok( @@ -735,18 +781,16 @@ def cleanup_single_branch(input_data: CleanupInput) -> Envelope: # Stack guard: the branch is about to be deleted unless protected. A merge commit # visible in git is safe (GitHub retargets child PRs onto the trunk); a caller # override (`merged: true`) or an empty layer is not. - if ( - not is_protected - and input_data.tracking_enabled - and not is_landed_by_merge(input_data.branch, merge_base, cwd=repo_root) - ): + if not is_protected and input_data.tracking_enabled: guard_path = ( Path(input_data.tracking_path).resolve() if input_data.tracking_path else get_default_tracking_path(worktree_base) ) - stack_children = find_stack_children(load_tracking_jsonl(guard_path), input_data.branch) - if stack_children: + stack_children, landed = stack_guard( + input_data.branch, load_tracking_jsonl(guard_path), repo_root, merge_base + ) + if stack_children and not landed: transcript.step_failed( step="stack guard", error=f"live stack children: {', '.join(stack_children)}", @@ -758,10 +802,7 @@ def cleanup_single_branch(input_data: CleanupInput) -> Envelope: f"{', '.join(stack_children)} and is not verifiably landed; deleting it breaks their PR base" ), recoverable=True, - suggested_action=( - "Land the stack first (gh stack merge) so the merge is visible in git, " - "or clean up / abort the child layers first" - ), + suggested_action=STACK_GUARD_ACTION, data={"stack_children": stack_children}, transcript=transcript, ) diff --git a/packages/sc-git-worktree/scripts/worktree_create.py b/packages/sc-git-worktree/scripts/worktree_create.py index d0612995b..682397421 100755 --- a/packages/sc-git-worktree/scripts/worktree_create.py +++ b/packages/sc-git-worktree/scripts/worktree_create.py @@ -35,6 +35,7 @@ import json import os +import shlex import subprocess import sys from datetime import datetime, timezone @@ -53,10 +54,12 @@ check_branch_exists_remote, check_remote_branch_exists, create_tracking_branch, + find_stack_children, get_default_tracking_path, get_repo_root, get_worktree_status, is_ancestor, + load_tracking_jsonl, rev_parse, run_git, ) @@ -69,10 +72,12 @@ check_branch_exists_remote, check_remote_branch_exists, create_tracking_branch, + find_stack_children, get_default_tracking_path, get_repo_root, get_worktree_status, is_ancestor, + load_tracking_jsonl, rev_parse, run_git, ) @@ -157,6 +162,7 @@ def check_stack_preconditions( *, branch_exists_local: bool, branch_exists_remote: bool, + tracking_entries: Optional[list] = None, ) -> tuple: """Validate a stack cut. Returns (stack_info, None) or (None, error Envelope). @@ -248,6 +254,18 @@ def fail(code: str, message: str, action: str, **data: Any) -> tuple: "Read the real layer above the parent from /sc-gh-stack-view, or omit `above`", ) + if above is None and tracking_entries: + # Appending: the parent must be the top. A live layer already on it means this cut + # would fork the stack (two layers sharing a parent), which cannot be linked linearly. + existing = find_stack_children(tracking_entries, base, cwd=repo_root) + if existing: + return fail( + ErrorCodes.STACK_PARENT_HAS_CHILD, + f"'{base}' already has live layer(s) on it: {', '.join(existing)}; cutting another from it would fork the stack", + f"Cut from the current top ({existing[-1]}) instead, or pass stack.above = '{existing[-1]}' to insert under it", + existing_children=existing, + ) + local_base_sha = rev_parse(base, cwd=repo_root) if local_base_sha and local_base_sha != parent_sha: transcript.step_ok( @@ -278,11 +296,12 @@ def build_stack_handoff( stack_info: Dict[str, Any], repo_root: Path, ) -> Dict[str, Any]: - """The information a worker needs to stack this layer successfully. + """The information needed to stack this layer, split by role. - Everything here is derived from the gh-stack field playbook (sc-gh-stack). - Placeholders in angle brackets are values the worker reads from - /sc-gh-stack-view or from `gh pr create` output. + `writer` is for the single agent that works in the worktree; `stack_writer` is for + the one agent that runs gh stack write commands and opens PRs (never the layer's + writer). Everything derives from the gh-stack field playbook (sc-gh-stack). + Angle-bracket placeholders are read from /sc-gh-stack-view or gh output. """ trunk = stack_info["trunk"] parent = stack_info["parent"] @@ -290,52 +309,65 @@ def build_stack_handoff( above = stack_info.get("above") chain_check = repo_root / ".claude" / "scripts" / "gh_stack_chain_check.py" chain_check_available = chain_check.exists() + wt = shlex.quote(str(worktree_path)) + bottom = parent == trunk # Layers below the parent, as PR-number and branch-name placeholders. A layer cut # from the trunk is the stack bottom: nothing sits below it. - lower_prs = "" if parent == trunk else f"<bottom-pr#> ... <pr# of {parent}> " - lower_names = "" if parent == trunk else f"<bottom> ... {parent} " - - pr_body = f"Parent: {parent} @ {sha}\nTask: {purpose}\nFence: <paths this layer may touch>" + lower_prs = "" if bottom else f"<bottom-pr#> ... <pr# of {parent}> " + lower_names = "" if bottom else f"<bottom> ... {parent} " - rules = [ + pr_base_rule = f"The PR base is {parent}" + ("" if bottom else f", never {trunk}") + "." + writer_rules = [ f"You are the only writer of {branch}. Never edit, rebase, or force-push any layer below it.", - f"Push a WIP commit within minutes, then open the PR with base {parent} (never {trunk}).", - f"Stack on the first push, never 'once it is green'. Pass PR numbers to gh stack link, not branch names.", + f"Make a WIP commit within minutes and push it; the stack writer opens the PR and links it on that first push, never 'once it is green'. {pr_base_rule}", ( f"Rebase at most once per task, at the start, only while this layer has no children: " - f"git fetch origin && git rebase --onto origin/{parent} {sha} {branch} && git push --force-with-lease; " - f"then record the new parent SHA." + f"git fetch origin && git rebase --onto origin/{parent} {sha} {branch} && git push --force-with-lease. " + f"Between tasks the layer does not move." ), + f"Never run gh stack write commands (link, unstack, sync, rebase, merge) or open PRs from this worktree; report to the stack writer.", f"The next layer is cut from origin/{branch} after it is pushed, never from a local ref.", ] + writer_commands = [ + f"git -C {wt} commit --allow-empty -m {shlex.quote('wip: ' + purpose[:60])} # first WIP commit now; a PR needs at least one", + f"git -C {wt} push -u origin {branch}", + ] - commands = [ - f"git -C {worktree_path} push -u origin {branch}", - f'gh pr create --base {parent} --head {branch} --title "<title>" --body "{pr_body}"', + pr_body = f"Parent: {parent} @ {sha}\nTask: {purpose}\nFence: <paths this layer may touch>" + pr_create = ( + f"gh pr create --base {parent} --head {branch} --title \"<title>\" --body \"$(cat <<'B'\n" + f"Parent: {parent} @ {sha}\nTask: {purpose}\nFence: <paths this layer may touch>\nB\n)\"" + ) + stack_writer_commands = [ + f"# after the writer's first push of {branch}:", + pr_create, + f"cd {wt} && gh stack checkout <stack#> # import tracking into this new worktree; skip only when no stack exists yet", ] if above: - commands += [ - f"# writer of {above} carries this layer forward with ONE merge commit (never a rebase, never a force-push):", + stack_writer_commands += [ + f"# every layer above {parent}, bottom to top, carries the layer below it forward with ONE merge commit each", + f"# (never a rebase, never a force-push), starting with the writer of {above}:", f"git -C <worktree of {above}> fetch origin && git -C <worktree of {above}> merge --no-ff origin/{branch} && git -C <worktree of {above}> push", + f"# then the layer above {above} merges origin/{above}, and so on up to the top", "gh stack unstack <stack#> # PRs and branches untouched; confirm with the user first", f"gh pr edit <pr# of {above}> --base {branch}", ] if chain_check_available: - commands.append( + stack_writer_commands.append( f"python3 .claude/scripts/gh_stack_chain_check.py --trunk {trunk} {lower_names}{branch} {above} ... <top> # must print LINKABLE" ) - commands += [ + stack_writer_commands += [ f"gh stack link --base {trunk} {lower_prs}<pr# of {branch}> <pr# of {above}> ... <top-pr#>", "# every other stack worktree: gh stack unstack --local && gh stack checkout <new stack#>", "/sc-gh-stack-view", ] else: if chain_check_available: - commands.append( + stack_writer_commands.append( f"python3 .claude/scripts/gh_stack_chain_check.py --trunk {trunk} {lower_names}{branch} # must print LINKABLE" ) - commands += [ - f"gh stack link <stack#> <pr# of {branch}> # append to an existing stack", + stack_writer_commands += [ + f"gh stack link <stack#> <pr# of {branch}> # append: only if {parent} is the top row in /sc-gh-stack-view", f"gh stack link --base {trunk} {lower_prs}<pr# of {branch}> # first link, or full relink", "/sc-gh-stack-view", ] @@ -348,8 +380,15 @@ def build_stack_handoff( "above": above, "pr_base": parent, "pr_body": pr_body, - "rules": rules, - "commands": commands, + "writer": { + "audience": f"the single agent working in {worktree_path}", + "rules": writer_rules, + "commands": writer_commands, + }, + "stack_writer": { + "audience": "the one agent that opens PRs and runs gh stack write commands (not the layer's writer)", + "commands": stack_writer_commands, + }, "chain_check_available": chain_check_available, "reference": ( "sc-gh-stack references/recipe-restack.md section 1 (insert)" @@ -498,6 +537,7 @@ def create_worktree_main(input_data: CreateInput) -> Envelope: transcript, branch_exists_local=branch_exists_local, branch_exists_remote=branch_exists_remote, + tracking_entries=load_tracking_jsonl(tracking_path) if tracking_path else None, ) if stack_error is not None: return stack_error diff --git a/packages/sc-git-worktree/scripts/worktree_scan.py b/packages/sc-git-worktree/scripts/worktree_scan.py index 244440bd6..8846924f2 100644 --- a/packages/sc-git-worktree/scripts/worktree_scan.py +++ b/packages/sc-git-worktree/scripts/worktree_scan.py @@ -259,8 +259,12 @@ def batch_get_worktree_statuses(worktrees: List[WorktreeInfo]) -> Dict[str, Tupl # ============================================================================= -def stack_issues(stack_meta: Optional[Dict[str, Any]], repo_root: Path) -> List[str]: - """Issues for a stack layer: parent landed (gone or merged into trunk) or parent advanced.""" +def stack_issues(stack_meta: Optional[Dict[str, Any]], repo_root: Path, layer: Optional[str] = None) -> List[str]: + """Issues for a stack layer: parent landed (gone or merged into trunk) or parent advanced. + + "Advanced" means the layer no longer contains the parent's pushed head; it clears by + itself after the writer's one rebase or a merge-forward, without touching tracking. + """ if not stack_meta: return [] parent = stack_meta.get("parent") @@ -276,8 +280,10 @@ def stack_issues(stack_meta: Optional[Dict[str, Any]], repo_root: Path) -> List[ return [f"stack_parent_landed: origin/{parent} no longer exists"] if is_ancestor(f"origin/{parent}", f"origin/{trunk}", cwd=repo_root): return [f"stack_parent_landed: {parent} is contained in {trunk}"] - if recorded and current != recorded: - return [f"stack_parent_advanced: origin/{parent} {recorded[:8]} -> {current[:8]}"] + if layer: + head_ref = f"origin/{layer}" if rev_parse(f"origin/{layer}", cwd=repo_root) else layer + if not is_ancestor(f"origin/{parent}", head_ref, cwd=repo_root): + return [f"stack_parent_advanced: origin/{parent} {recorded[:8]} -> {current[:8]}, not contained in {head_ref}"] return [] @@ -499,7 +505,7 @@ def scan_worktrees( issues.append(f"prunable: {wt.prunable_reason or 'worktree may be stale'}") if remote_ahead > 0: issues.append(f"remote_ahead: {remote_ahead} commit(s)") - issues.extend(stack_issues(stack_meta, repo_root)) + issues.extend(stack_issues(stack_meta, repo_root, layer=wt.branch)) worktree_results.append({ "branch": wt.branch, @@ -550,7 +556,8 @@ def scan_worktrees( ) if parent_landed: recommendations.append( - f"{parent_landed} stack layer(s) whose parent landed: confirm the PR now targets the trunk (/sc-gh-stack-view)" + f"{parent_landed} stack layer(s) whose parent landed: confirm the PR now targets the trunk (/sc-gh-stack-view); " + f"before touching the layer, git diff --stat HEAD origin/<layer> and reset only if empty; never force-push over GitHub's retarget" ) return Envelope.success_response( diff --git a/packages/sc-git-worktree/scripts/worktree_shared.py b/packages/sc-git-worktree/scripts/worktree_shared.py index c896ee6af..4d179f1a3 100644 --- a/packages/sc-git-worktree/scripts/worktree_shared.py +++ b/packages/sc-git-worktree/scripts/worktree_shared.py @@ -443,7 +443,11 @@ def is_landed_by_merge(branch: str, base: str, cwd: Optional[Path] = None, limit result = run_git(["rev-list", "--first-parent", f"-n{limit}", base], cwd=cwd, check=False) if result.returncode != 0: return False - return head not in result.stdout.split() + line = result.stdout.split() + if head in line: + return False + # Window exhausted without seeing the head: cannot tell, fail closed + return len(line) < limit def check_branch_exists_local(branch: str, cwd: Optional[Path] = None) -> bool: @@ -863,19 +867,40 @@ class TrackingEntry(BaseModel): stack: Optional[Dict[str, Any]] = Field(None, description="Stack layer metadata when the worktree is a gh-stack layer") -def find_stack_children(entries: list["TrackingEntry"], branch: str) -> list[str]: - """Return branches recorded as stack layers cut from `branch` that are still live. +def _entry_is_live(entry: "TrackingEntry", cwd: Optional[Path] = None) -> bool: + """Liveness checked against git, not the stored flags (which only scan refreshes).""" + if entry.local_worktree and Path(entry.path).exists(): + return True + return check_remote_branch_exists(entry.branch, cwd=cwd) + + +def find_stack_children(entries: list["TrackingEntry"], branch: str, cwd: Optional[Path] = None) -> list[str]: + """Return live stack layers that sit on `branch`: layers cut from it, plus the layer + `branch` was inserted under (its own row's `stack.above`). - A child is live while it has a local worktree or a remote branch. Deleting a + A child is live while its worktree directory or its remote branch exists. Deleting a parent under a live child breaks the stack chain (the child PR loses its base). """ - children = [] + by_name = {e.branch: e for e in entries} + candidates: list[str] = [] for entry in entries: stack = entry.stack or {} - if stack.get("parent") != branch: + if stack.get("parent") == branch and entry.branch not in candidates: + candidates.append(entry.branch) + own = by_name.get(branch) + above = (own.stack or {}).get("above") if own else None + if above and above not in candidates: + candidates.append(above) + children = [] + for name in candidates: + entry = by_name.get(name) + if entry is None: + # Inserted-under layer without a tracking row: assume live (fail closed) + if check_remote_branch_exists(name, cwd=cwd) or run_git(["rev-parse", "--verify", "--quiet", name], cwd=cwd, check=False).returncode == 0: + children.append(name) continue - if entry.local_worktree or entry.remote_exists: - children.append(entry.branch) + if _entry_is_live(entry, cwd=cwd): + children.append(name) return children diff --git a/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md b/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md index c037e52a6..4d61a90a2 100644 --- a/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md +++ b/packages/sc-git-worktree/skills/sc-git-worktree/SKILL.md @@ -52,9 +52,9 @@ To invoke an agent, use the Task tool with the agent prompt and pass parameters A stack layer is a worktree whose branch will be a PR in a `gh stack`. The plain create is wrong for it: it branches from the **local** base ref (possibly stale or another writer's unpushed state) and, from a remote ref, would leave the parent as upstream. `sc-worktree-create-stacked` cuts from `origin/<parent>` with `--no-track`, refuses bad cuts before touching anything (parent not pushed, already landed, insert target not stacked on the parent), records the parent SHA in tracking, and returns a `stack_handoff` block. -- Pass `stack_handoff` verbatim to the agent that will work in the worktree. It is the writer's contract: first push with `-u`, PR base = parent, link on the first push, at most one rebase at task start, no edits to lower layers, and for an insert the merge-forward / unstack / `gh pr edit --base` / full relink sequence. -- Cleanup and abort refuse to delete a branch that live layers were cut from unless git shows it landed in the trunk (`STACK.HAS_CHILDREN`); batch cleanup reports such branches under `stack_blocked`. -- Scan reports `stack_parent_advanced` (rebase once at task start) and `stack_parent_landed` (PR should now target the trunk) on layer rows. +- `stack_handoff.writer` goes verbatim to the agent that will work in the worktree (WIP commit, first push with `-u`, at most one rebase at task start, no edits to lower layers, no gh stack write commands). `stack_handoff.stack_writer` goes to the stack writer only (PR with base = parent, `gh stack checkout` in the new worktree, link on top, or the insert sequence: merge-forward by every layer above, unstack, `gh pr edit --base`, full relink). +- Cleanup refuses to delete a branch that live layers sit on unless git shows a merge-commit landing in the trunk (`STACK.HAS_CHILDREN`; batch cleanup reports `stack_blocked`, and never sweeps a fresh layer with no commits). Abort always refuses. Both read the tracking file. +- Scan reports `stack_parent_advanced` (layer no longer contains the parent's head) and `stack_parent_landed` (PR should now target the trunk) on layer rows. Details and the handoff contract: `references/stack-layers.md`. The stack model, recipes and the view tool live in the `sc-gh-stack` package (`/sc-gh-stack`, `/sc-gh-stack-view`); this skill only makes the worktree side of that model safe. diff --git a/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md b/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md index da9e0bb2e..c2a292867 100644 --- a/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md +++ b/packages/sc-git-worktree/skills/sc-git-worktree/references/stack-layers.md @@ -35,33 +35,44 @@ All refusals happen before any worktree or branch is created and return a {"trunk": "develop", "parent": "sprint-6", "parent_sha": "<40-char sha>", "above": null, "position": "top"} ``` -`data.stack_handoff` is for the agent that will work in the worktree. Paste it -into that agent's prompt unchanged. Its `commands[]` are ordered; angle-bracket -placeholders are filled from `gh pr create` output and `/sc-gh-stack-view`. +`data.stack_handoff` has two role blocks. Paste `writer` into the prompt of the +agent that will work in the worktree and `stack_writer` into the stack writer's +prompt (the one agent that opens PRs and runs gh stack write commands; layer +writers never do). `commands[]` are ordered; angle-bracket placeholders are +filled from `gh pr create` output and `/sc-gh-stack-view`. -## Writer contract (what the handoff says) +## Writer contract (`stack_handoff.writer`) 1. You are the only writer of the layer. Never edit, rebase or force-push any layer below it. -2. Push a WIP commit within minutes: `git push -u origin <layer>`. Then open the PR with `--base <parent>` (never the trunk unless the parent *is* the trunk). The PR body records `Parent: <parent> @ <parent_sha>`. -3. Stack on the first push, never "once it is green": `gh stack link <stack#> <pr#>` to append, or the full `gh stack link --base <trunk> <ordered pr#s>`. PR numbers, not branch names. -4. Rebase at most once per task, at the start, only while the layer has no children: `git rebase --onto origin/<parent> <parent_sha> <layer>` then `git push --force-with-lease`; record the new parent SHA. +2. Make a WIP commit within minutes and push it: `git push -u origin <layer>`. A PR needs at least one commit. The stack writer opens the PR with `--base <parent>` (the trunk only when the parent *is* the trunk) and links it on that first push, never "once it is green". +3. Rebase at most once per task, at the start, only while the layer has no children: `git rebase --onto origin/<parent> <parent_sha> <layer>` then `git push --force-with-lease`. Between tasks the layer does not move. +4. Never run gh stack write commands (link, unstack, sync, rebase, merge) or open PRs from the worktree; report to the stack writer. 5. The next layer is cut from `origin/<layer>` once it is pushed, never from a local ref. -## Insert mid-stack (`position: insert`) +## Stack writer sequence (`stack_writer` block) -`gh stack link` cannot insert. The handoff adds, in order: +Append on top (`position: top`; the parent must be the top row of `/sc-gh-stack-view`, otherwise the create refuses with `STACK.PARENT_HAS_CHILD`): -1. Push and open the PR with base `<parent>` as above. -2. The writer of `<above>` carries the new layer forward with **one** merge commit (`git merge --no-ff origin/<layer>`), never a rebase; otherwise the stack is non-linear and cannot land. +1. After the writer's first push: `gh pr create --base <parent> --head <layer>` with the body `Parent: <parent> @ <parent_sha>` (quoted heredoc; the handoff renders it). +2. `cd <worktree> && gh stack checkout <stack#>` so the new worktree carries tracking before any write command; skip only when no stack exists yet. +3. Chain check when `sc-gh-stack` is installed, then `gh stack link <stack#> <pr#>` to append, or the full `gh stack link --base <trunk> <ordered pr#s>`. PR numbers, not branch names. +4. `/sc-gh-stack-view`. + +Insert mid-stack (`position: insert`; `gh stack link` cannot insert): + +1. PR with base `<parent>` and `gh stack checkout` as above. +2. **Every** layer above the insertion point, bottom to top, carries the layer below it forward with one merge commit each (`git merge --no-ff origin/<layer-below>` then push), starting with the writer of `<above>` merging the new layer. Never a rebase, never a force-push; otherwise some layer stops containing its parent and the stack cannot land. 3. `gh stack unstack <stack#>` (PRs and branches untouched; confirm with the user, it is outward-facing). 4. `gh pr edit <pr# of above> --base <layer>`. -5. Chain check (`gh_stack_chain_check.py`, present when `sc-gh-stack` is installed), then the full `gh stack link --base <trunk> ...` with the new layer in place. +5. Chain check, then the full `gh stack link --base <trunk> ...` with the new layer in place. 6. Every other stack worktree: `gh stack unstack --local && gh stack checkout <new stack#>`; then `/sc-gh-stack-view`. ## What the other operations do with layers -- **Cleanup / abort**: a branch with live child layers in tracking (child has a worktree or a remote branch) is never deleted unless git shows the branch merged into the trunk. Error `STACK.HAS_CHILDREN` names the children; batch cleanup lists them under `stack_blocked`. A landed parent is safe to delete: GitHub retargets the children onto the trunk. -- **Scan**: layer rows carry `tracking_entry.stack` and may report `stack_parent_advanced: origin/<parent> <old> -> <new>` (the writer's one rebase applies) or `stack_parent_landed` (the PR should now target the trunk; confirm with `/sc-gh-stack-view`). The trunk moving under a bottom layer is not reported; it is expected. +- **Cleanup**: a branch with live child layers (layers cut from it, or the layer it was inserted under; live = worktree directory or remote branch exists) is deleted only when git shows it landed with a merge commit into the children's trunk (`origin/<trunk>`). Error `STACK.HAS_CHILDREN` names the children; batch cleanup lists them under `stack_blocked`, together with fresh layers that have no commits yet (never swept). A landed parent is safe to delete: GitHub retargets the children onto the trunk. A fast-forward landing is indistinguishable from an empty branch and fails closed. +- **Abort**: with `allow_delete_branch`, a branch with live children is always refused (`STACK.HAS_CHILDREN`); a landed parent is cleaned with `--cleanup`, not aborted. +- Both guards read the tracking file: with `tracking_enabled: false` they are off. If a child is already gone but still listed, run `--list` to reconcile. +- **Scan**: layer rows carry `tracking_entry.stack` and may report `stack_parent_advanced` (the layer no longer contains the parent's pushed head; the writer's one rebase or a merge-forward clears it) or `stack_parent_landed` (the PR should now target the trunk; confirm with `/sc-gh-stack-view`, diff before any reset, never force-push over GitHub's retarget). The trunk moving under a bottom layer is not reported; it is expected. - **Update**: unchanged; trunks are protected branches and are updated with `--update`. ## Not done here diff --git a/packages/sc-git-worktree/tests/__init__.py b/packages/sc-git-worktree/tests/__init__.py deleted file mode 100644 index 6a8b7b558..000000000 --- a/packages/sc-git-worktree/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# sc-git-worktree tests diff --git a/packages/sc-git-worktree/tests/test_stack_aware.py b/packages/sc-git-worktree/tests/test_stack_aware.py index 123b132da..c71e61116 100644 --- a/packages/sc-git-worktree/tests/test_stack_aware.py +++ b/packages/sc-git-worktree/tests/test_stack_aware.py @@ -108,12 +108,19 @@ def test_cut_from_pushed_parent_without_upstream(self, stack_repo): } handoff = data["stack_handoff"] assert handoff["pr_base"] == "l1" - assert handoff["commands"][0] == f"git -C {wt} push -u origin l2" - assert "--base l1 --head l2" in handoff["commands"][1] - assert any(cmd.startswith("gh stack link <stack#> <pr# of l2>") for cmd in handoff["commands"]) - assert any("gh stack link --base develop <bottom-pr#> ... <pr# of l1> <pr# of l2>" in c for c in handoff["commands"]) - assert handoff["commands"][-1] == "/sc-gh-stack-view" - assert any("rebase --onto origin/l1" in rule for rule in handoff["rules"]) + writer, stack_writer = handoff["writer"]["commands"], handoff["stack_writer"]["commands"] + assert writer[0].startswith(f"git -C {wt} commit --allow-empty -m 'wip: work on l2'") + assert writer[1] == f"git -C {wt} push -u origin l2" + assert not any("gh " in c for c in writer), "writers never get gh commands" + assert any("gh pr create --base l1 --head l2" in c and "<<'B'" in c for c in stack_writer) + assert any(c.startswith(f"cd {wt} && gh stack checkout <stack#>") for c in stack_writer) + assert any(c.startswith("gh stack link <stack#> <pr# of l2>") for c in stack_writer) + assert any("gh stack link --base develop <bottom-pr#> ... <pr# of l1> <pr# of l2>" in c for c in stack_writer) + assert stack_writer[-1] == "/sc-gh-stack-view" + rules = handoff["writer"]["rules"] + assert any("rebase --onto origin/l1" in r for r in rules) + assert any("Never run gh stack write commands" in r for r in rules) + assert any("The PR base is l1, never develop." in r for r in rules) # Tracking row carries the stack metadata entries = load_tracking_jsonl(tracking_path(stack_repo)) assert entries[0].stack["parent_sha"] == data["stack"]["parent_sha"] @@ -121,17 +128,21 @@ def test_cut_from_pushed_parent_without_upstream(self, stack_repo): def test_bottom_layer_handoff_has_nothing_below(self, stack_repo): result = create(stack_repo, "l0", "develop", stack={"trunk": "develop"}) assert result["success"], result - cmds = result["data"]["stack_handoff"]["commands"] + handoff = result["data"]["stack_handoff"] + cmds = handoff["stack_writer"]["commands"] assert "gh stack link --base develop <pr# of l0> # first link, or full relink" in cmds assert not any("pr# of develop" in c for c in cmds) + assert any("The PR base is develop." in r for r in handoff["writer"]["rules"]) + assert not any("never develop" in r for r in handoff["writer"]["rules"]) def test_insert_handoff(self, stack_repo): result = create(stack_repo, "l1b", "develop", stack={"trunk": "develop", "above": "l1"}) assert result["success"], result data = result["data"] assert data["stack"]["position"] == "insert" - cmds = data["stack_handoff"]["commands"] + cmds = data["stack_handoff"]["stack_writer"]["commands"] assert any("merge --no-ff origin/l1b" in c for c in cmds) + assert any("every layer above develop, bottom to top" in c for c in cmds) assert "gh pr edit <pr# of l1> --base l1b" in cmds assert any(c.startswith("gh stack unstack <stack#>") for c in cmds) assert any("gh stack link --base develop <pr# of l1b> <pr# of l1> ... <top-pr#>" in c for c in cmds) @@ -140,13 +151,18 @@ def test_insert_handoff(self, stack_repo): def test_chain_check_command_only_when_installed(self, stack_repo): result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) assert not result["data"]["stack_handoff"]["chain_check_available"] - assert not any("gh_stack_chain_check" in c for c in result["data"]["stack_handoff"]["commands"]) + assert not any("gh_stack_chain_check" in c for c in result["data"]["stack_handoff"]["stack_writer"]["commands"]) (stack_repo / ".claude" / "scripts").mkdir(parents=True) (stack_repo / ".claude" / "scripts" / "gh_stack_chain_check.py").write_text("# stub\n") - result = create(stack_repo, "l3", "l1", stack={"trunk": "develop"}) + git("push", "-q", "origin", "l2", cwd=stack_repo) # l2 is now the top; l3 goes on it + result = create(stack_repo, "l3", "l2", stack={"trunk": "develop"}) handoff = result["data"]["stack_handoff"] assert handoff["chain_check_available"] - assert any("gh_stack_chain_check.py --trunk develop <bottom> ... l1 l3" in c for c in handoff["commands"]) + assert any("gh_stack_chain_check.py --trunk develop <bottom> ... l2 l3" in c for c in handoff["stack_writer"]["commands"]) + # insert mode also carries the chain check, with the new layer in place + git("push", "-q", "origin", "l1:l1x", cwd=stack_repo) + result = create(stack_repo, "l1y", "develop", stack={"trunk": "develop", "above": "l1x"}) + assert any("gh_stack_chain_check.py --trunk develop l1y l1x ... <top>" in c for c in result["data"]["stack_handoff"]["stack_writer"]["commands"]) def test_parent_behind_trunk_is_a_note_not_a_block(self, stack_repo): # Trunk moves on after l1 was cut: allowed, recorded in the transcript @@ -201,6 +217,35 @@ def test_above_cannot_be_parent_or_self(self, stack_repo): result = create(stack_repo, "l2", "l1", stack={"trunk": "develop", "above": "l1"}) assert result["error"]["code"] == "STACK.ABOVE_INVALID" + def test_append_onto_parent_with_live_child_is_a_fork(self, stack_repo): + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + result = create(stack_repo, "l2b", "l1", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_HAS_CHILD" + assert "Cut from the current top (l2)" in result["error"]["suggested_action"] + assert result["data"]["existing_children"] == ["l2"] + # the same cut as an explicit insert is allowed + git("push", "-q", "origin", "l2", cwd=stack_repo) + ok = create(stack_repo, "l2c", "l1", stack={"trunk": "develop", "above": "l2"}) + assert ok["success"], ok + + def test_parent_off_trunk(self, stack_repo): + # an orphan branch shares no history with the trunk + git("checkout", "-q", "--orphan", "orphan", cwd=stack_repo) + git("commit", "-q", "--allow-empty", "-m", "orphan", cwd=stack_repo) + git("push", "-q", "origin", "orphan", cwd=stack_repo) + git("checkout", "-q", "develop", cwd=stack_repo) + result = create(stack_repo, "l2", "orphan", stack={"trunk": "develop"}) + assert result["error"]["code"] == "STACK.PARENT_OFF_TRUNK" + + def test_purpose_with_quotes_is_shell_safe(self, stack_repo): + result = create(stack_repo, "l2", "l1", stack={"trunk": "develop"}, purpose='fix "it" $(now) `x`') + assert result["success"], result + handoff = result["data"]["stack_handoff"] + wip = handoff["writer"]["commands"][0] + assert "commit --allow-empty -m 'wip: fix \"it\" $(now) `x`'" in wip + pr = next(c for c in handoff["stack_writer"]["commands"] if c.startswith("gh pr create")) + assert "<<'B'" in pr and 'Task: fix "it" $(now) `x`' in pr + def test_empty_trunk_rejected_at_input(self, stack_repo): result = create(stack_repo, "l2", "l1", stack={"trunk": " "}) assert not result["success"] @@ -237,46 +282,34 @@ def test_entry_without_stack_loads(self, tmp_path): entries = load_tracking_jsonl(p) assert entries[0].stack is None - def test_find_stack_children_only_live(self): - def entry(branch, parent=None, local=True, remote=False): + def test_find_stack_children_only_live(self, stack_repo): + """Liveness is checked against git and the filesystem, not the stored flags.""" + wt_base = stack_repo.parent / "repo-worktrees" + (wt_base / "l2").mkdir(parents=True) + (wt_base / "l4").mkdir(parents=True) + git("push", "-q", "origin", "develop:l3", cwd=stack_repo) # l3 exists only on origin + + def entry(branch, parent=None, local=True, remote=False, above=None): return TrackingEntry( - branch=branch, path="/p", base=parent or "main", owner="o", + branch=branch, path=str(wt_base / branch), base=parent or "main", owner="o", created="2024-01-01T00:00:00Z", last_checked="2024-01-01T00:00:00Z", local_worktree=local, remote_exists=remote, - stack={"trunk": "main", "parent": parent, "parent_sha": "x", "above": None, "position": "top"} if parent else None, + stack={"trunk": "develop", "parent": parent, "parent_sha": "x", "above": above, "position": "top"} + if parent else None, ) entries = [ entry("l1"), entry("l2", parent="l1"), entry("l3", parent="l1", local=False, remote=True), - entry("gone", parent="l1", local=False, remote=False), + entry("stale-flag", parent="l1", local=True, remote=True), # flags say live, git says gone entry("l4", parent="l2"), + entry("ins", parent="develop", above="l2"), # inserted under l2 ] - assert find_stack_children(entries, "l1") == ["l2", "l3"] - assert find_stack_children(entries, "l2") == ["l4"] - assert find_stack_children(entries, "l4") == [] - - -class TestLandedByMerge: - def test_empty_branch_on_trunk_is_not_landed(self, stack_repo): - git("branch", "-q", "empty", "develop", cwd=stack_repo) - assert not is_landed_by_merge("empty", "develop", cwd=stack_repo) - - def test_unmerged_layer_is_not_landed(self, stack_repo): - assert not is_landed_by_merge("l1", "develop", cwd=stack_repo) - - def test_merge_commit_landing_is_landed(self, stack_repo): - git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=stack_repo) - assert is_landed_by_merge("l1", "develop", cwd=stack_repo) - - def test_fast_forward_is_not_landed(self, stack_repo): - # A fast-forward puts the layer head on the trunk's first-parent line: indistinguishable - # from an empty branch, so it is treated as not landed (fails closed). - git("merge", "-q", "--ff-only", "l1", cwd=stack_repo) - assert not is_landed_by_merge("l1", "develop", cwd=stack_repo) - - def test_unknown_branch(self, stack_repo): - assert not is_landed_by_merge("ghost", "develop", cwd=stack_repo) + assert find_stack_children(entries, "l1", cwd=stack_repo) == ["l2", "l3"] + assert find_stack_children(entries, "l2", cwd=stack_repo) == ["l4"] + assert find_stack_children(entries, "l4", cwd=stack_repo) == [] + # the inserted layer's own row names the layer above it as a child + assert find_stack_children(entries, "ins", cwd=stack_repo) == ["l2"] # ============================================================================= @@ -297,7 +330,7 @@ def test_abort_refuses_to_delete_parent(self, layered): "worktree_abort.py", {"branch": "l1", "allow_delete_branch": True, "repo_root": str(layered)}, cwd=layered ) assert result["error"]["code"] == "STACK.HAS_CHILDREN" - assert result["error"]["data"]["stack_children"] == ["l2"] if "data" in result["error"] else True + assert result["data"]["stack_children"] == ["l2"] assert (layered.parent / "repo-worktrees" / "l1").exists(), "nothing may be mutated before the guard" assert git("rev-parse", "--verify", "l1", cwd=layered) @@ -314,6 +347,60 @@ def test_cleanup_override_refused(self, layered): assert "gh stack merge" in result["error"]["suggested_action"] assert (layered.parent / "repo-worktrees" / "l1").exists() + def test_abort_refuses_even_when_parent_landed(self, layered): + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + result = run_script( + "worktree_abort.py", {"branch": "l1", "allow_delete_branch": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["error"]["code"] == "STACK.HAS_CHILDREN" + assert "--cleanup" in result["error"]["suggested_action"] + + def test_inserted_layer_is_guarded_by_its_above(self, stack_repo): + # l1b inserted under l1: l1's row still says parent=develop, but l1 now sits on l1b + create(stack_repo, "l1b", "develop", stack={"trunk": "develop", "above": "l1"}) + wt = stack_repo.parent / "repo-worktrees" / "l1b" + git("commit", "-q", "--allow-empty", "-m", "wip", cwd=wt) + git("push", "-q", "-u", "origin", "l1b", cwd=wt) + result = run_script( + "worktree_abort.py", {"branch": "l1b", "allow_delete_branch": True, "repo_root": str(stack_repo)}, cwd=stack_repo + ) + assert result["error"]["code"] == "STACK.HAS_CHILDREN" + assert result["data"]["stack_children"] == ["l1"] + + def test_gone_child_no_longer_blocks(self, layered): + # l2's worktree removed outside the skill and never pushed: not live any more + import shutil + shutil.rmtree(layered.parent / "repo-worktrees" / "l2") + git("worktree", "prune", cwd=layered) + git("branch", "-q", "-D", "l2", cwd=layered) + result = run_script( + "worktree_abort.py", {"branch": "l1", "allow_delete_branch": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["success"], result + + def test_guard_off_when_tracking_disabled(self, layered): + result = run_script( + "worktree_abort.py", + {"branch": "l1", "allow_delete_branch": True, "tracking_enabled": False, "repo_root": str(layered)}, + cwd=layered, + ) + assert result["success"], result + + def test_landing_checked_against_children_trunk(self, layered): + # protected list [main, develop] with the stack trunk develop: the landing must be + # judged against origin/develop, not the first protected branch + git("branch", "-q", "main", "develop", cwd=layered) + git("push", "-q", "origin", "main", cwd=layered) + (layered / ".sc" / "shared-settings.yaml").write_text("git:\n protected_branches:\n - main\n - develop\n") + git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) + git("push", "-q", "origin", "develop", cwd=layered) + result = run_script( + "worktree_cleanup.py", {"branch": "l1", "merged": True, "repo_root": str(layered)}, cwd=layered + ) + assert result["success"], result + assert result["data"]["branch_deleted_local"] is True + def test_cleanup_after_real_landing_is_allowed(self, layered): git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) git("push", "-q", "origin", "develop", cwd=layered) @@ -333,7 +420,9 @@ def test_batch_cleanup_blocks_fast_forwarded_parent(self, layered): assert "l1" in blocked and blocked["l1"]["stack_children"] == ["l2"] assert "l1" not in {c["branch"] for c in result["data"]["cleaned"]} assert git("rev-parse", "--verify", "l1", cwd=layered) - assert result["data"]["summary"]["stack_blocked"] == 1 + # l2 is also listed: a fresh layer with no commits is never swept + assert "fresh stack layer" in blocked["l2"]["reason"] + assert result["data"]["summary"]["stack_blocked"] == 2 def test_batch_cleanup_cleans_parent_landed_by_merge(self, layered): git("merge", "-q", "--no-ff", "-m", "land l1", "l1", cwd=layered) @@ -346,6 +435,14 @@ def test_batch_cleanup_cleans_parent_landed_by_merge(self, layered): # l2 has no commits of its own, so once l1 landed it is an empty worktree and is # swept by the pre-existing empty-branch rule; nothing stack-specific applies. + def test_batch_cleanup_never_sweeps_fresh_layer(self, stack_repo): + create(stack_repo, "l0", "develop", stack={"trunk": "develop"}) + result = run_script("worktree_cleanup.py", {"repo_root": str(stack_repo)}, cwd=stack_repo) + assert result["success"], result + blocked = {b["branch"]: b for b in (result["data"]["stack_blocked"] or [])} + assert "l0" in blocked and "fresh stack layer" in blocked["l0"]["reason"] + assert (stack_repo.parent / "repo-worktrees" / "l0").exists() + def test_empty_parent_cannot_get_children(self, stack_repo): # The scenario the guard would otherwise need: a pushed empty branch as a parent. git("branch", "-q", "empty", "develop", cwd=stack_repo) @@ -371,6 +468,20 @@ def test_parent_advanced(self, stack_repo): assert any(i.startswith("stack_parent_advanced: origin/l1") for i in l2["issues"]) assert l2["tracking_entry"]["stack"]["parent"] == "l1" assert any("rebases once at task start" in r for r in result["data"]["recommendations"]) + # after the writer merges the parent forward the issue clears without touching tracking + wt2 = stack_repo.parent / "repo-worktrees" / "l2" + git("merge", "-q", "--no-ff", "-m", "carry l1 forward", "origin/l1", cwd=wt2) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l2 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l2") + assert not any(i.startswith("stack_parent_advanced") for i in (l2["issues"] or [])) + + def test_parent_branch_deleted(self, stack_repo): + create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) + git("push", "-q", "origin", "--delete", "l1", cwd=stack_repo) + git("fetch", "-q", "--prune", "origin", cwd=stack_repo) + result = run_script("worktree_scan.py", cwd=stack_repo, args=("--no-cache",)) + l2 = next(w for w in result["data"]["worktrees"] if w["branch"] == "l2") + assert "stack_parent_landed: origin/l1 no longer exists" in l2["issues"] def test_parent_landed(self, stack_repo): create(stack_repo, "l2", "l1", stack={"trunk": "develop"}) diff --git a/pytest.ini b/pytest.ini index 47eb6ff43..307aea10d 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 packages/sc-gh-stack/tests +testpaths = tests packages/sc-docling-pdf/tests packages/sc-gh-stack/tests packages/sc-git-worktree/tests python_files = test_*.py python_classes = Test* python_functions = test_*