# Stack Cascade

> 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.

- Skill: `voxpelli/stack-cascade` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add voxpelli/stack-cascade`
- Raw SKILL.md: https://api.skillmd.com/api/skills/voxpelli/stack-cascade/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: voxpelli (https://skillmd.com/u/voxpelli)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/voxpelli/stack-cascade

---


# 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:

```bash
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`):

```bash
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, 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):

```bash
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:

```bash
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):

```bash
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`):

```bash
(
  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:

```bash
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:

```bash
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:

```bash
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:

```bash
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):

```bash
(
  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):

```bash
(
  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:

```bash
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-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.

