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 syncfetches, 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 frompreflight's output.bash "$SKILL_DIR/scripts/rebase-sync.sh" stack-syncSTATUS: syncedand you're done — report which layers moved.Exit 10 means conflicts.
syncis 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 "$SKILL_DIR/scripts/rebase-sync.sh" stack-rebaseResolve conflicts exactly as in the plain path below (same inverted ours/theirs caveat),
git addeach file, thenstack-continue— repeating while it keeps exiting 10, since each layer is replayed in turn.stack-abortrestores every branch. Once it reportsSTATUS: rebased, runstack-syncagain to push.STATUS: sync-failedusually means the local and remote stacks diverged.syncneeds 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 rungh stack syncin 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 "$SKILL_DIR/scripts/rebase-sync.sh" server-syncSTATUS: syncedmeans GitHub rewrote the branch and the local branch now matches it. Report the new commits, and call out aCOMMITS_BEFORE/AFTERmismatch 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-failedorserver-timeout— nothing was changed either way.server-timeoutmeans GitHub accepted the request but the ref wasn't rewritten within ~30s. Don't retryserver-syncblindly; runpreflightagain 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 "$SKILL_DIR/scripts/rebase-sync.sh" preflightThe base branch is auto-detected from
origin/HEAD, falling back tomain/master/develop. If the user names a different base, pass it as an argument topreflightandrebase(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
mainitself 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 "$SKILL_DIR/scripts/rebase-sync.sh" rebaseUncommitted 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 addeach file, then:bash "$SKILL_DIR/scripts/rebase-sync.sh" continue
A rebase replays commits one at a time, so
continuemay hit conflicts again and exit 10 again. Loop until it reportsSTATUS: 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 abortrestores 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 "$SKILL_DIR/scripts/rebase-sync.sh" pushThis uses
--force-with-lease(plus--force-if-includeswhen 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 —rebaseonly 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, orgit 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.