| name | fast-forward |
| description | Use when the user invokes /fast-forward to land a finished feature branch (or its worktree) onto the local main/master branch as a fast-forward of its logically grouped, changelog-worthy commits. Commits any outstanding work, syncs and rebases onto the local trunk, regroups the history into a few reviewable commits whose messages read as public changelog entries, fast-forwards them onto the trunk with no merge commit, then deletes the branch and removes its worktree. User-invoked only; local only, never pushes; irreversible and destructive. |
| disable-model-invocation | true |
Fast-forward
Land a completed feature branch (or its worktree) onto the local main/master
branch as a fast-forward of its commits, then clean up. Unlike collapsing the
branch to a single commit, this keeps the branch's handful of logically grouped
commits and welds them, unchanged, onto the trunk — no merge commit, no squash,
linear history.
Because those commits become the trunk's permanent public history, this skill
holds every one of them to a changelog-worthy bar before it lands. That per-commit
discipline is the whole reason to reach for /fast-forward instead of squashing:
the reader of the trunk log or a release changelog sees the real shape of the work,
so each commit must read as an entry they'd want there.
This skill is destructive and irreversible once the deletions run, and it
never pushes — the fast-forward lands on the local trunk only.
Non-negotiable guardrails
- Never push. The fast-forward lands on the local trunk. Stop after cleanup
and let the user push when they're ready.
- Fast-forward only — never a merge commit. Use
git merge --ff-only. If it
refuses, the trunk moved since the prep rebase; stop and report. Do not fall
back to a --no-ff merge: a merge commit defeats the linear history this skill
exists to produce, and silently changes what lands on the trunk.
- Every landing commit is changelog-worthy. The commits become permanent
public history, so each subject and body must read as a release note for a
user of the project: what changed and why they benefit. No inside baseball —
private class/file/module names as the point of the message, incidental churn
(lint, test tweaks, renames) dressed up as a feature, or process references
("as discussed", "addresses review", agent/tool/conversation mentions, bare
ticket or PR numbers). See Step 4 for the bar and how it's enforced.
- Verify before you delete. Confirm the trunk fast-forwarded and holds the
work before removing any branch or worktree. Deletions are the last steps.
- Conventional commits throughout. Every commit this skill makes — prep
commits and the regrouped commits — must be a valid conventional commit (full
rule in the shared prep reference). The
enforce_commit_message hook rejects
anything else.
Why this works with branch protection
This repo's enforce_branch_protection hook blocks commits and file edits on
main/master. /fast-forward never trips it, for two reasons:
- All commits land on the feature branch, never the trunk. The prep, the
regroup, and any message rewrites all run while the branch is checked out, where
commits are allowed. The trunk is only touched by the fast-forward itself.
- A fast-forward merge makes no commit.
git merge --ff-only <branch> just
advances the trunk ref; it cannot write a merge commit, so the hook permits it.
(The hook does block merges that would land a merge commit on the trunk — a
bare git merge or --no-ff — so the --ff-only here is also what keeps the
merge itself allowed, not only what guarantees linear history.) No squash
carve-out is needed, unlike collapsing to a single commit, which does commit
on the trunk.
Workflow
digraph fast_forward {
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 land?\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)"];
regroup [label="Step 4: regroup into a few commits +\nmake every message changelog-worthy"];
goto [label="Step 5: move to the trunk checkout, checkout main,\ngit merge --ff-only <branch>"];
fffail [label="Fast-forward refused?" shape=diamond];
stop [label="Trunk moved since prep.\nStop and report"];
verify [label="Verify trunk tip == feature tip"];
cleanup [label="Step 6: remove worktree (if any),\ngit branch -d"];
done [label="Report result. Do NOT push." shape=doublecircle];
detect -> refuse;
refuse -> done [label="yes"];
refuse -> sync [label="no"];
sync -> prep -> regroup -> goto -> fffail;
fffail -> stop [label="yes"];
fffail -> verify [label="no"];
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 land).
A branch level with the trunk but with uncommitted changes is not a refusal:
the shared prep's first step commits that work onto the branch, leaving something
to land. Run git status --porcelain before refusing on the second condition — if
it prints anything, there is work to land, so proceed.
Sync the local trunk first
The fast-forward lands on the local trunk (Step 5), which can be ahead of
its remote (prior unpushed landings) 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-merge. 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 fast-forward 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 fast-forward will land on. Rebasing the feature onto it is what
makes the branch a linear descendant of the trunk, which is what guarantees a
true fast-forward is possible in Step 5.
Return here once it's done.
Step 4 — Regroup into changelog-worthy commits
Repackage the branch into a few logically grouped commits and make every one of
them read as a public changelog entry. These commits land verbatim on the trunk, so
this is the step that earns the linear history.
Record the current tip first so the rewrite is verifiable:
orig=$(git rev-parse HEAD)
Read ../shared/regroup-history.md (relative to this skill's base directory)
and perform every step in it, with:
<base> = the local <trunk> branch (the commit you rebased onto in
Step B, and the one the fast-forward lands on).
<original-tip> = $orig (the SHA recorded just above).
That procedure groups the commits, rebuilds them with a soft reset, and verifies the
tree is byte-for-byte identical (restoring from $orig if anything drifted). Apply
two /fast-forward-specific augmentations to it:
-
Widen its "is a rewrite worth it?" test to include message quality. The
procedure normally leaves a branch alone when the grouping already reads
cleanly. Here, also rewrite when any existing subject or body fails the
changelog bar below — a branch with perfect grouping but inside-baseball
messages still needs the rewrite to fix the wording. The rewrite stays
tree-preserving either way (its byte-identical guard is unchanged); only the
commit messages improve.
-
Hold every subject and body you write to the changelog bar. A commit is
changelog-worthy when a user of the project, reading the trunk log or a
release changelog, learns what changed and why it benefits them:
- Describe the user-facing change, not the internal mechanics. Reference the
public capability or behavior, not private class/function/module names or file
paths — unless that name is the public surface (a CLI flag, an exported API).
- No process or provenance references: "as discussed", "per review",
"addresses feedback", mentions of agents/tools/conversations/sessions, or bare
ticket/PR numbers as the message's payload. None of that means anything to a
changelog reader.
- Don't dress incidental churn as a feature. Fold lint fixes, test tweaks,
and renames into the commit they support, or word them honestly — unless the
churn is the user-facing point.
- Still a valid conventional commit (
<type>(<scope>): <subject>), since the
enforce_commit_message hook enforces that on each commit.
Return here once the procedure reports the tree is verified unchanged. If it rolled
back (the byte-identical check failed), the branch is exactly as it was — re-read
the diff and regroup again before continuing; do not fast-forward a branch the
rewrite could not faithfully rebuild.
Step 5 — Fast-forward onto the trunk
Get onto the trunk checkout, then fast-forward it to the feature branch.
- Single checkout:
git checkout <trunk> in the current repo.
- Worktree:
cd into the trunk's checkout (from git worktree list); it's
already on the trunk. 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 fast-forwarded to the remote in the sync step, 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 merging (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.
Now fast-forward the trunk to the feature branch:
git merge --ff-only <feature-branch>
- Success stages no conflict and creates no commit: the trunk ref simply
advances to the feature tip, bringing the regrouped commits with it.
- If
--ff-only refuses ("Not possible to fast-forward"), the trunk is no
longer an ancestor of the feature — the remote or local trunk moved since the
Step B rebase. Stop and report. Do not retry with --no-ff; restart from the
sync step so the feature is rebased onto the current trunk first.
Step 6 — Verify, then clean up
Only after the fast-forward, and only once verified.
Confirm the trunk now points at the work:
git log -1 --stat
git rev-parse HEAD <feature-branch>
Then remove the leftovers. A fast-forward makes the feature branch a true ancestor
of the trunk, so git branch -d (safe delete) succeeds and doubles as a final
merge check — if it ever complained "not fully merged", the merge did not actually
land and you should stop rather than force it. (This is the opposite of a squash
landing, which records no merge ancestry and needs -D.)
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 commits now on the local trunk (hashes + subjects,
from git log <old-trunk-tip>..HEAD --oneline), 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 |
|---|
--ff-only refuses | Trunk moved since the prep rebase; branch no longer linear | Stop; restart from the sync step so the feature rebases onto the current trunk. Never --no-ff |
| Local trunk sync reports divergence | Local and remote trunk both have unique commits | Stop and report; never force the trunk to either side |
| Regroup verify failed and rolled back | A group's paths were staged wrong, dropping/altering content | The branch is unchanged; re-read the diff and regroup again before landing |
| Commit rejected by hook | A regrouped/prep message isn't a valid conventional commit | Fix the type/subject; chore is not an allowed type here |
| Messages read like internal notes | Commits carry inside baseball, not changelog entries | Rewrite them in Step 4 (the regroup rewrite is tree-preserving); land only worthy messages |
branch -d says "not fully merged" | The fast-forward did not actually land | Stop — do not -D; investigate why the trunk tip isn't the feature tip |
worktree remove refuses | Untracked/dirty files in the worktree | Stop, show the user; don't force-discard their files |
| Merge conflict during prep rebase | Trunk diverged from the branch's base | Stop; let the user resolve, then resume (handled in the shared prep) |