docs: compose the git-operations handbook

Branching through tags, forged collaboratively over a full session:
trunk-based branching with type/description naming, atomic commits,
rebase-to-stay-current paired with merge --no-ff to land, revert vs.
reset vs. switch/restore split by shared-vs-local history, an
origin/upstream/mirror remote-naming discipline (including a
vendor-branch pattern for reconciling borrowed-repo drift), and
SemVer tags driven by the same type taxonomy as commits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EBCcjJvEvhpToahqdsLLA
This commit is contained in:
2026-08-22 00:55:43 +09:00
parent 0ede23fdfe
commit ebfb7da987
2 changed files with 308 additions and 22 deletions
+306 -21
View File
@@ -1,6 +1,7 @@
# Git operations
> Draft outline — 2026-08-21. General practice, not tied to any one project's Gitea/GitHub setup.
> Composed 2026-08-21 – 2026-08-22. General practice, not tied to any one project's Gitea/GitHub
> setup.
## Scope
@@ -9,26 +10,310 @@ committing, merging vs. rebasing, and how to recover from the common ways each g
specific to one project's hosting setup (branch protection, PR template, CI triggers) belongs in
that project's own KB corpus instead — see this repo's README.
## Outline
## Branching
- Branching
- naming conventions and when a new branch is warranted vs. not
- long-lived vs. short-lived branches
- Committing
- what makes a commit atomic; when to split or squash
- message conventions and what a good message actually needs to say
- Merging vs. rebasing
- when each is appropriate, and the history each produces
- conflict resolution — reading a conflict marker correctly, not guessing
- Reverting and undoing
- revert vs. reset vs. checkout — what each does to shared history
- recovering from a bad rebase or reset (reflog)
- Remotes and collaboration
- fetch vs. pull, and why the distinction matters under contention
- force-push: when it's ever appropriate, and how to do it without destroying someone else's work
- Tags and releases
- lightweight vs. annotated tags
- what a tag should mean once it's pushed
**Model: trunk-based (GitHub flow).** One long-lived integration branch carries the project's
history; everything else is a short-lived branch cut from it and merged straight back in. No
`develop`, no `release/*`, no `hotfix/*` — those exist to support scheduled, multi-version releases,
which most agent-driven work doesn't need. A project that genuinely needs staged releases is a
deviation from this default, and belongs in that project's own KB corpus, not here.
*(Fill in as it's composed. Each section should end up citable on its own — a chunk boundary, not
**Trunk name: `master`**, by convention across these instances — not `main`. This is a default for
*new* repos, not a mandate to rename an existing one: if a repo already uses `main` (or anything
else), work with what's there. Never rename a repo's default branch as an incidental part of
unrelated work.
**Naming scheme: `type/short-description`.** A type prefix from the taxonomy below, then a
kebab-case description — `feat/branch-naming-rules`, `fix/null-check-on-login`. No ticket ID in the
name: a branch name should say what the change does, and if a tracker ticket exists, that reference
belongs in the commit body or the PR, not the branch name. No author namespace either — branch names
describe the change, not who's making it; `git log`/`git blame` already answer "who."
**Type taxonomy — mirrors Conventional Commits**, so branch prefixes and commit-message prefixes
share one vocabulary:
- `feat` — new capability
- `fix` — bug fix
- `docs` — documentation only
- `refactor` — restructuring with no behavior change
- `test` — tests only
- `chore` — maintenance (deps, tooling, config) with no product-facing effect
- `ci` — CI/CD pipeline changes
The one ambiguous split is `refactor` vs. `chore`: if the change touches source that ships, it's
`refactor`; if it touches only the surrounding machinery (build config, lint rules, dependency
bumps), it's `chore`.
**When a branch is not warranted.** Trivial, low-risk changes — a typo fix, a corrected newline, a
single-line doc or config edit — can be committed straight to trunk: `git commit`, then `git push`.
Don't check branch-protection status first; just attempt the push. A rejection (protected branch, or
no direct push permission at all — increasingly the default for an agent identity) *is* the signal:
fall back to branching normally (`git switch -c fix/...` — `git checkout -b` is equivalent — then
push, open a PR) and carry on as if a
branch had been used from the start. Anything past that bar — any actual logic or behavior change,
however small — branches from the outset; don't wait for a push rejection to decide.
**Branch lifetime.** No fixed day limit — the right threshold varies too much by project pace to be
universal — but watch for the smell: a branch that's drifted far enough from trunk that merging it
is no longer a small diff, that's grown hard to review as one unit, or that's now blocking someone
else's work. When any of those show up, don't just keep adding commits to it — merge what's ready,
split the remainder into its own branch, or rebase onto current trunk before continuing.
## Committing
**Merges preserve the branch's commits as-is** (see Merging vs. rebasing) — there is no squash-merge
collapsing everything into one commit at the end. That makes the commits on a branch, not just the
diff they sum to, part of what a reviewer reads and what lands permanently on trunk. Commit
discipline is therefore not optional cleanup; it's the actual shape of the history.
**Atomic commits.** One logical change per commit — the smallest unit that still makes sense on its
own if someone had to revert, cherry-pick, or `git bisect` to just that commit. A commit that mixes
an unrelated formatting pass into a bug fix, or bundles two unrelated fixes because they happened to
touch the same file, isn't atomic even if it's small. Size isn't the test; independence is — could
this commit be reverted alone without dragging along something unrelated?
**Split and clean up before merge, not after.** Since a branch's commits ride straight onto trunk,
in-progress noise (`wip`, `fix typo`, `actually fix it`) should never reach it. Use
`git commit --fixup=<sha>` while working, then `git rebase -i --autosquash` before pushing for review
to fold those into the commit they actually belong to. If a single commit turns out to bundle two
unrelated changes, split it with `git reset` (or interactive rebase + `git add -p`) before merge —
don't leave that untangling for whoever reads the history later.
**Message format: a type prefix is recommended, not enforced.** Same taxonomy as branch names —
`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `ci:` — because reusing one vocabulary
beats maintaining two. But don't force a change into a type that doesn't fit; a plain descriptive
subject with no prefix is better than a mislabeled one. Subject line in imperative mood — "Reject
expired tokens", not "Rejected expired tokens" or "Rejects expired tokens" — as if finishing the
sentence "If applied, this commit will...".
**Body: add one when the subject line alone would run too long to stay readable**, not as a
mandatory second paragraph on every commit. When a body is warranted, its job is to carry what the
diff itself can't — motivation, the root cause behind a fix, why an approach was chosen over an
obvious alternative — not to re-narrate the diff line by line.
## Merging vs. rebasing
**Two different jobs, not competing techniques for the same one.** Rebase is for keeping a branch
current while it's still being worked on; merge is for the one-time act of landing a finished branch
back onto trunk. A fast-forward integration would still keep every individual commit, but it would
lose the durable marker `--no-ff` provides: the merge commit itself, which is what makes a unit of
work walkable later via `git log --first-parent` or revertible as a whole via `git revert -m 1`
(see Branching, Committing). Using merge to catch a branch up mid-flight, in the other direction,
buries trunk's incoming commits as extra noise inside the branch's own history instead.
**Staying current while in progress: rebase onto trunk.**
```
git fetch origin
git rebase origin/master
# resolve conflicts if any
git push --force-with-lease
```
Rebase rewrites the branch's commits (new SHAs), which is why the follow-up push needs
`--force-with-lease`, never plain `--force` (see Remotes and collaboration). This is safe as long as
the branch is still exclusively yours. **The moment a branch is shared** — someone else has fetched
it and built on top — rebasing it out from under them corrupts their work; merge trunk into the
branch instead in that case (`git merge origin/master`), and accept the extra merge-commit noise as
the price of not rewriting history someone else is depending on.
**Landing on trunk: merge --no-ff.**
```
git switch master # checkout: git checkout master
git merge --no-ff branch-name
```
This produces a merge commit and keeps the branch's own commits intact and visible as one unit in
trunk's history — the payoff for the atomic, preserved-as-is commits Committing asks for: the merge
commit is what ties them back to a single piece of work.
**Reading a conflict, not guessing.** A conflict marker block:
```
<<<<<<< HEAD
(current branch's version)
=======
(incoming version)
>>>>>>> branch-name
```
**`HEAD` and "incoming" swap meaning between merge and rebase.** During a `merge`, `HEAD` is the
branch you're on (trunk) and the lower half is the branch being merged in. During a `rebase` it
inverts: `HEAD` is the commit being replayed from the branch, and the lower half is trunk — because a
rebase replays each commit as if it were a fresh change against the new base. Getting this backwards
means resolving every conflict exactly wrong. Run `git status` first to confirm which operation is
actually in progress, and resolve by reading what each side really contains, never by reflexively
picking one side. If a resolution goes wrong partway through, `git merge --abort` or
`git rebase --abort` returns to the pre-conflict state cleanly — cheaper than untangling a bad
resolution by hand.
## Reverting and undoing
Three tools, three different scopes — the dividing line is the same one from Merging vs. rebasing:
**has this history already been shared?**
**`git revert` — safe for anything already pushed or shared.** Creates a new commit that undoes an
earlier one, without rewriting anything. History stays intact and honest: the mistake and its
correction are both visible. This is the only undo tool that's safe on trunk, or on any branch
someone else might have fetched.
```
git revert <sha> # undo a single commit
git revert -m 1 <merge-sha> # undo an entire merged unit of work (see Merging vs. rebasing)
```
**`git reset` — local, unpushed history only.** Moves the branch pointer, optionally taking the
working tree and index with it. It rewrites history the same way rebase does, so resetting anything
already shared is the same mistake as rebasing shared history — don't.
- `--soft <sha>` — move the pointer, keep changes staged. Useful for folding recent commits back
into the index to re-commit them differently.
- `--mixed <sha>` (the default) — move the pointer, unstage changes, leave them in the working tree.
- `--hard <sha>` — move the pointer and discard changes entirely. Destructive to anything not
committed elsewhere — check `git status` first, same standing rule as any other history-discarding
command.
**`git switch` / `git restore` — day-to-day, not history-rewriting.** `switch` changes which branch
`HEAD` points to; `restore` discards or unstages changes to specific files. Neither touches commit
history at all — the older, overloaded `git checkout` still does both jobs and remains valid on any
git a project might be pinned to; the equivalents below are for reference, not a second command set
to also learn.
```
git switch -c fix/typo-readme # create + switch to a new branch (checkout: git checkout -b fix/typo-readme)
git switch master # switch back (checkout: git checkout master)
git restore --staged file.txt # unstage a file (checkout: git reset file.txt)
git restore file.txt # discard working-tree changes (checkout: git checkout -- file.txt)
```
**Recovering from a bad rebase or reset: the reflog.** Every move of `HEAD` — including ones a
rebase or `reset --hard` just threw away — is recorded locally in the reflog, independent of branch
history.
```
git reflog # find the entry from just before the mistake
git reset --hard HEAD@{n} # or the sha shown — back to that state
```
The reflog is local only — it never leaves the machine, isn't fetched or pushed, and isn't a
substitute for backing up work by pushing it somewhere. It also expires (unreachable entries after
30 days, reachable ones after 90, by default) — a safety net for the last few minutes' mistake, not
a long-term archive.
## Remotes and collaboration
**Remote names are role signals, not just labels — and the role holds regardless of which host a
given repo happens to point them at.** A repo can carry up to three:
- **`origin` — the canonical remote.** Every repo has one; it's where actual work happens and where
every rule from the sections above (protected trunk, `--no-ff` merges, rebase-before-merge) applies
at full strength. On a repo you authored, it's where the repo was created. On a borrowed repo (next
bullet), it's still the canonical one — the moment you start working on someone else's project,
your own copy of it is what you treat as home.
- **`upstream` — present only on borrowed repos: the external project a repo was cloned from**
(someone else's public or limited-access repo you don't control). Fetch-only, by rule: pull from it
to track their changes when you want to, never push to it. This isn't a fork-and-contribute-back
workflow; it's personal reuse, so there's no write access to lose and no PR cycle back to it.
- **`mirror` — present only on repos you additionally publish out: a second remote that receives a
copy of canonical work.** Push-only, by rule: push to it after `origin` already has the change,
never pull from it or treat it as a source of truth. It's a passive broadcast copy, not a place
where independent work happens — if it ever diverges from `origin`, that's a process bug, not a
second line of development to reconcile.
The pattern across all three: **the name states the relationship, and the relationship dictates which
of fetch/pull/push are ever legitimate on that remote** — `origin` gets all three, `upstream` gets
fetch only, `mirror` gets push only. The name alone doesn't enforce this (nothing stops a literal
`git push upstream`) — it's a discipline rule to hold yourself to, the same way `--force-with-lease`
below is a habit, not something git makes you use.
**Fetch vs. pull.** `git fetch` downloads a remote's state into local `refs/remotes/*` without
touching your working branch — nothing about your checkout changes. `git pull` is `fetch` plus an
automatic merge or rebase into your current branch, applied immediately, with the exact behavior
depending on `pull.rebase` config you may not have checked. Under contention — someone else has
pushed to the branch you're on, or a rebase is already in flight — that automatic step is the
problem: `fetch` lets you look at what actually changed (`git log origin/master..master`, `git diff`)
and integrate deliberately; `pull` commits you to an integration right away, often producing a merge
commit you didn't ask for on what should have been a clean rebase. Default to `fetch` followed by a
deliberate rebase or merge (see Merging vs. rebasing) over `pull`.
**Force-push discipline.** Only ever `--force-with-lease`, never plain `--force` (see Merging vs.
rebasing) — restated here because it's fundamentally a remotes concern: `--force-with-lease` refuses
the push if the remote has moved since your last fetch (someone else got there first), where plain
`--force` overwrites blind, without checking. Force-pushing is legitimate on a branch that's still
exclusively yours (rebasing your own in-progress feature branch), and on your own `mirror` remote
(which never holds independent history to lose — it's a broadcast target, so overwriting it just
re-syncs it to `origin`). Force-pushing `origin`'s trunk, or any branch someone else has fetched, is
never legitimate, whichever flag is used.
**Reconciling upstream changes on borrowed repos.** A borrowed repo has two moving targets — your
local changes and `upstream`'s own progress — and the real risk is letting them collide unprepared,
not the merge conflict itself (Merging vs. rebasing already covers that mechanically).
- **A dedicated `vendor` branch mirrors `upstream` exactly and never carries local commits.** It only
ever fast-forwards:
```
git switch vendor
git fetch upstream
git merge --ff-only upstream/master
```
If the fast-forward fails, upstream's history moved in a way it shouldn't have (rare, but possible
on a repo you don't control) — stop and look, don't force it. `vendor` is the answer to "what did
upstream actually look like at this sync point," kept separate from your own diff against it.
- **Local changes live on trunk, merged in from `vendor` — never the reverse.** After updating
`vendor`, merge it into trunk the normal way (see Merging vs. rebasing); trunk gains upstream's new
commits, and any conflict surfaces exactly at that merge, isolated from the rest of your work.
- **Inspect before merging, not after conflict markers appear.** `git log master..vendor` for what
upstream actually changed; `git diff vendor...master` for your own outstanding delta since the last
sync. Knowing which of your local changes touch files upstream also changed tells you where a real
collision is possible before you're staring at raw markers — a sync that touches unrelated files
needs no special care.
- **Tag before a sync, beyond what the reflog gives you.** This is a recurring operation, not a
one-off — `git tag -a pre-sync-2026-08-22 -m "before upstream sync"` before attempting it (see Tags
and releases for why always annotated). The reflog (Reverting and undoing) is a safety net for the
next few minutes; a sync worth revisiting days later deserves a marker that doesn't expire.
- **No fixed sync cadence — same drift smell as Branch lifetime.** The longer local work diverges
from upstream unreconciled, the more a sync eventually costs. Sync opportunistically rather than
let it compound.
- **When a conflict does appear, there's no universal winner — read what each hunk is actually for**
(see Merging vs. rebasing's conflict-marker rule). One question worth asking first: is upstream's
changed hunk the reason your local patch exists — a bug it also touches, a function it also
modifies — or just adjacent code your patch never depended on? The first case usually means the
patch needs rethinking against the new code, not mechanical preservation; the second usually means
taking upstream's version outright is safe. That doesn't replace judgment — it just says which
conflicts are worth slowing down for.
## Tags and releases
**Always annotated, no exceptions** — `git tag -a v1.2.0 -m "Release 1.2.0"`, never plain
`git tag v1.2.0`. One command shape to hold to, including for a disposable personal marker like the
pre-sync tags in Remotes and collaboration: annotated tags carry a tagger, date and message, which is
exactly the context worth having on anything you might read back later. Lightweight tags exist mainly
as a cheap scripted alias to a commit, not for anything a human or agent is meant to read.
**Version scheme: SemVer, `vMAJOR.MINOR.PATCH`**, with the bump driven by the same type taxonomy
already adopted for branches and commits (Branching, Committing):
- `feat` merged since the last tag → bump MINOR
- `fix`, `chore`, `docs`, `refactor`, `test`, `ci` merged since the last tag → bump PATCH
- a documented breaking change → bump MAJOR, regardless of which type introduced it
This is the payoff for adopting Conventional-Commits-shaped types earlier: the version number becomes
a fact derived by walking merged type prefixes since the last tag, not a judgment call made fresh at
release time.
**A tag marks a release, not a merge.** Every `--no-ff` merge already marks an integration point in
trunk's history (Merging vs. rebasing); a tag is a coarser, deliberate boundary on top of that — "this
state was released," not "this feature landed." Tagging every merge would just duplicate what the
merge commits already record. Tags don't travel with an ordinary `git push` — push one explicitly
(`git push origin v1.2.0`, or `git push --tags` for all of them) once the release point is decided.
**Once pushed, a tag is immutable — the same rule as pushed commits, applied to tags.** Never
re-point an existing tag name at a different commit and re-push it (`git tag -f` + `git push --force`)
once it's left your machine: a tag someone else may have already checked out, built from, or cited in
a release note has to keep meaning what it meant when they read it. If a release was tagged wrong, tag
the corrected state under a new name (`v1.2.1`, never a rewritten `v1.2.0`) — the same "add a
correction, don't rewrite" instinct Reverting and undoing already establishes for commits.
*(Handbook composed 2026-08-22. Each section above stands as a citable unit — a chunk boundary, not
just a heading.)*