smart-rebase — rebase onto the base branch and force-push
The whole workflow is in scripts/rebase-sync.sh, split into subcommands so a conflict can stop the run, get fixed, and resume. Drive the script — don't hand-roll git fetch/rebase/push calls, because the script also carries the safety checks (refuse on the base branch, lease-protected push, autostash).
$SKILL_DIR below is this skill's directory.
Three paths, decided by preflight
The guiding principle: prefer the tool that already knows how to do this safely over a hand-rolled sequence. gh covers two situations better than local git can — a chain of stacked PRs, and a plain PR that just needs catching up — so reach for those first and fall back to local git only when neither fits.
preflight prints a STACK: line and a SERVER: line, and together they route everything that follows:
| preflight says |
Path |
Why it wins |
STACK: stack |
Stack path — stack-sync |
Cascade-rebases the whole chain |
SERVER: eligible |
Server path — server-sync |
GitHub rebases; no local force-push |
| otherwise |
Plain path — rebase → push |
Handles everything else, incl. conflicts |
Check STACK: first — it outranks SERVER:, because updating one PR branch of a stack in isolation strands the layers above it.
Also read STATUS: before doing any work. up-to-date means the base is already contained in this branch and nothing needs to happen — say so and stop rather than performing a no-op rebase. push-only means there is nothing to rebase but local and remote differ, so only step 5 (push) is needed.
Why the stack path exists
A stack is a chain where each branch's parent is the branch below it, so rebasing one layer with plain git leaves the layers above pointing at the old parent — their PR diffs then show the lower layer's commits as if they were new work, and reviewers see noise that isn't theirs. gh stack sync cascade-rebases every layer onto its updated parent and pushes them atomically, which is why it wins whenever it applies.
Why the server path exists
gh pr update-branch --rebase asks GitHub to rebase the PR's head branch onto its base and rewrite the remote ref server-side. That beats a local rebase when it applies: one round trip instead of fetch/rebase/push, GitHub validates the expected head and refuses atomically on conflict, and nothing is force-pushed from here — so the classic failure mode of clobbering someone else's commits can't arise.
The trade is a reversal of trust: afterwards the rewritten remote is the source of truth and server-sync hard-resets the local branch onto it. That is only sound when the local ref is genuinely disposable, so the script requires an open same-repo PR whose base matches, HEAD identical to the PR's remote head, and a clean tree. Any other situation reports a reason on the SERVER: line and you take the plain path — including the case where gh itself is unavailable.
Two things to keep in mind when reporting back. The rewritten commits are created by GitHub, so the author is preserved but the committer becomes the GitHub account — which can differ from the local git config, and means a locally-created signature does not survive. And GitHub drops commits that become empty against the new base; that is usually correct, but the script flags it as a COMMITS_BEFORE/AFTER mismatch precisely because it is the one way this path changes history without saying so.
Stack path
- Confirm with the user first.
gh stack sync fetches, rebases and pushes in
one indivisible step, so there is no post-rebase/pre-push checkpoint to stop at the way the plain path has. Tell them it will force-push (with lease) every branch in the stack, and show the stack from preflight's output.
- ```bash
bash "$SKILL_DIR/scripts/rebase-sync.sh" stack-sync ```
STATUS: synced and you're done — report which layers moved.
- Exit 10 means conflicts.
sync is conservative here: it rolls every branch
back to its original state and pushes nothing, so the stack is untouched. Replay it with pauses instead:
``bash bash "$SKILL_DIR/scripts/rebase-sync.sh" stack-rebase ``
Resolve conflicts exactly as in the plain path below (same inverted ours/theirs caveat), git add each file, then stack-continue — repeating while it keeps exiting 10, since each layer is replayed in turn. stack-abort restores every branch. Once it reports STATUS: rebased, run stack-sync again to push.
STATUS: sync-failed usually means the local and remote stacks diverged.
sync needs an interactive terminal to ask which side wins, and it aborts rather than guess — so nothing was changed. Don't try to force it: report the message and ask the user to run gh stack sync in their own terminal.
Server path
Used when STACK: is not stack and SERVER: eligible.
- Confirm with the user first, for the same reason as the stack path: rebase
and remote-rewrite happen in one indivisible call, so there is no post-rebase/pre-push checkpoint. Tell them GitHub will rewrite origin/<branch> and that their local branch will be reset onto the result. Skip this only if they already said to push without asking.
- ```bash
bash "$SKILL_DIR/scripts/rebase-sync.sh" server-sync ```
STATUS: synced means GitHub rewrote the branch and the local branch now matches it. Report the new commits, and call out a COMMITS_BEFORE/AFTER mismatch if there is one — that means commits were dropped as empty, which is usually correct but is exactly the kind of thing the user should confirm.
- Exit 10 (
STATUS: server-conflict) means conflicts. GitHub refused and
changed nothing. Conflicts can only be resolved where the files can be read and edited, so fall through to the plain path below — start at its step 2.
STATUS: server-failed or server-timeout — nothing was changed either way.
server-timeout means GitHub accepted the request but the ref wasn't rewritten within ~30s. Don't retry server-sync blindly; run preflight again to see the current state, and if it's still behind, take the plain path.
Everything below is the plain path, used when STACK: is not stack and the server path doesn't apply or bailed out.
Why it's split into subcommands
git rebase stores its paused state in .git/rebase-merge/, which outlives the process. So stopping at a conflict and resuming from a later shell invocation is safe — that's what makes rebase → (fix) → continue → push work.
Steps
- Preflight — always first. It fetches the base branch and reports the
branch, the resolved base, whether the tree is dirty, whether the branch is actually behind, and which path applies:
``bash bash "$SKILL_DIR/scripts/rebase-sync.sh" preflight ``
The base branch is auto-detected from origin/HEAD, falling back to main/master/develop. If the user names a different base, pass it as an argument to preflight and rebase (e.g. ... rebase develop).
If it exits non-zero because the current branch is the base branch, stop and tell the user — rebasing and force-pushing main itself would rewrite shared history. Ask which feature branch they meant.
Then route on the output before doing anything else: STATUS: ready-stacked → stack path; SERVER: eligible → server path; STATUS: up-to-date → nothing to do, say so and stop; STATUS: push-only → skip to step 5. Otherwise continue here.
- Rebase:
``bash bash "$SKILL_DIR/scripts/rebase-sync.sh" rebase ``
Uncommitted work is stashed automatically (--autostash) and restored by git as soon as the rebase finishes, so you don't need to manage the stash.
- If exit code is 10, there are conflicts. The script prints the conflicted
files and leaves the rebase in progress. Resolve them yourself:
- Read each conflicted file and understand both sides. The user's own commits are being replayed on top of the base, so "ours" is the base branch's version and "theirs" is the commit being replayed — this is inverted compared to a merge, and getting it backwards silently drops their work. - Keep both intents where they're compatible rather than picking a side wholesale. If two people edited neighbouring lines, the answer is usually both edits, not one. - git add each file, then:
``bash bash "$SKILL_DIR/scripts/rebase-sync.sh" continue ``
A rebase replays commits one at a time, so continue may hit conflicts again and exit 10 again. Loop until it reports STATUS: rebased.
If a conflict is genuinely ambiguous — you can't tell which behaviour the user wants — don't guess. Explain the choice and ask. rebase-sync.sh abort restores the pre-rebase state cleanly if they'd rather handle it themselves.
- Confirm before pushing. Force-pushing rewrites the remote branch, so show
the user what happened and get an explicit go-ahead first:
- which commits are now on top of the base (the script prints them) - which conflicts you resolved, and how you resolved each one — this is the part they most need to sanity-check, since a wrong resolution looks like a clean rebase - that the next step force-pushes origin/<branch>
Skip this confirmation only if the user already said to push without asking (e.g. "rebaseしてforce pushまでやって").
- Push:
``bash bash "$SKILL_DIR/scripts/rebase-sync.sh" push ``
This uses --force-with-lease (plus --force-if-includes when git supports it) — never a bare --force.
If it reports push-rejected, someone else pushed to this branch and the script lists the commits that a force-push would destroy. Stop there. Do not escalate to --force, and note that re-running step 2 does not help — rebase only fetches the base branch, so it won't pick up commits made on the feature branch itself. Show the user the listed commits and let them decide (typically: coordinate with whoever pushed, or git rebase origin/<branch> to put their own work on top of it).
Reporting back
Keep the summary short and factual: which path ran (stack / server / plain), the base branch and its new tip, how many commits were replayed, conflicts resolved (file + one line on the resolution), and the push result. If anything was left undone — aborted rebase, rejected push, a path that bailed out — say so plainly rather than implying success.
Naming the path matters more than it looks: on the server path the user did not force-push and their commits were re-created by GitHub, so if they later wonder why the SHAs and committer metadata changed, the answer is in that one word.