| name | squash |
| description | Use when the user invokes /squash to collapse a finished feature branch or worktree into a single commit on main. Commits any outstanding work, squash-merges the whole branch into one conventional commit on the local main/master branch, then deletes the branch and removes its worktree. User-invoked only; this is an irreversible, destructive workflow. |
| disable-model-invocation | true |
Squash
Collapse a completed feature branch (or its worktree) into a single commit on
the local main/master branch, then clean up. This is the end-of-feature
"land it" workflow: one tidy commit on the trunk, no leftover branch.
This skill is destructive and irreversible once the deletions run. Force
through nothing — synthesize the message, verify the commit landed, then clean up.
Non-negotiable guardrails
- Never push. The squash lands on the local trunk only. Stop after
cleanup and let the user push when they're ready.
- The final commit message is for an end user, not a maintainer. It
describes the branch as one shipped feature and why the project's users
benefit — not a changelog of every internal change. Synthesize it, then
commit it directly (no approval prompt) and report the message used.
- Verify before you delete. Confirm the squash commit exists and holds the
work before removing any branch or worktree. Deletions are the last steps.
- Conventional commits throughout. Every commit this skill makes — the
prep commits and the final squash commit — must be a valid conventional
commit (full rule in the shared prep reference below). The
enforce_commit_message hook will reject anything else.
Why this works with branch protection
This repo's enforce_branch_protection hook blocks commits directly on
main/master — except when a squash merge is in progress (it detects
SQUASH_MSG in the git dir, or a git merge --squash ... && git commit chain).
That carve-out exists for exactly this workflow. So the final commit must go
through git merge --squash followed by git commit. Do not try to commit on
the trunk any other way; it will be blocked.
Workflow
digraph squash {
rankdir=TB; node [shape=box];
detect [label="Step 0: detect feature branch, trunk name,\nworktree or single checkout"];
refuse [label="On trunk already / nothing to squash?\nStop and explain" shape=diamond];
sync [label="Sync local trunk to remote\n(ff-only; stop if diverged)"];
prep [label="Steps A-D: shared prep\n(commit, rebase feature onto local trunk, green, docs)"];
goto [label="Step 4: move to the trunk checkout,\ncheckout main, ensure clean"];
squash [label="git merge --squash <branch>"];
conflict [label="Conflicts?" shape=diamond];
resolve [label="Stop. Report conflict, let user resolve"];
msg [label="Synthesize ONE user-facing\nconventional commit message"];
commit2 [label="git commit (allowed: squash in progress)"];
verify [label="Verify commit landed + holds the work"];
cleanup [label="Step 5: remove worktree (if any),\nforce-delete branch"];
done [label="Report result. Do NOT push." shape=doublecircle];
detect -> refuse;
refuse -> done [label="yes"];
refuse -> sync [label="no"];
sync -> prep -> goto -> squash -> conflict;
conflict -> resolve [label="yes"];
conflict -> msg [label="no"];
msg -> commit2;
commit2 -> verify -> cleanup -> done;
}
Step 0 — Detect the situation
Establish four facts before touching anything:
git branch --show-current
git rev-parse --git-dir
git rev-parse --git-common-dir
git worktree list
- Trunk name: prefer
main; use master if that's what exists
(git rev-parse --verify main / master).
- Worktree vs single checkout: if
--git-dir and --git-common-dir resolve
to different paths, you are in a linked worktree; the trunk lives in a
separate checkout (find it in git worktree list — it's the one on
main/master). Otherwise it's a single checkout and you'll switch it to the
trunk yourself.
Refuse early if either:
- the current branch is already
main/master (nothing to land), or
- the branch has no commits beyond the trunk and the working tree is clean
(truly nothing to squash).
A branch that is level with the trunk but has uncommitted changes is not a
refusal: the shared prep's first step commits that work onto the branch, leaving
exactly one commit to squash. Run git status --porcelain before refusing on
the second condition — if it prints anything, there is work to squash, so
proceed.
Sync the local trunk first
The squash lands on the local trunk (Step 4), which can be ahead of its
remote (prior unpushed squashes) or, if the remote advanced, behind it. Bring
the local trunk current with the remote before the shared prep rebases the
feature onto it, so the feature is built on exactly what it will land on and any
conflict surfaces during prep, not mid-squash. Skip this whole step on a
local-only repo (no remote).
git fetch --all --prune
Fast-forward the local trunk to the remote without rewinding a locally-ahead
trunk or forcing a divergence. The mechanics differ by the layout detected in
Step 0:
-
Single checkout (you're on the feature branch; the trunk isn't checked
out, and the tree may still be dirty — Step A hasn't run yet). Update the
trunk ref only when the remote is strictly ahead:
ahead=$(git rev-list --count origin/<trunk>..<trunk>)
behind=$(git rev-list --count <trunk>..origin/<trunk>)
if [ "$behind" -gt 0 ] && [ "$ahead" -eq 0 ]; then
git fetch . origin/<trunk>:<trunk>
elif [ "$behind" -gt 0 ]; then
:
fi
-
Worktree (the trunk is checked out in another worktree from Step 0).
Fast-forward it in place:
git -C <trunk-checkout> merge --ff-only origin/<trunk>
If either form reports divergence, stop and report — do not force it.
Steps A–D — Prepare the branch (shared)
Read ../shared/finishing-prep.md (relative to this skill's base directory)
and perform every step in it before continuing. Every commit it makes goes onto
the feature branch, never the trunk. The squash lands on the local trunk
you just synced, so that prep's Step B rebases onto it:
<rebase-onto> = the local <trunk> branch (not origin/<trunk>) —
the exact commit the squash will land on.
Return here once it's done.
Step 4 — Squash onto the trunk
Get onto the trunk checkout, confirm it's clean, then squash-merge the branch.
- Single checkout:
git checkout main in the current repo.
- Worktree:
cd into the trunk's checkout (from git worktree list); it's
already on main. Confirm with git status that the trunk tree is clean
before merging — a dirty trunk means stop and ask the user.
The local trunk was already fast-forwarded to the remote in the sync step above,
and the shared prep (Step B) rebased the feature onto that up-to-date local
trunk — so the feature now sits directly on the commit it's about to land on.
On a remote-backed repo, re-confirm the trunk is still current as a cheap safety
net before squashing (skip on a local-only repo):
git merge --ff-only origin/<trunk>
If this unexpectedly reports the trunk is behind or diverged, the remote moved
since prep — stop and report rather than forcing it, then restart from the
sync step.
git merge --squash <feature-branch>
This stages every change from the branch as uncommitted work and writes
SQUASH_MSG. If it reports conflicts, stop — report which files conflict and
let the user resolve; do not guess at resolutions or abort their work.
Now write the final commit message. Read the branch's contribution for context,
but the message is not a changelog of it:
git log --oneline main..<feature-branch>
git diff --staged --stat
Describe the branch as one feature, framed for an end user of the project —
what they can now do and why it benefits them — not an inventory of every
internal change. The reader is someone scanning the trunk's history or a release
changelog, so reference the public-facing capability, not internal class names,
refactors, or intermediate commits. A branch with fifteen commits across five
files should still land as a single, coherent "here's what shipped and why"
message. Drop the incidental churn (test tweaks, lint fixes, renames) unless it
is the user-facing point.
Draft one conventional commit (subject + body explaining the why for the
user) and commit it directly — do not pause for approval. Report the
message you used as part of the final summary:
git commit -m "<type>(<scope>): <subject>" -m "<body>"
The branch-protection hook permits this commit because the squash merge is in
progress. Validate it landed before going further:
git log -1 --stat
Step 5 — Clean up
Only after the commit is verified. Squash merges leave no merge ancestry, so
git does not consider the branch merged — git branch -d will fail with "not
fully merged". Use -D (force). This is safe and not blocked by the hook,
which only protects main/master from force-deletion.
Order matters: a branch checked out in a worktree can't be deleted, so remove
the worktree first.
git worktree remove <worktree-path>
git branch -D <feature-branch>
If git worktree remove complains about untracked or dirty files, stop and
report rather than forcing — forcing would silently discard those files.
Finish
Summarize what happened: the single squash commit (hash + subject) now on the
local trunk, the branch deleted, the worktree removed. Remind the user the trunk
is not pushed — that's theirs to do.
Common failure modes
| Symptom | Cause | Do this |
|---|
| Commit on trunk blocked | Committed without an in-progress squash merge | Use git merge --squash then git commit; never commit on the trunk directly |
branch -d says "not fully merged" | Squash merge records no merge ancestry | Use git branch -D (expected, not an error) |
worktree remove refuses | Untracked/dirty files in the worktree | Stop, show the user; don't force-discard their files |
| Merge conflict on squash | Trunk diverged from the branch's base | Stop; let the user resolve, then resume at the commit step |
| Commit rejected by hook | Message isn't a valid conventional commit | Fix the type/subject; chore is not an allowed type here |