| name | stack-cascade |
| description | Safely propagate a base-branch commit through all descendant branches in a stacked-PR chain. Handles safety-tagging, `git rebase --update-refs --empty=drop` from the tip, conflict-resolution heuristics, dependency-drift recovery, and `--force-with-lease` push. Use when landing a fix on an upstream branch in a stack and the descendants must be rebased. Covers scenarios like: 'cascade through the stack', 'rebase pr6..pr13 onto the new pr5', 'land on the base and propagate', 'update the descendant branches', 'restack onto the new base', 'force-with-lease the whole stack', 'fix the base and roll the stack forward'. Pairs with `/rebase-validate` for post-cascade meaning-preservation check. |
| user-invocable | true |
| effort | high |
| allowed-tools | ["Bash","Read"] |
| license | MIT |
| compatibility | Requires git ≥ 2.38 (--update-refs). Works across ecosystems; per-ecosystem dep-refresh + check-ordering steps are noted as examples (npm shown; substitute pnpm/cargo/go/etc. as applicable). |
Stack Cascade — propagate a base-branch fix through a PR stack
Land a commit on a base branch in a stacked-PR chain, then mechanically
propagate the change through every descendant with safety tags, conflict
resolution, dependency refresh, and a single force-with-lease push.
Companion to /rebase-validate (which checks a cascade preserved meaning).
Run this skill to execute the cascade, then /rebase-validate to verify.
When to Use
- A fix has just been (or is about to be) committed on a base branch and
every dependent branch needs to be rebased onto the new tip.
- You're working on a stack like
feature/pr1 → feature/pr2 → … → feature/prN,
each rebased onto the previous, with --update-refs ergonomics.
- The cascade involves more than a trivial fast-forward (i.e. real risk of
conflict, dropped commits, or post-rebase regressions).
(For the inverse — what this skill is NOT for — see "When NOT to Use" at the
bottom.)
Preconditions
- The fix-commit on the base branch is committed AND validated individually
(the base branch passes its own CI suite). Authoring + base-validation are
manual; this skill kicks in for the propagation.
- Working tree is clean (
git status --short returns nothing).
- You have the full ordered list of branches in the stack.
Step 1 — Verify the worktree HEAD
Session prompts and IDE chrome can lie about which branch is checked out,
especially across reused worktrees. Verify before tagging:
git rev-parse --abbrev-ref HEAD
git rev-parse --short HEAD
Confirm both that you're on the expected branch AND that the SHA matches
your expectation (fix landed or not).
Step 2 — Create safety tags
Use tags, not backup branches: git rebase --update-refs silently moves any
branch ref that points into the replayed range, including branches you
created as "backups" — they'll follow the rebase and become useless as
snapshots. Tags are immune.
One tag per branch, all at their CURRENT tips. Tags are namespaced as
safety/<op-id>/before/<branch> so each operation's anchors cluster under
their own prefix — sort-by-name groups by operation, sort-by-SHA still
surfaces duplicates. set -euo pipefail in the subshell catches three
classes of silent failure: a typo'd branch name (git tag errors), a
forgotten OP_ID (-u aborts on unset), and any other command failure
mid-loop (-e):
OP_ID="<short-name>"
(
set -euo pipefail
: "${OP_ID:?OP_ID is empty — fill in the placeholder above}"
[[ "$OP_ID" != "<short-name>" ]] \
|| { echo "OP_ID still the literal '<short-name>' placeholder — substitute a real name" >&2; exit 1; }
EXISTING_TAGS=$(git tag -l "safety/${OP_ID}/before/*")
if [[ -n "$EXISTING_TAGS" ]]; then
echo "OP_ID '$OP_ID' already has safety tags. Either pick a different" >&2
echo "OP_ID, or prune the existing tags with /tag-audit before retrying:" >&2
echo "" >&2
echo " git tag -l 'safety/$OP_ID/*' # preview what would be pruned" >&2
echo " # then run /tag-audit and accept the DROP proposal for this OP_ID" >&2
echo "" >&2
echo "Existing tags blocking this OP_ID:" >&2
echo "$EXISTING_TAGS" >&2
exit 1
fi
for b in <ordered list of branch names>; do
git tag "safety/${OP_ID}/before/$b" "$b"
done
)
Then retag the base branch to its PRE-commit SHA, so rollback restores
the state before your fix landed. Without this retag, the tag points at
your new commit and rollback is a no-op.
Compute <pre-commit-sha> carefully:
- If your fix is a single commit at the tip of the base:
<base-branch>^.
- If you landed multiple commits on the base (or merged a fix branch):
<base-branch>^ is WRONG — use the parent of the first fix commit, e.g.
git log --oneline <base-branch> ^origin/<base-branch> to enumerate your
fix commits, then git rev-parse <first-fix-commit>^.
Easy to get wrong. The retag block below refuses to overwrite if the
provided SHA equals the current base tip (the most likely typo: pasting
the current HEAD by mistake makes rollback a silent no-op):
PRE_COMMIT_SHA="<paste-sha-here>"
(
set -euo pipefail
: "${OP_ID:?OP_ID must be set — same value as the per-branch loop above}"
[[ -n "$PRE_COMMIT_SHA" && "$PRE_COMMIT_SHA" != "<paste-sha-here>" ]] \
|| { echo "PRE_COMMIT_SHA empty or still placeholder — fill in above" >&2; exit 1; }
resolved=$(git rev-parse --verify "${PRE_COMMIT_SHA}^{commit}") \
|| { echo "PRE_COMMIT_SHA '$PRE_COMMIT_SHA' does not resolve to a commit" >&2; exit 1; }
current_tip=$(git rev-parse --verify "<base-branch>^{commit}")
[[ "$resolved" != "$current_tip" ]] \
|| { echo "PRE_COMMIT_SHA equals current <base-branch> tip — that's the POST-commit state. Rollback would be a no-op. Refusing retag." >&2; exit 1; }
git tag -f "safety/${OP_ID}/before/<base-branch>" "$resolved"
echo "Retagged base to $(git rev-parse --short "$resolved") (was $(git rev-parse --short "$current_tip"))" >&2
)
Then sanity-check the final tag:
git rev-parse --short "safety/${OP_ID}/before/<base-branch>"
Step 3 — Cascade rebase from the tip
Switch to the TIP branch (the last one in the stack) in the same worktree
(don't create sibling worktrees per branch — current-worktree-switching is
the established pattern):
git checkout <tip-branch>
git rebase --update-refs --empty=drop <base-branch>
--update-refs moves every intermediate branch ref along with the rebase —
no per-branch checkout-and-rebase loop required.
--empty=drop discards commits that become empty after replay (their
changes were already absorbed into the new base by your fix). It does NOT
skip commits whose content already exists downstream; that's handled
automatically by git's cherry-pick machinery (--reapply-cherry-picks
default).
After the rebase completes, verify ancestry across the whole stack —
this catches silent ref orphans where --update-refs couldn't move a tip.
The block exits non-zero if any orphan is found so you can't accidentally
proceed past a corrupted stack (e.g. when running this in CI or via tee):
(
set -euo pipefail
orphan=0
prev=<base-branch>
for b in <ordered list of branch names except the base>; do
if ! git merge-base --is-ancestor "$prev" "$b"; then
echo "ORPHAN: $b is not descended from $prev" >&2
orphan=1
fi
prev="$b"
done
[ "$orphan" -eq 0 ] || { echo "Stop: fix orphans before continuing past Step 3." >&2; exit 1; }
)
Any "ORPHAN: …" line means that branch's tip is no longer descended from
its predecessor — fix with git update-ref refs/heads/<branch> <new-sha>
where <new-sha> is the rebased equivalent of the original tip-minus-one
commit.
If git printed Recorded preimage / Recorded resolution during conflicts,
rerere just LEARNED a resolution. If it printed Resolved '<path>' using previous resolution, rerere REPLAYED one — and replays can silently apply
a STALE resolution if the surrounding code has evolved since rerere last
saw the conflict. When you see REPLAY messages, run git rerere status
and inspect git diff HEAD~ -- <path> for each replayed file before
proceeding to Step 5; don't trust tests alone to catch a wrong replay.
For the full mechanism, see references/silent-behaviors.md. (For
companion validation-time behaviors like --update-refs ref-movement, the
/rebase-validate skill has its own silent-behaviors file.)
Step 4 — Resolve conflicts
If git stops at a conflict, apply these heuristics as starting points (verify
each against the actual conflict content; don't blindly follow them). The
underlying principles are ecosystem-agnostic; parenthetical examples lean on
JS/TS conventions but translate naturally to other languages:
- Modify/delete (downstream modified a file you deleted on the base):
keep the deletion.
git rm <file> then git rebase --continue.
- Manifest/dependency-list additions (e.g. JS
import lines, Python
import, Rust use, Go import (...) blocks): union the additions from
both sides.
- Additive type or schema changes (e.g. JS/TS
typedef/interface
fields, Rust struct fields, GraphQL schema additions): prefer the side
that adds — additive changes from downstream usually still apply on top
of your fix.
- Documentation/comment conflicts (e.g. JSDoc, docstrings, README
paragraphs): prefer the more accurate description, often the downstream
one if it reflects post-cascade behavior.
- Routing/wiring conflicts (route registrations, plugin loads, DI
bindings): keep both registrations; verify no duplicate registration
after merging.
- Configuration object conflicts (config files; lockfiles excluded —
let the package manager regenerate those): union keys where both sides
add disjoint config; if both modify the same key, prefer the downstream
value unless your fix explicitly supersedes it.
Stage with git add (or git rm), then git rebase --continue. Repeat
until git reports success.
If a conflict feels wrong (e.g. you're being asked to re-add code you
deliberately deleted), abort and investigate:
git rebase --abort
Step 5 — Refresh dependencies (drift recovery)
Switching branches can leave the dependency tree out of sync — deps added
in downstream branches may be missing after switching from an upstream
branch. After the cascade, refresh:
npm install --no-audit --no-fund
If you skip this, the next step may surface false-positive errors (missing
modules, "dependency not installed" warnings from installed-check-style
tooling).
Step 6 — Validate the tip
Run the project's full check suite on the new tip:
npm run check-all
npm test
If your project generates artifacts (parsed schemas, CSS bundles, codegen
output, vendor .d.ts files), run the relevant build step BEFORE validation —
not as a workaround after a failed first run. Otherwise the check suite may
fail on stale generated files that look correct in source. Example for an
npm monorepo where root checks depend on workspace declarations:
npm run check-workspaces && npm run check-all
The script names (check-workspaces, build, etc.) are project-specific;
read package.json / Cargo.toml / equivalent first if you don't know
them.
For stronger validation that the cascade preserved meaning across the
stack (not just "tip compiles"), invoke /rebase-validate next. The
two skills compose: stack-cascade executes, rebase-validate verifies.
Step 7 — Push
Push all branches in a single transaction. Two safety flags are mandatory:
--atomic: the multi-ref push either all-succeeds or all-fails. Without
it, a single rejected ref (protected branch rule, server-side hook denial)
leaves the remote stack incoherent — some branches at new SHAs, others at
old SHAs.
- Per-ref
--force-with-lease=<branch>:<sha> pinned to the safety-tag
SHAs from Step 2. Bare --force-with-lease re-anchors to whatever your
local remote-tracking refs currently say — so if you ran git fetch
before the cascade (common reflex), a teammate's force-push between fetch
and push is invisible to the lease and you silently overwrite their work.
Pinning to the safety-tag SHA closes that window.
For a long stack, generate the lease flags programmatically. Build a
branches array alongside leases so the lease list and the push list
can never drift out of sync (a mismatch would silently leave a branch
unpinned or unpushed). set -euo pipefail + the OP_ID precondition +
the tag-existence check are all load-bearing — without them, an unset
OP_ID or a missing safety tag silently produces an empty $sha,
which makes --force-with-lease="$b:" re-anchor to the remote ref
(exactly the silent-overwrite footgun Step 7 is meant to prevent):
(
set -euo pipefail
: "${OP_ID:?OP_ID must be set — same value used in Step 2}"
leases=()
branches=()
for b in <ordered list of branch names>; do
tag="safety/${OP_ID}/before/$b"
git rev-parse --verify "$tag^{commit}" >/dev/null \
|| { echo "Missing safety tag '$tag' — refusing to push" >&2; exit 1; }
sha=$(git rev-parse "$tag^{commit}")
leases+=(--force-with-lease="$b:$sha")
branches+=("$b")
done
git push --atomic "${leases[@]}" origin "${branches[@]}"
)
Never --force without a lease — no protection against concurrent work.
For GitHub/GitLab, each pushed branch updates its existing MR/PR (rather than
re-creating) because branch names are preserved by --update-refs.
Rollback
If anything went wrong (cascade left the stack in a worse state than it
started, validation failed mysteriously, push got rejected):
(
set -euo pipefail
: "${OP_ID:?OP_ID must be set — same value used in Step 2}"
[ -z "$(git status --porcelain)" ] || { echo "dirty worktree — commit/stash first" >&2; exit 1; }
git_dir=$(git rev-parse --git-dir)
for sentinel in REBASE_HEAD CHERRY_PICK_HEAD MERGE_HEAD rebase-merge rebase-apply; do
[ ! -e "$git_dir/$sentinel" ] || { echo "in-progress git operation ($sentinel) — abort it first" >&2; exit 1; }
done
for b in <ordered list of branch names>; do
tag="safety/${OP_ID}/before/$b"
git rev-parse --verify "$tag^{commit}" >/dev/null \
|| { echo "Missing safety tag '$tag' — rollback aborted (no refs changed)" >&2; exit 1; }
git rev-parse --verify "refs/heads/$b" >/dev/null \
|| { echo "Branch ref 'refs/heads/$b' does not exist — rollback aborted (no refs changed)" >&2; exit 1; }
done
for b in <ordered list of branch names>; do
git update-ref "refs/heads/$b" "safety/${OP_ID}/before/$b"
done
)
The verification pass ensures every safety tag and branch ref exist before
any ref is mutated — a failed precondition prints "no refs changed" and
aborts cleanly. The mutation pass then restores every branch to its
pre-cascade tip. The base branch returns to its pre-fix state too — but
ONLY if you completed Step 2's retag of the base. If the base tag still
points at your fix commit, this loop is a no-op for the base.
Then re-investigate without time pressure.
Cleanup
Safety tags persist as rollback anchors after Step 7's push completes — they
don't expire and don't actively harm anything, but they pile up and Step 2
of the next cascade will refuse to proceed if you reuse the same OP_ID.
Once the rollback window has closed (PRs merged or stable, CI green, no
surprises from teammates), prune the tags with /tag-audit. This is
often days or weeks later in a fresh shell, so re-set OP_ID from the
original cascade — the :? guard catches an empty/unset value:
OP_ID="<paste-original-op-id>"
: "${OP_ID:?OP_ID must be set — without it the preview matches nothing}"
git tag -l "safety/${OP_ID}/*"
/tag-audit defaults to the canonical safety/* namespace only — narrow
by design, since pre-* / backup-* / stack-* patterns can match
release or ad-hoc tags. Widen its PATTERNS explicitly to also clean up
legacy stack-*-start/* tags from cascades that pre-date this naming
convention, or any other safety conventions you use.
Common Failure Modes
-
--update-refs orphans the tip of a dropped commit: when a commit
that was a branch's TIP (not just an intermediate) is dropped during the
cascade, git can't move that ref — it stays at the un-rebased commit. The
ancestry check at the end of Step 3 surfaces this; fix with manual
git update-ref refs/heads/<branch> <rebased-equivalent-sha>.
-
Re-replay through grandchildren: when git drops a commit as empty
during the cascade, it does not automatically prevent that commit's
changes from reappearing when a grandchild branch is rebased over the
gap. Symptom: a commit you intended to drop reappears in a grandchild's
log. Fix with git rebase --onto <new-base> <dropped-commit> <grandchild>
to explicitly skip it.
-
Branch switch leaves deps inconsistent: if Step 6 reports missing
modules that the project's manifest lists, you skipped Step 5. Run the
dep-refresh and re-validate.
-
Lease rejection despite pinning: a --force-with-lease=<branch>:<sha>
rejection (after Step 7's safety-tag pinning) means a push genuinely
landed against this branch after your Step 2 snapshot. The lease is
doing its job — fetch, inspect what landed, and decide whether to
integrate or coordinate before retrying. Do NOT switch to bare --force.
When NOT to Use This Skill
- Single isolated rebase with no descendant chain — use git directly.
- The fix isn't authored or validated yet — author + base-validate first.
- Public-history rewrites on a shared trunk branch (
main/master) — this
skill targets feature-branch stacks where force-push is the established
convention.