Stack cascade
Git workflow safety skills for rebase validation, merge auditing, and stacked PR management
npx -y skills add voxpelli/claude-git --skill stack-cascadeAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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.
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
19.4 KB, ~4.8k tokens by cl100k_base, as published. Nobody here has run it
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-refsergonomics. - 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 --shortreturns 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>" # e.g. "cascade-2026-05-27", "auth-fix", "mock-fields"
(
set -euo pipefail
# Catch empty / unfilled-placeholder OP_ID. `set -u` would catch *unset*,
# but OP_ID="" (accidentally exported empty) or OP_ID="<short-name>" (literal
# placeholder, never substituted) would otherwise produce safety//before/* —
# a valid-looking but wrong namespace.
: "${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; }
# Refuse to proceed if any tag in this namespace already exists — overwriting
# them with `git tag -f` below would silently destroy a previous cascade's
# rollback anchors. Capturing to a variable lets `set -e` abort if
# `git tag -l` itself fails (corrupt refs, packed-refs lock) — an `if`
# pipeline would silently treat that failure as "no existing tags".
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 # e.g. pr5 pr6 pr7 pr8 ...
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, thengit 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>"
# expect: the SHA BEFORE your fix
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>thengit rebase --continue. - Manifest/dependency-list additions (e.g. JS
importlines, Pythonimport, Rustuse, Goimport (...)blocks): union the additions from both sides. - Additive type or schema changes (e.g. JS/TS
typedef/interfacefields, 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
# investigate, then restart from Step 3
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 # npm
# pnpm install # pnpm
# cargo fetch # cargo
# go mod download # go
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 # or equivalent: cargo check, go vet, etc.
npm test # or equivalent
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-leasere-anchors to whatever your local remote-tracking refs currently say — so if you rangit fetchbefore 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; }
# ^{commit} peel matters for annotated tags — bare rev-parse returns the
# tag-object SHA, which would never match the remote-tracking branch's
# commit SHA and cause every lease to fail.
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}"
# Ensure clean state: dirty index + git update-ref on the currently
# checked-out branch leaves the working tree out of sync with HEAD.
[ -z "$(git status --porcelain)" ] || { echo "dirty worktree — commit/stash first" >&2; exit 1; }
# Ensure no in-progress rebase/merge/cherry-pick state.
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
# Verification pass — confirm every safety tag AND branch ref exist
# BEFORE mutating any ref. A mid-loop failure would leave some branches
# restored and others not; partial rollback looks like success but isn't.
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
# Mutation pass — all preconditions met, restore every branch.
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>" # same value used in the original cascade's Step 2
: "${OP_ID:?OP_ID must be set — without it the preview matches nothing}"
git tag -l "safety/${OP_ID}/*" # preview this OP_ID's tags
# then run /tag-audit and accept the DROP proposal for this namespace
/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-refsorphans 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 manualgit 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.
What ships with it: 1 file
5.7 KB alongside SKILL.md
references/
- silent-behaviors.md5.7 KB