Smart Rebase — Partial Rebase for Squash-Merge Repos

SkillDev tools

Smart partial rebase for squash-merge repositories. Auto-detect which commits to keep/drop when base branch was squash-merged into target. Use when: user says 'rebase', 'partial rebase', 'base already merged', 'smart rebase', or /smart-rebase. Not for: simple git rebase (the developer runs it — Claude never executes rebase), merge conflict resolution (use /merge-prep), branch switching (the developer runs git checkout). Output: rebase plan table + a shell-quoted git rebase --onto command for the developer to run after the ambiguity check in Step 5.

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 Smart Rebase — Partial Rebase for Squash-Merge Repos skill

What this skill tells your AI

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

Analyze branch history → identify squash-merged commits → generate precise git rebase --onto command.

When NOT to Use

  • Simple git rebase without squash-merge complexity — the developer runs it; Claude never executes rebase, here or anywhere else (§ Permissions). "Use git directly" is an instruction to the human, and this list is read by the dispatcher before § Permissions loads, so it says so here
  • Merge conflict resolution (use /merge-prep)
  • Branch management or switching — the developer runs the git command
  • Cherry-picking specific commits (use git cherry-pick)

Core Problem

In squash-merge repositories, when a feature branch is based on another branch that was already squash-merged:

main:    A ─── S (squash merge of B1+B2+B3) ─── ...
              ↑
feature: A ─ B1 ─ B2 ─ B3 ─ F1 ─ F2 ─ F3
              ↑ drop (in S)   ↑ keep (unique)

Need: git rebase --onto main B3 feature to keep only F1-F3.

Permissions

Claude must not execute git rebasethere is no authorization that lifts this. rebase is a destructive operation under Anchor Register #4 (@rules/discretion.md), whose enumerated approval workflows are a closed set: /push-ci (push), /smart-commit --execute (add + commit) and /epic-merge (rebase --onto, force-with-lease, squash-merge). This skill is not on that list, so user approval here cannot create the exception — adding a workflow to the list is itself an Anchor-level change. This skill outputs the rebase command; the developer runs it.

Note on allowed-tools: Bash(git:*) is granted because Steps 1–3 read history (git log, git rev-parse, git branch --show-current), and Bash(bash:*) to run the analysis script — the latter cannot be narrowed to specific script paths until #9354 is resolved. A tool grant is not an authorization: what may be executed is decided by the rule above, not by what the permission string happens to permit.

Prerequisites

Before starting, validate:

