From sd0x-dev-flow
Sequentially squash-merges linear stacked PR chains into an epic branch with dependency-ordered rebase, backup tags, CI monitoring, and post-merge verification.
npx claudepluginhub sd0xdev/sd0x-dev-flow --plugin sd0x-dev-flowThis skill is limited to using the following tools:
Sequentially squash-merge a chain of stacked PRs into an epic branch, producing one squash commit per PR for clean per-PR review on the epic. Every destructive iteration is gated by `AskUserQuestion` to keep the operator in control.
Monitors pull requests through to merge by automatically handling CI failures, review comments, and thread resolution until all checks pass. Invoke after PR creation or via /pr-shepherd.
Guides Git branch management and GitHub PR workflows using main-branch development, modern git switch/restore commands, and MCP tools for creating, listing, and updating PRs.
Finishes Git development branches end-to-end: verifies working tree/tests/lint, reviews diffs, picks merge/PR/squash/stacked strategy, writes messages, pushes, cleans up. Use when ready to ship.
Share bugs, ideas, or general feedback.
Sequentially squash-merge a chain of stacked PRs into an epic branch, producing one squash commit per PR for clean per-PR review on the epic. Every destructive iteration is gated by AskUserQuestion to keep the operator in control.
/create-pr + GitHub UI/smart-rebase/merge-prepThis skill is one of the explicit exceptions in @rules/git-workflow.md allowed to execute git rebase --onto, git push --force-with-lease, and gh pr merge --squash. Every destructive step is gated by AskUserQuestion.
| Phase | Operation | Mutates | Approval |
|---|---|---|---|
| Phase 1 backup | git fetch, git tag -f | local refs only | No (no remote / non-recoverable mutation) |
| Phase 2 iteration | rebase + force-push + gh pr merge | local + remote | Yes — single bundled gate per iteration (or per-step with --per-step) |
| Phase 3 verify | git log | none | No (read-only) |
--dry-run skips all destructive steps and outputs the plan only.
After squash-merging PR N, the original commits are replaced by a single squash commit on epic. PR N+1 still contains N's original commits as its base — these must be cut via git rebase --onto before merging N+1. The cut point is the original tip of PR N's branch, captured in Phase 1 as a backup tag.
epic: E ─── S_N (squash of PR N)
PR N+1: E ─ A1 ─ A2 ─ ... ─ B1 ─ B2
└── drop (in S_N) ──┘ └─ keep ─┘
After: git rebase --onto epic backup/pr-<N> PR_N+1
epic: E ─── S_N ─── B1' ─ B2'
sequenceDiagram
participant U as User
participant E as /epic-merge
participant W as /watch-ci
participant GH as GitHub
E->>E: Phase 0 — analyze chain (linear?)
E->>GH: Phase 1 — fetch + create backup tags
Note over E,GH: Iteration 1 — direct squash (no rebase needed)
E->>U: AskUserQuestion (bundled gate)
U-->>E: Proceed / Dry-run / Abort
E->>GH: gh pr merge --squash
E->>GH: fetch updated epic
Note over E,GH: Iteration 2..N — rebase + force-push + CI + merge
loop For each remaining PR
E->>U: AskUserQuestion (bundled gate)
U-->>E: Proceed / Per-step / Dry-run / Abort
E->>E: rebase --onto epic backup/pr-<prev>
E->>E: verify manifest (subject + count)
E->>GH: push --force-with-lease
E->>GH: gh pr edit --base epic
E->>W: /watch-ci --sha <sha> --branch <head> --timeout <ci-timeout>
W-->>E: PASS / FAIL verdict
E->>GH: gh pr merge --squash
E->>GH: fetch updated epic
end
E->>E: Phase 3 — verify final epic log
# For each PR, get head/base branch and unique commit count
gh pr view <N> --json number,headRefName,baseRefName,title,state
git log origin/<base>..origin/<head> --oneline | wc -l
Output a chain table:
| Order | PR | Head Branch | Base Branch | Unique Commits | State |
|---|---|---|---|---|---|
| 1 | #100 | feat/A | epic/xxx | N | OPEN |
| 2 | #101 | feat/B | feat/A | N | OPEN |
| ... | ... | ... | ... | ... | ... |
Validation gate — abort if any of:
git status --porcelain non-empty)Creates safety nets. Original branch tips and PR-level commit fingerprints persist as git tags + manifest files so they survive shell session loss.
git fetch origin
# Collision-safe backup tags keyed by PR number (NOT branch basename)
for pr in <PR-numbers>; do
head_branch=$(gh pr view "$pr" --json headRefName -q .headRefName)
git tag -f "backup/pr-${pr}" "origin/${head_branch}"
done
# Stable manifest per PR (subject-only — survives SHA rewrite during rebase)
for pr in <PR-numbers>; do
head=$(gh pr view "$pr" --json headRefName -q .headRefName)
base=$(gh pr view "$pr" --json baseRefName -q .baseRefName)
git log "origin/${base}..origin/${head}" --pretty=format:'%s' > ".epic-merge-pr-${pr}.manifest"
done
Why backup/pr-<N>: branch basenames collide (feat/foo vs fix/foo both become foo). PR numbers are globally unique within the repo.
Why subject-only manifest: rebase rewrites SHAs; commit subjects are stable across rebases (assuming no --squash/--fixup mid-rebase). Subject + count is the right invariant to verify.
Why origin refs: local branches drift; origin/* is SSOT.
Why tags: shell variables die on session interruption; tags persist in .git/refs/tags/.
# AskUserQuestion gate (see Iteration Gate Design below)
# On Proceed:
gh pr merge <first-PR> --squash
git fetch origin <epic-branch>
For each subsequent PR (PR <N> with head branch <head>, previous PR was <prev>):
# Step 1: AskUserQuestion BEFORE any destructive op (see Gate Design)
# On Proceed: continue Steps 2-8 atomically
# On Per-step: re-prompt before push (Step 5) and merge (Step 7)
# On Dry-run: print Steps 2-8 commands, do not execute
# On Abort: stop, leave backup tags in place
# Step 2: Checkout fresh from remote
git switch -C "<head>" "origin/<head>"
# Step 3: Rebase — cut already-squashed commits, replay unique ones onto epic
git rebase --onto "origin/<epic>" "backup/pr-<prev>" "<head>"
# Step 4: Verify manifest (subject + count, NOT SHA)
git log "origin/<epic>..<head>" --pretty=format:'%s' > ".epic-merge-actual.manifest"
diff ".epic-merge-pr-<N>.manifest" ".epic-merge-actual.manifest"
# Mismatch → STOP, restore: git switch -C "<head>" "backup/pr-<N>"
# Step 5: Force-push (--force-with-lease, NEVER --force)
git push --force-with-lease "origin" "<head>"
# Step 6: Update PR base so CI runs against correct diff
gh pr edit "<N>" --base "<epic>"
# Step 7: Wait for CI — delegate to /watch-ci with proper args
sha=$(git rev-parse "<head>")
# Skill: /watch-ci --sha "$sha" --branch "<head>" --timeout <--ci-timeout value (default 15)>
# Re-enter loop only on PASS verdict; FAIL → STOP and restore from backup
# Step 8: Squash merge
gh pr merge "<N>" --squash
# Step 9: Refresh epic
git fetch "origin" "<epic>"
Default: one bundled gate per iteration. Operator can opt into finer control with --per-step.
| Mode | Gate count per iteration | Gate moments | When to use |
|---|---|---|---|
| Bundled (default) | 1 | Before Step 2 (covers Steps 2-8) | Trusted chain, fast iteration |
--per-step | 3 | Before Step 3 (rebase), Step 5 (push), Step 7 (merge) | First-time use, untrusted diff, recovery from prior failure |
AskUserQuestion fields (bundled mode):
| Field | Value |
|---|---|
question | "Proceed with PR #<N> ('<title>')? Will rebase onto epic, force-push, wait for CI, then squash-merge." |
options | Proceed, Per-step approval, Dry-run only, Abort and rollback |
description per option | Show diff stats (+X -Y across F files), backup tag SHA, expected unique-commit count from manifest |
AskUserQuestion fields (per-step mode):
| Step | question (short) |
|---|---|
| Before rebase | "Run: git rebase --onto origin/<epic> backup/pr-<prev> <head> ?" |
| Before push | "Manifest verified (N commits). Force-push <head>?" |
| Before merge | "CI passed for #<N>. Squash-merge into <epic>?" |
Each iteration uses the previous PR's backup tag as the rebase cut point:
| Iteration | PR | Branch | Cut Point (--base of rebase) |
|---|---|---|---|
| 2 | #101 | feat/B | backup/pr-100 |
| 3 | #102 | feat/C | backup/pr-101 |
| ... | ... | ... | ... |
git log origin/<epic-branch> --oneline -<N+5>
Expect N squash commits (newest first), each with PR number suffix (#NNN):
<sha> feat: ... (#103)
<sha> feat: ... (#102)
<sha> feat: ... (#101)
<sha> feat: ... (#100)
<sha> <previous epic commits>
Final report:
| Item | Value |
|---|---|
| Epic branch | epic/xxx |
| PRs merged | 4 (#100, #101, #102, #103) |
| Backup tags | backup/pr-100, backup/pr-101, ... (kept for safety) |
| CI status | All PASS (verdicts via /watch-ci) |
| Manifests | .epic-merge-pr-*.manifest (kept until --cleanup) |
| Gate transcripts | All AskUserQuestion answers logged in conversation |
| Rule | Rationale |
|---|---|
--force-with-lease only | Prevents overwriting concurrent remote pushes |
Backup tags from origin/* | Remote is SSOT; local refs may be stale |
| Tags keyed by PR number | Avoids namespace collisions across branch prefixes |
| Manifest = subject + count (not SHA) | Survives SHA rewrite during rebase |
| Mismatch → STOP + restore | Backup tags enable instant rollback |
| CI must PASS before merge | Rebase can introduce conflicts; CI catches them |
| Update PR base before CI | CI must run against the correct epic diff |
| AskUserQuestion before destructive ops | Per @rules/git-workflow.md exception model |
/watch-ci for CI delegation | Reuses tested timeout/verdict logic; avoids inline gh run watch divergence |
If any step fails or user aborts:
# 1. Working tree must be clean
git status --porcelain # must be empty
# 2. Restore branch from backup tag
git switch -C "<head>" "backup/pr-<N>"
git push --force-with-lease "origin" "<head>"
# 3. If a merge already happened, manually revert via GitHub UI
# (no scripted revert — too dangerous for stacked chain)
If git rebase --onto encounters conflicts:
git rebase --continue — never --skip (commits must not be lost)git rebase --abort + restore from backup tagFor chains of 10+ PRs, mid-failure recovery without restart:
git tag -l 'backup/pr-*' + git log origin/<epic> --oneline | grep -E '\(#[0-9]+\)'/epic-merge <epic> <remaining-PRs> — Phase 0 will detect already-merged PRs from epic log and skip them# Remove local merged branches
git branch -D <branch1> <branch2> ...
# Inspect backup tags (kept by default)
git tag -l 'backup/pr-*'
# Remove backup tags only after confirming nothing went wrong
git tag -d backup/pr-100 backup/pr-101 ...
# Remove manifest files
rm .epic-merge-pr-*.manifest
| Argument | Description | Default |
|---|---|---|
<epic-branch> | Target epic branch name | Required |
<PR-list> | Comma-separated PR numbers | Auto-detect from chain |
--dry-run | Show plan + commands, skip all destructive ops | off |
--per-step | Three gates per iteration (rebase / push / merge) | off (single bundled gate) |
--cleanup | Delete local branches + manifests after success | off |
--keep-backup-tags | Keep backup tags even with --cleanup | off |
--ci-timeout <min> | Timeout passed to /watch-ci | 15 |
# Auto-detect chain into epic branch (single gate per iteration)
/epic-merge epic/gas-account-OK-49808
# Dry-run to see plan first
/epic-merge epic/feature-xxx --dry-run
# Explicit PR list with finer per-step gates (first-time use)
/epic-merge epic/feature-xxx 100,101,102,103 --per-step
# Full cleanup after success
/epic-merge epic/feature-xxx --cleanup
gh CLI authenticated (gh auth status)git status --porcelain empty)backup/pr-* not already in use for unrelated purposes/watch-ci to be availableorigin/* refs, keyed by PR number--force-with-lease; no push command contains the bare --force flag (token-level check, not substring — --force-with-lease is allowed)/watch-ci invoked with --sha <sha> --branch <head>; PASS verdict received before gh pr mergediff exits 0 after each rebase(#NNN) suffix in expected order| File | Purpose | When to Read |
|---|---|---|
@rules/git-workflow.md | Push/rebase exception model (this skill is one of three exceptions) | Before any destructive op |
skills/smart-rebase/SKILL.md | Single-PR squash-merge rebase pattern | Phase 2 rebase logic |
skills/merge-prep/SKILL.md | Pre-merge analysis primitives | Phase 0 chain analysis |
skills/watch-ci/SKILL.md | CI verdict polling — argument contract | Phase 2 Step 7 delegation |
skills/push-ci/SKILL.md | AskUserQuestion gate pattern for git push | Iteration Gate Design |