Epic Merge — Stacked PR Chain Squash-Merge

SkillMonitoring & ops

Sequential squash-merge of stacked PR chains into an epic branch. Handles dependency-ordered rebase, collision-safe backup tags, CI monitoring (delegates to /watch-ci), and post-merge verification. Use when: merging a chain of stacked PRs into an epic branch, collapsing a linear PR stack into per-PR squash commits, preparing an epic branch for final review. Triggers on: 'merge PRs into epic', 'squash chain', 'collapse PR stack', 'epic merge', or when user has a linear PR dependency chain (PR A -> B -> C) targeting an epic branch. Not for: single PR merge (use /create-pr + GitHub UI), simple rebase (use /smart-rebase), pre-merge analysis (use /merge-prep). Output: chain analysis table + backup tag manifest + per-iteration AskUserQuestion gate + verification log.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Epic Merge — Stacked PR Chain Squash-Merge skill

What this skill tells your AI

The instructions your AI receives, as published by sd0xdev/sd0x-harness in skills/epic-merge/SKILL.md and read by ahel’s review.

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.

When NOT to Use

  • Single PR merge — use /create-pr + GitHub UI
  • Simple rebase without stacked dependencies — use /smart-rebase
  • Pre-merge conflict / impact analysis only — use /merge-prep
  • Diamond / parallel merge chains — this skill handles linear chains only
  • Repos that use merge-commit or rebase-merge — this skill assumes squash-merge only

Permissions

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