CheckCommandFail action
Not on a protected branchgit branch --show-current must not match the complete protected set defined in @rules/git-workflow.md § Prohibited (which lists the complete protected set): main, master, develop, release/*. Do not re-spell a shorter list here — a partial copy is how master and release/* reached the force-push suggestion belowAbort with warning
Clean working treegit status --porcelain must be emptyAbort: "stash or commit changes first"
Not detached HEADgit symbolic-ref HEAD succeedsAbort: "checkout a branch first"

Names in commands

Every command this skill outputs carries ref names, and a ref name is not display text: git check-ref-format accepts ;, backticks, $( ) and ' inside one — all measured. A name substituted into a command as a bare word runs whatever it contains, and a fixed pair of single quotes is broken by a name containing '.

So every <…> slot in a command block below is written <quoted …>: substitute a shell-quoted value, single-quoted with each ' rendered as '\''.

Shell-quoting is only half of it, and the other half is git's own option parser. Quotes are consumed by the shell; git never sees them, so a branch named --all is still an option when it arrives. Measured: git check-ref-format refs/heads/--all exits 0, and git push --force-with-lease origin '--all' pushed every branch in the repository, not the one named. The mechanism the templates below rely on is the -- / --end-of-options separator, placed before the ref operand — not fully-qualifying it. That distinction is load-bearing for one operand in particular: a git rebase branch operand written refs/heads/<branch> is measured to land on a detached HEAD and never move the branch, which is the whole point of the command, whereas the same short name after -- stops the option parser and — when the short name resolves unambiguously — updates the ref. That qualification is not hypothetical and the separator does not supply it: with a branch refs/heads/tags/v0.0.1 and a tag v0.0.1 both present, git rebase --onto main main -- 'tags/v0.0.1' exits 0 and leaves the branch unmoved (measured — docs/features/ref-name-hardening/requests/2026-08-20-ref-name-hardening-r1.md finding 1). Until the ambiguity probe that closes this lands (that ticket's AC 1), the emitted rebase_command is not safe to run verbatim on a repository where the current branch name could also name a tag — check git rev-parse --verify for an is ambiguous warning on the operand before running it. So the separator, carried in the template, is the rule — -- for git push and git rebase, --end-of-options for git merge-base and for git log --not (Step 6); a <quoted branch> after it stays a short name on purpose. Which spelling matters, and where, is measured — not a blanket rule. Measured on git 2.55.0 in this repository:

Command----end-of-optionsConsequence
git push, git fetch, git merge-baseacceptedacceptedInterchangeable. Picking one is a convention, not a correctness requirement
git log … --not <name>forces <name> to a pathspec — a name that is neither ref nor file yields nothing at exit 0keeps rev/path disambiguation — the same name exits 128 with ambiguous argumentNot interchangeable. --end-of-options fails closed; -- fails silently. Step 6 uses --end-of-options for this reason
git rev-parseechoes the separator literally, then resolvesNeither separator helps. Use git rev-parse --verify --quiet refs/heads/<name>
git check-ref-format --branchrejected (exit 129)rejected (exit 129)Honours neither, so the script validates a source by its full refs/heads/… form instead

The separator terminates option parsing, never refspec parsing: git push origin -- '+main' still reads the leading + as the force modifier, and git check-ref-format --branch '+main' exits 0, so a lexical protected-branch check passes a branch literally named +main. Emit fully-qualified refspecs ('refs/heads/<n>:refs/heads/<n>') rather than short names wherever a + could appear. This is the same fact § Step 5 states about the analysis script: the resolved operand is what git acts on, never the spelling that was typed.

The slot also brings its own quotes — never wrap one in a further fixed pair. '--base=<quoted branch-or-commit>' reads as safe and is not: substituting feat/x;printf${IFS}PWN yields '--base='feat/x;printf${IFS}PWN'', which the shell splits into an analysis call and a second command (measured), and a name containing ' turns it into a syntax error. Write the fixed prefix unquoted, adjacent to the slot: --base=<quoted branch-or-commit>. Where the analysis script has already produced the string — its rebase_command field, and the git fetch … line in its failure message — copy that string rather than rebuilding it; sh_quote in the script is the same rule, already applied. Reasons in full: docs/features/ref-name-hardening/4-implementation.md § 1.

Workflow

Step 1: Analyze → run script to detect commits
Step 2: Identify → determine keep/drop boundary (cut point)
Step 3: Display → output rebase plan
Step 4: Confirm → user reviews
Step 5: Output → print the rebase command for the developer to run
Step 6: Verify → confirm history is correct

Step 1: Analyze

Diagnose first — this analyzer writes refs, and a configured negative refspec changes what its refresh can reach. Round 59 corrected this paragraph: it used to call that an open defect and tell the operator not to run the analyzer at all. The write defect is closed (§ Names in commands has the shipped behaviour), so what the check below buys is a reason — it names the configuration that will make a refresh transfer nothing, before the analyzer refuses and the operator has to work out why. Run it for each remote the analyze step will probe:

# Capture, status-check, THEN match — a pipeline exits with `grep`'s status, so a `git config`
# that fataled would report an ordinary no-match. Measured on git 2.55.0:
# `GIT_CONFIG_COUNT=1 git config --get-all remote.origin.fetch` exits 128, and the same command
# piped into `grep -q '^\^'` exits 1 — the exact status a valid config with no negative refspec
# produces. Exit 1 from git itself means the key is simply unset, which is a real answer;
# anything else means the configuration could not be read at all.
remote=<quoted remote>   # the remote the analyzer will probe — `origin` unless --target names another
if refspecs=$(git config --get-all "remote.${remote}.fetch"); then rc=0; else rc=$?; fi
if [[ "$rc" -ne 0 && "$rc" -ne 1 ]]; then
  echo "⛔ cannot read remote.${remote}.fetch (git exited $rc) — do not run the analyzer" >&2
elif printf '%s\n' "$refspecs" | grep -q '^\^'; then
  echo "⚠️ NEGATIVE REFSPEC CONFIGURED on $remote — if the analyzer aborts on an empty transfer, this is why" >&2
fi
[[ "$rc" -eq 0 || "$rc" -eq 1 ]]   # the fence's exit status IS this line — see below

remote is bound, and the exit status answers exactly one question. Both were wrong in the first version of this gate and wrong in the same direction. ${remote} was left unbound, so running the fence verbatim queried remote..fetch — a key that is never set, so the gate reported nothing about the remote it was supposed to guard. And the block ended at an echo on both refusal paths while the safe path fell out of a failed grep, so $? read 1 for the one case that may proceed and 0 for both cases that may not — exactly inverted for any wrapper.

The status is now a trailing [[ ]] rather than a true / false terminator, and that is a correctness fix rather than a style one. Round 61 measured what the change's own threat model (docs/features/push-gate-optin/4-implementation.md § 4.7) already implies: bash imports functions from the environment, and a function outranks a builtin, so true, false and exit all return whatever an imported BASH_FUNC_true%% says — measured on git 2.55.0 / bash 3.2, each returned 7 under a shadow declaring return 7. [[ is a shell keyword, resolved by the parser before any name lookup, so it is the one form here that cannot be replaced. The line therefore says the verdict directly instead of delegating it to a word.

Two consequences worth stating rather than discovering. The echos can still be shadowed, so a caller may see no message; the status is the contract. And the terminators are deliberately not exit, both because the fence may be sourced and because exit is shadowable in exactly the same way.

What the [[ ]] does and does not buy — round 62 narrowed this. An earlier wording here called the status "the part that cannot be forged", which claims more than the keyword delivers. What cannot be replaced is the verdict form: no imported function can invert the last line, so the inversion this gate shipped with in its first version is now structurally impossible. The verdict's input is another matter — $rc comes from a bare git, a command word like any other, and under BASH_FUNC_git%%='() { return 0; }' an unreadable configuration reports rc=0 and the fence exits 0. That residue has no fix at this layer and no pretence of one: the push fences answer it by re-executing under bash -p, which discards function imports outright, and a markdown fence run in the operator's own shell cannot ask for -p (docs/features/push-gate-optin/4-implementation.md § 4.3 records the same limit for the caller side of the push gate). The backstop is elsewhere and is independent: the analyzer performs the refresh itself and aborts on an empty transfer, so a forged clean read here buys a run that still stops — which is the same division of labour as the ⚠️ above, where this block labels and the analyzer decides.

The question the status answers is "could the configuration be read?", and nothing more. Round 60 narrowed it: a hit used to return false as well, which put the gate in the business of predicting an outcome it cannot see. A negative refspec only empties the transfer when it cancels every positive mapping on the refresh line — ^refs/heads/wip/* beside +refs/heads/*:refs/remotes/origin/* cancels one prefix and leaves the rest reachable — and this block reads the configured spelling, not the intersection. Refusing on any hit therefore stopped runs that would have worked, and the operator's only route past it was to ignore the gate, which is how a check stops being read at all.

So a hit is a label, not a verdict: it prints ⚠️ and Step 1 proceeds. The analyzer is the one that can answer, because it is the one that performs the refresh — its explicit refresh carries that remote's negatives on its own command line, so git applies its own matching where the write happens and no ref is refreshed from a source the configuration excluded; and when every positive on that line is cancelled, the refresh transfers nothing and the analyzer aborts rather than planning from stale history. That abort is the refusal, and the ⚠️ line above it is what saves the operator from having to work out why. Resolve the target by hand, or run against a remote with no negative refspec, if you need a plan anyway. The one case that still refuses here is the branch: the configuration could not be read, so the block has no answer to give, and treating that as a clean no-hit is what would let an unreadable configuration read as a clean bill of health.

# --target is optional; omit it to auto-detect against origin/main.
bash skills/smart-rebase/scripts/smart-rebase-analyze.sh --target=<quoted target>

The --target value is a ref name and follows § Names in commands: the joined --target=<quoted target> form, never a bare --target <value> — a target named origin/main;printf${IFS}PWN is a legal ref (check-ref-format accepts it) and a bare, unquoted operand would run the second command when the line is pasted.

Auto-detect mode uses git cherry to find commits already cherry-picked to target. Squash merges cannot be detected by git cherry — proceed to Step 2.

Step 2: Identify Cut Point

Case A — User provides base branch or commit

# Resolve the common ancestor as cut candidate
git merge-base --end-of-options <quoted base-branch> HEAD
# Or specify the cut point commit directly
bash skills/smart-rebase/scripts/smart-rebase-analyze.sh --base=<quoted branch-or-commit>

Case B — Inference needed

  1. Check target_new squash merge commit messages
  2. Compare with commits messages in current branch
  3. Identify which commits are covered by the squash merge
  4. Confirm cut point and re-run with --base

Case C — git cherry detected all

When cherry_dropped > 0, detected commits can be dropped. Verify cut point is contiguous (all drops must precede all keeps).

Step 3: Display Plan

## Rebase Plan

| Item           | Value                                   |
| -------------- | --------------------------------------- |
| Current branch | feat/my-feature                         |
| Target         | origin/main (dd21265c)                  |
| Cut point      | 06a7fae6                                |
| Keep           | 3 commits                               |
| Drop           | 15 commits (already in main via squash) |

### Commits to Keep

1. `57d7898a` feat: Add error classification framework
2. `05c11119` docs: Document classification rules
3. `da987681` fix: Correct classification accuracy

### Commits to Drop (already in main)

1. `f76209f4` docs: Add RPC optimization design
   ...

Step 4: User Confirmation

Display plan and wait for user confirmation before proceeding.

Step 5: Output the command

One command is output, and the script already wrote it. A successful analysis emits rebase_command, with every name shell-quoted — copy that field verbatim rather than rebuilding it (§ Names in commands). Verbatim means "do not re-quote it", not "run it unchecked." Until the ambiguity probe lands (see the warning above), first confirm the operand is unambiguous:

# Bind once, then use the variable — § Names in commands. The slot brings its own quotes,
# so it may sit alone on the right of `=`, but it must NEVER be nested inside a further
# fixed pair: `"refs/heads/<quoted branch>"` renders as `"refs/heads/'main'"`, where the
# single quotes are literal ref-name characters. Measured: that pattern matches 0 refs for
# an ordinary unique branch, so the guard below would refuse every legitimate rebase.
branch=<quoted branch>
ok=1
git rev-parse --verify --quiet "refs/heads/$branch" >/dev/null ||
  { echo "⛔ not a branch"; ok=0; }
# Exact refs, one --verify each. NOT `for-each-ref <pattern>`: its patterns match a
# refname *prefix* at a path boundary, so `refs/codex` matches `refs/codex/turn-diffs/x`
# and an ordinary branch named `codex` counts 2 and is refused. Measured in this very
# repository, which carries such a subtree (`git for-each-ref refs/codex` prints it while
# `git show-ref --verify refs/codex` fails) — a false refusal, on a legal branch name.
n=0
for r in "refs/$branch" "refs/tags/$branch" "refs/heads/$branch" \
         "refs/remotes/$branch" "refs/remotes/$branch/HEAD"; do
  git show-ref --verify --quiet -- "$r" && n=$((n+1))
done
case "$n" in
  1) ;;
  *) echo "⛔ short name matches $n refs — do not run"; ok=0 ;;
esac
short=$(git rev-parse --verify --quiet "$branch")
head=$(git rev-parse --verify --quiet "refs/heads/$branch")
if [[ -z "$head" || "$short" != "$head" ]]; then
  echo "⛔ short name resolves to ${short:-nothing}, not the branch ${head:-nothing} — do not run"; ok=0
fi
# OID equality is not ref identity, and the gap is reachable. A branch literally named
# `FETCH_HEAD` sits under the pseudo-ref of the same name, which wins resolution — but if
# both currently point at the same commit, the count is 1 and `$short` = `$head`, so the two
# checks above clear an operand that still denotes the pseudo-ref. Measured in a scratch repo:
# count=1, OIDs equal, guard PASSES, while git itself calls the name ambiguous. So ask git what
# the name *is*, not only what it points at.
sym=$(git rev-parse --verify --quiet --symbolic-full-name --end-of-options "$branch" 2>/dev/null)
if [[ "$sym" != "refs/heads/$branch" ]]; then
  echo "⛔ short name denotes ${sym:-an ambiguous or non-branch ref}, not refs/heads/$branch — do not run"; ok=0
fi
# Read the VALUE, never `$?`: on the ambiguous name git printed `error: refname … is ambiguous`
# to stderr, wrote nothing to stdout, and still **exited 0**. A `|| { ... }` on this command
# would have cleared exactly the case it was added to catch.
[[ "$ok" -eq 1 ]]   # ← the block's exit status. Zero ⇔ every check passed

The last line is the point, not a formality. Each check above reports and keeps going, so the developer sees every reason at once rather than the first — but a block that only prints exits 0 whatever it found, and anything reading $? (a wrapper script, a && chain, a copy pasted into a runner) reads "clear to rebase" off a block that just refused three times. [ "$ok" -eq 1 ] as the final command makes the block's own status the verdict. It is deliberately not exit 1: this snippet is meant to be pasted, and exit in an interactive shell closes the terminal.

And the verdict is stated by keywords, not by [. Round 63. [ is a builtin, so an imported BASH_FUNC_[%% function outranks it and answers every test in the block — including the last one — for the caller. Measured on bash 3.2.57: under that shadow, a name deliberately colliding with a tag and resolving to a non-branch produced no refusal message and exit status 0. [[ and case are keywords, resolved by the parser before any name is looked up, so no imported function can reach them; that is the whole reason the four tests above are written the way they are, and case rather than [[ … -eq … ]] for the count because -eq inside [[ ]] is arithmetic — an empty operand would read as 0 and a non-numeric one would be dereferenced as a variable name.

The same narrowing as the Step 1 gate applies here, and for the same reason: what cannot be replaced is the verdict form. The verdict's inputs are bare git invocations, and a shadowed git forges those exactly as it forges Step 1's read. There is no fix for that at this layer — a fence pasted into the operator's own shell cannot ask for bash -p — and claiming otherwise is the error round 62 corrected once already.

The second check counts refs; it must never read git's message, and must never read only its exit status. Both of the obvious shortcuts are measured failures on git 2.55.0, in a repository holding both a branch and a tag named dup:

ShortcutMeasuredWhy it fails
git rev-parse --verify dup 2>&1 | grep -q 'is ambiguous'matches todayGit marks that string for translation. It survived LC_ALL=zh_CN.UTF-8 here only because that catalogue leaves the clause untranslated — a locale that does translate it turns this guard silently green
git rev-parse --verify --quiet dup; [ $? -ne 0 ]exit 0Git treats a branch/tag collision as a warning: it recovers from, resolving to one of them and printing a sha. Reading the status alone therefore fails open on the very case the check exists for — strictly worse than the string match

The five names are checked as exact refs, and that is a correctness requirement rather than a style choice. for-each-ref's pattern argument matches a prefix at a path boundary: the pattern refs/codex matches refs/codex/turn-diffs/… as readily as an exact refs/codex. Custom ref namespaces under refs/<something>/ are ordinary — this repository carries one — so the pattern form counts an unrelated subtree as a second hit and refuses a legal branch. Measured here:

CommandResult
git for-each-ref --format='%(refname)' refs/codexprints refs/codex/turn-diffs/…
git show-ref --verify refs/codexfatal: 'refs/codex' - not a valid ref
pattern count, with a branch codex present2 → refused
exact show-ref --verify count, same repository1 → proceeds

The direction of the error decides the fix: refusing a legitimate rebase is loud and recoverable, but it teaches the operator to work around the guard, and a guard routinely worked around stops guarding the case it was written for. Exact verification costs the same five commands.

The five names are not git's whole DWIM set, which is why a third check exists. Git resolves $GIT_DIR/<name> pseudo-refs — HEAD, FETCH_HEAD, ORIG_HEAD, MERGE_HEAD and their siblings — before it looks in refs/** at all, and for-each-ref iterates the ref store, so it structurally cannot see them. Measured on git 2.55.0, in a repository holding a branch named FETCH_HEAD at commit B and an ordinary .git/FETCH_HEAD naming commit A:

CheckResultReading
git check-ref-format --branch FETCH_HEADexit 0A legal branch name — this is not a hypothetical
git rev-parse --verify --quiet FETCH_HEADAThe pseudo-ref wins; the branch is invisible
the five-name exact-ref count1"Unique — proceed." The count fails open here
short vs refs/heads/$branchABThe collision, stated as the fact that matters

Shortened here. Read the whole file on GitHub.

Signals

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