From vp-git
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/vp-git:stack-cascadeThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Land a commit on a base branch in a stacked-PR chain, then mechanically
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.
feature/pr1 → feature/pr2 → … → feature/prN,
each rebased onto the previous, with --update-refs ergonomics.(For the inverse — what this skill is NOT for — see "When NOT to Use" at the bottom.)
git status --short returns nothing).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).
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:
<base-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>"
# expect: the SHA BEFORE your fix
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.)
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:
git rm <file> then git rebase --continue.import lines, Python
import, Rust use, Go import (...) blocks): union the additions from
both sides.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.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
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).
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.
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.--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; }
# ^{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.
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.
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.
--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.
main/master) — this
skill targets feature-branch stacks where force-push is the established
convention.Guides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub voxpelli/vp-claude --plugin vp-git