PhaseOperationMutatesApproval
Phase 0 step 0 refreshbounded git fetch (§ Phase 0 step 0)refs/remotes/origin/* + .git/FETCH_HEADNo (local, bounded, recoverable)
Phase 1 backupgit tag -flocal refs onlyNo (no remote / non-recoverable mutation)
Phase 2 iterationrebase + force-push + gh pr mergelocal + remoteYes — single bundled gate per iteration (or per-step with --per-step)
Phase 3 verifygit lognoneNo (read-only)

--dry-run outputs the plan and performs exactly one bounded local operation — enumerated rather than implied, because every earlier phrasing was a promise the flag did not keep. "Skips all destructive steps" let Phase 1 run unconditionally, so a dry run force-updated every backup/pr-* tag and left a manifest per PR in the working tree. The repair overcorrected: it said the run "writes nothing" while still running git fetch origin, which is not a read and is not confined to origin/* — see § Phase 0 step 0. And "one bounded write" was still too strong: a refspec bounds which refs a fetch may update, not what else fetching does.

Under --dry-runBehaviour
Phase 0 step 0 refreshRuns. The one mutating operation a dry run keeps: the bounded fetch of § Phase 0 step 0, whose ref updates are bounded to refs/remotes/origin/* plus .git/FETCH_HEAD — its other writes are not, see § --dry-run residue below. Skipping it would print a plan derived from stale refs, and a wrong plan is worse than a refreshed origin/*
Phase 0 analysisRuns — read-only, over the refs step 0 has just refreshed
Phase 1 git tag -f "backup/pr-*"Skipped. Printed as a command, not executed
Phase 1 manifest filesSkipped. The git log runs, but its output goes to the report instead of $(git rev-parse --git-path epic-merge)/expected-pr-*.manifest, so no file is created and none of a previous run's are overwritten
Phase 2 iterationSkipped entirely — no gate is asked, no rebase, no push, no merge. The commands are printed
Phase 3 verificationSkipped — there is nothing to verify

The residue, stated because a dry run that leaves a trace should say where. Refs: refs/remotes/origin/* and .git/FETCH_HEAD — that set is what the explicit refspec bounds. Objects and metadata: a fetch that finds new commits downloads them into the object database, and depending on configuration git may run auto-maintenance or rewrite the commit-graph on its way out; in a shallow clone it may update the shallow metadata too. None of that is destructive and none of it is bounded by a refspec, which is why the promise above is "one bounded operation" and not "one bounded write". Pass --no-auto-maintenance --no-write-commit-graph to suppress the maintenance half; the downloaded objects are inherent to fetching and remain. Nothing changes on the remote.

Core Concept

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 origin/epic backup/pr-<N> PR_N+1
epic:    E ─── S_N ─── B1' ─ B2'

Names in commands

A PR head branch name is not display text. It arrives from GitHub, and git check-ref-format accepts far more than the names people type: measured, refs/heads/feat/x$(printf${IFS}PWNED>&2) and refs/heads/--all both pass, git update-ref creates both, and git clone carries both to every copy of the repository. git switch -C refuses to create such a name, which is why they look impossible — creation is not how they arrive.

Two separate readers, and each needs its own answer:

ReaderWhat a hostile name doesThe answer
The shellA name pasted into a command as literal text is evaluated: case "feat/x$(printf${IFS}PWNED>&2)" in runs printf before the guard decides anything, and the guard then passes the branch as unprotectedBind once to a variable (head=<quoted head>), then use "$head". Expanding a variable does not re-scan for $( ) — measured both ways
git's option parserQuotes are consumed by the shell, so git still sees --all as a flag. Measured: pushing such a branch without a separator answers Everything up-to-date — git took the flag and pushed every branch, none of them the one the operator approvedThe separator, before the ref operand

Every <…> slot bound above is written <quoted …>: substitute a shell-quoted value, single-quoted with each ' rendered as '\''.

And the separator ends option parsing, not refspec parsing — a third reader with its own answer. After --, git reads the operand as a refspec, where a leading + means "force" and a : splits source from destination. git check-ref-format refs/heads/+main exits 0, so +main is a legal branch name that the protected-head guards below compare against main and pass as unprotected. Measured, with a local main rewound behind the remote:

$ git push origin -- "+main"                      # no force flag anywhere on this line
 + affcbe7...ad7e970  main -> main (forced update)   # exit 0 — a protected branch, force-updated

It is not only a bypass, it is the wrong branch: with a real +main branch present, that form pushed main and never created refs/heads/+main on the remote, while refs/heads/+main:refs/heads/+main created it correctly and left main untouched. So both pushes below name a full src:dst refspec, whose first character cannot be read as +. Write ${head} in braces — $head:refs is a modifier expansion in zsh and silently eats the :refs.

Bind at the first use, not at the first destructive one. Phase 0 reads the names and already puts them on a command line, so a binding that started at Phase 2 would leave the whole analysis step evaluating them. Every fenced block below that names a ref binds it at the top of that block — each fence is its own shell, so nothing carries over between them.

Which separator a command takes is measured, not assumed — and for one command neither works. Measured on git 2.55.0:

Subcommand names below are written without the git prefix, so that naming a command in this table is not mistaken for issuing it:

SubcommandSeparatorMeasured
push, fetch, merge-base-- and --end-of-optionsBoth accepted, equivalent. -- here is a convention, not a correctness requirement
branch -D--git branch -D -- --all deletes the branch actually named --all; without the separator git answers fatal: branch name required
rev-parseneithergit rev-parse -- main prints -- and main back verbatim; git rev-parse --end-of-options main prints --end-of-options and then the SHA — two lines. Either way the captured value is not a SHA, and Step 7 would hand it to /watch-ci

rev-parse is therefore solved by --verify --quiet with a fully-qualified ref, never by a separator — Step 7 below uses that form.

The + refspec hazard above applies to git fetch too, and it is why the epic refresh in Steps 9 and the Iteration-1 tail names a full src:dst refspec rather than "$epic": measured, git fetch origin -- '+main' reads the + as the force modifier and fetches main, so an epic literally named +main would leave origin/+main stale and every later rebase would cut against the wrong tip.

A rev range takes -- too, and for a different reason than the option one. It cannot begin with -, so binding does settle the option question — but not the revision-versus-path one. When the range names a ref that does not resolve, git falls back to reading the whole argument as a pathspec, and if a matching path happens to exist the command succeeds:

$ git log --oneline "origin/main..origin/feat"      # ref missing, path ./origin/main..origin/feat exists
f0fb083 two                                          # exit 0 — read as a path, answered about the wrong thing
$ git log --oneline "origin/main..origin/feat" --
fatal: bad revision 'origin/main..origin/feat'       # exit 128 — the failure that should have happened

Measured — and the same fallback catches a single unresolvable ref, not just a range: git log --oneline "origin/gone" answers about a path and exits 0 where … -- fatals bad revision. A wrong answer with a zero exit is worse than an error here: the manifests below are built from these arguments and then compared, so an unreadable one degrades into a mismatch that reads as "the rebase went wrong". Every revision argument in this document — range or single ref — carries the separator.

What this section does not close: git switch -C "$head" "refs/remotes/origin/$head". No separator form applies — git refuses an option-shaped branch name outright there, so such a head ends the run with git's own error and no explanation from this skill. That is a failure, not an exploit, and it is not a handled case either. Tracked in docs/features/ref-name-hardening/requests/2026-08-20-ref-name-hardening-r1.md, which owns this defect class across the ref-handling skills.

Until that redesign lands, the Phase 0 validation gate below detects it rather than letting it surface mid-run: abort if any PR head begins with -. That is not the redesign — it neither fully-qualifies nor escapes anything — it only moves an opaque failure to the point before backup tags and per-iteration approvals are created, where it costs nothing to recover from. A head beginning with - is legal to git (git check-ref-format refs/heads/-x exits 0) but unusable here, so refusing it loses no working case.

Workflow

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

Phase 0: Analyze PR Chain

Step 0 — the bounded refresh, before anything reads a ref. Every count and validation below is computed from refs/remotes/origin/*, so a refresh that runs afterwards refreshes nothing the operator was shown. It used to sit in Phase 1, one whole approval gate too late.

git fetch origin is the wrong command for it, and not marginally: git applies the repository's configured remote.origin.fetch refspecs, and those may write anywhere. Measured — one extra git config --add remote.origin.fetch '+refs/heads/feat/a:refs/heads/victim', then a plain git fetch origin, printed + 425e2ea...0a77df9 feat/a -> victim (forced update) and destroyed a local branch. Default tag following and submodule recursion are two more write paths on the same command. So the refresh is spelled out rather than left to configuration:

Before that, one refusal — and it precedes the refresh rather than following it, because the refresh is itself a transport operation that writes refs: redirected, it does not merely misreport the chain, it fills refs/remotes/origin/* from another repository, and every count, backup tag, rebase destination and lease below is computed from exactly those refs.

# ── Step 0a: the interpreter, before anything else ────────────────────────────
# First, because every check below is only as good as the shell running it. A non-interactive bash
# SOURCES `$BASH_ENV` before line 1 of this fence; zsh does the same with `$ENV` under sh
# emulation. A sourced file may define a function whose name contains a slash — bash refuses to
# IMPORT such a name from the environment, which is why the prefix is spelled absolutely, but it
# does not refuse to DEFINE one. Measured 2026-08-22, bash 3.2.57 and zsh 5.9: with
# `function /usr/bin/env { …; }` defined, the word `/usr/bin/env` resolved to the function and the
# child never ran. Every reading this phase prints, and every attestation the iteration gates
# collect, would then be whatever that function chose to say.
#
# **This block contains no command word, and that is the design.** Two `[[ ]]` tests (a keyword the
# parser resolves — a function cannot outrank it), three assignments (syntax, not commands), one
# expansion. Round 65 rewrote it after measuring the two ways the first version failed:
#   * it read its sentinel without resetting it, so an exported `SD0X_EPIC_MERGE_REFUSED=1` satisfied
#     the expansion and the fence continued with status 0 — the refusal printed and nothing stopped;
#   * it used `${!name+set}`, bash indirect expansion, which zsh rejects as `bad substitution`
#     even under `--emulate sh` — so on macOS's default shell it aborted at the first iteration
#     whether or not anything was set, and the `ENV` refusal it documents never ran.
# Assign, THEN expand: `:?` fires on null **or** unset, so assigning empty one line above makes it
# fire unconditionally. Set-ness, not emptiness, for what is DETECTED (`${BASH_ENV+set}` — an
# exported empty value is still a file the parent named); names never values (Anchor Register #2).
#
# What this does NOT close, stated because the comment that used to stand here over-claimed: a
# startup file that defines the function and then unsets the variable leaves nothing to detect. That
# residue has no owner downstream — the `pre-push` hook is opt-in, so where it is absent the
# in-session approval is the whole credential (`rules/git-workflow.md` § Push safety).
SHELL_STARTUP_INHERITED=
[[ -n "${BASH_ENV+set}" ]] && SHELL_STARTUP_INHERITED=BASH_ENV
[[ -n "${ENV+set}" ]] && SHELL_STARTUP_INHERITED="${SHELL_STARTUP_INHERITED:+${SHELL_STARTUP_INHERITED}, }ENV"
if [[ -n "$SHELL_STARTUP_INHERITED" ]]; then
  # No apostrophe anywhere in the word: inside `${var:?word}` bash reads one as an opening quote
  # even within double quotes, and that is a PARSE error — it would take the whole fence down on
  # every run, refusing and ordinary alike. Measured 2026-08-22.
  SD0X_EPIC_MERGE_REFUSED=
  : "${SD0X_EPIC_MERGE_REFUSED:?refusing — ${SHELL_STARTUP_INHERITED} is set in this environment.
   That startup file is sourced before line 1 of this fence and can redefine the commands below,
   including the absolute /usr/bin/env prefix (measured). Nothing this phase reports could then be
   relied on, and the in-session approval is the only credential where the opt-in pre-push hook is
   not installed. Unset it and re-run. Nothing is planned and nothing is pushed.}"
fi

# Transport variables decide WHERE git’s traffic goes — which repository is read from and
# written to — so nothing is planned while any of them is set. Four names, each measured 2026-08-22 on git 2.55.0 / OpenSSH 10.3p1: `GIT_SSH_COMMAND`,
# `GIT_SSH` and `GIT_PROXY_COMMAND` are run BY git AS the connection, handed the host and the
# remote command as arguments they are free to ignore; `GIT_SSH_VARIANT` names no executable at
# all but changes the argv git BUILDS — under `=plink` a URL's `:2222` is emitted as OpenSSH's
# `-P`, which takes a *tag* rather than a port (`ssh` usage: `[-P tag]`), so the connection
# silently falls back to 22.
#
# Refusing here, rather than relying on the `-u` clearing every command below carries, is this
# step's whole point. Clearing is not a neutral act: an operator's own
# `GIT_SSH_COMMAND='ssh -p 2222'` encodes part of the destination, and dropping it moves the push
# to port 22 — which SUCCEEDS silently wherever that host serves the same path there too. Set or
# cleared, the URL and digests this phase prints would then describe a destination the push does
# not reach, which is the one thing this phase exists to prevent. The `-u` list stays as defence
# in depth, for any caller that arrives at a later phase without passing through here.
#
# Set-ness, not emptiness, is the test — measured: an exported-empty `GIT_SSH_COMMAND` is not
# treated as unset, git runs `''` as the command (`run_command: GIT_PROTOCOL=version=2 '' -G …`).
# `${VAR+set}` — the direct form, one literal test per name — is what delivers it below; the
# indirect `${!_n+set}` a loop would need is bash-only and is why the loop is gone (next
# paragraph). Names are printed and values never are: a transport
# command line routinely carries a key path (Anchor Register #2).
# Four literal tests rather than a loop over `${!_n+set}`. That is **bash** indirect expansion and
# zsh 5.9 rejects it outright — `bad substitution`, rc=1, even under `--emulate sh` — so on the
# platform's default shell the loop aborted at its FIRST iteration whether or not anything was set:
# this refusal never ran, and neither did anything below it. Measured 2026-08-22. Round 65 took the
# same construction out of step 0a and left this copy, one block away, standing.
TRANSPORT_PRESENT=
[[ -n "${GIT_SSH_COMMAND+set}" ]] && TRANSPORT_PRESENT=GIT_SSH_COMMAND
[[ -n "${GIT_SSH+set}" ]] && TRANSPORT_PRESENT="${TRANSPORT_PRESENT:+${TRANSPORT_PRESENT}, }GIT_SSH"
[[ -n "${GIT_PROXY_COMMAND+set}" ]] && TRANSPORT_PRESENT="${TRANSPORT_PRESENT:+${TRANSPORT_PRESENT}, }GIT_PROXY_COMMAND"
[[ -n "${GIT_SSH_VARIANT+set}" ]] && TRANSPORT_PRESENT="${TRANSPORT_PRESENT:+${TRANSPORT_PRESENT}, }GIT_SSH_VARIANT"
if [[ -n "$TRANSPORT_PRESENT" ]]; then
  echo "⛔ transport variables set in this environment: ${TRANSPORT_PRESENT}" >&2
  echo "   Each one decides where a push lands, so neither honouring nor clearing them lets this" >&2
  echo "   phase describe the destination that would be reached." >&2
  echo "   Move the setting to ~/.ssh/config or 'git config core.sshCommand' — per-host, durable," >&2
  echo "   and visible to 'git config' — then re-run. Nothing is planned or pushed until then." >&2
  # Terminated the way step 0a is, and for the same measured reason: `exit` is a builtin, and an
  # imported `BASH_FUNC_exit%%` that returns leaves the refusal printed and the phase running.
  SD0X_EPIC_MERGE_REFUSED=
  : "${SD0X_EPIC_MERGE_REFUSED:?refusing — transport variables set in this environment}"
fi
/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git fetch --refmap= --no-tags --no-recurse-submodules --upload-pack=git-upload-pack origin \
  '+refs/heads/*:refs/remotes/origin/*' || {
  echo "⛔ cannot refresh origin — the chain table below would be computed from stale refs" >&2
  # Not `exit`. This document's own operating model is that an exported `BASH_FUNC_exit%%`
  # outranks the builtin (§ Names in commands), and the startup guard checks `BASH_ENV`/`ENV`
  # only — an imported function is not a variable it can see. Measured 2026-08-22 under bash 3.2:
  # with `exit() { return 0; }` imported, this arm printed its refusal and the group returned 0,
  # so every step below ran against stale remote-tracking refs. Assign-then-expand, as in the
  # `PHASE1_OK` / `ITER1_OK` / `PUSH_BLOCKED` blocks that already do this.
  SD0X_EPIC_MERGE_REFUSED=
  : "${SD0X_EPIC_MERGE_REFUSED:?refusing — origin could not be refreshed; the chain table would be stale}"
}

--refmap= discards the configured refmap so only the refspec written here applies; --no-tags and --no-recurse-submodules close the other two. --upload-pack=git-upload-pack closes a fourth, and it is the one that decides which repository answers: remote.origin.uploadpack names the program run at the far end, so a configured value serves refs from wherever it likes while the URL still reads as origin. Measured — with remote.x.url pointing at a path that does not exist and remote.x.uploadpack pointing at this repository, git ls-remote x HEAD printed this repository's refs and exited 0; with --upload-pack=git-upload-pack on the command line the same call failed 128. It is pinned on every fetch and ls-remote in this document and in /push-ci, symmetrically with --receive-pack=git-receive-pack on the pushes: a measurement and the push that acts on it must reach the same repository, and the read is the half that had no pin. Measured against the same hostile configuration, victim was left untouched. What still gets written is .git/FETCH_HEAD — which is why § Arguments calls a dry run bounded rather than read-only.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
188
Forks
24
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
epic-merge
Source
github.com/sd0xdev/sd0x-harness