54d6aebbe5
New repos only, same non-retroactive caveat as trunk naming. Underscores don't survive the trip to a domain, a Docker tag, or most package-registry names, where hyphens do; matches the kebab-case already used for branch descriptions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011EBCcjJvEvhpToahqdsLLA
352 lines
22 KiB
Markdown
352 lines
22 KiB
Markdown
---
|
||
title: Git operations
|
||
collections: [handbooks, dev-practice]
|
||
---
|
||
|
||
# Git operations
|
||
|
||
> Composed 2026-08-21 – 2026-08-22. General practice, not tied to any one project's Gitea/GitHub
|
||
> setup.
|
||
|
||
## Scope
|
||
|
||
General git operations a coding agent should get right without being told each time: branching,
|
||
committing, merging vs. rebasing, and how to recover from the common ways each goes wrong. Anything
|
||
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.
|
||
|
||
## Branching
|
||
|
||
**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.
|
||
|
||
**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.
|
||
|
||
**Repo name: hyphens, not underscores** (`my-project`, not `my_project`) — for new repos, same
|
||
caveat as trunk naming: don't rename an existing one over this.
|
||
|
||
**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 has two different
|
||
causes, and they call for different responses:
|
||
|
||
- **Protected branch, or no direct push permission at all** (increasingly the default for an agent
|
||
identity) — this *is* the signal to 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.
|
||
- **A plain non-fast-forward rejection** — trunk moved since your last fetch, nothing to do with
|
||
permission. `git fetch && git rebase origin/master`, then push again, is enough on its own;
|
||
branching is still a safe fallback if a second collision seems likely, just not the only correct
|
||
response to this one.
|
||
|
||
Anything past the trivial bar — any actual logic or behavior change, however small — branches from
|
||
the outset regardless of which rejection would apply; 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 whenever there's a "why" worth keeping**, not as a mandatory second paragraph on
|
||
every commit. A subject that would have to run long to say what's needed is one trigger; just as
|
||
common is a subject that's already a clean, short label but leaves the motivation unstated — the
|
||
root cause behind a fix, why an approach was chosen over an obvious alternative. Either case gets a
|
||
body; its job is to carry what the diff itself can't, 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` always means whatever's currently checked out — never assume, check.** `<<<<<<< HEAD` is the
|
||
state you have checked out right now; the bottom half (`>>>>>>> ...`) is whatever you told git to
|
||
bring in. That produces three different results across the operations this handbook actually uses,
|
||
verified directly rather than assumed:
|
||
|
||
- **Staying current** (`git rebase origin/master` on your feature branch): rebase rebuilds trunk
|
||
forward and replays your commits onto it one at a time, so `HEAD` during a conflict is *trunk's*
|
||
content — even though you're conceptually "on" your feature branch — and the bottom half is your
|
||
original commit being replayed.
|
||
- **Catching up a shared branch** (`git merge origin/master` while on the feature branch): `HEAD` is
|
||
your feature branch's content, and the bottom half is trunk's incoming change — the opposite
|
||
polarity from the rebase case above, despite both being "sync with trunk."
|
||
- **Landing on trunk** (`git switch master && git merge --no-ff branch-name`): `HEAD` is trunk again,
|
||
and the bottom half is the branch being merged in.
|
||
|
||
There's no single "merge vs. rebase" rule that covers all three — rebase always puts the target
|
||
you're rebasing *onto* in `HEAD`; merge puts whatever you had checked out *before* running the
|
||
command, which is trunk in the landing case and the feature branch in the catch-up case. Run
|
||
`git status` before resolving — it names both sides explicitly ("rebasing branch X on Y", or "On
|
||
branch X ... You are merging Y") — and resolve by reading what each side actually contains, never by
|
||
reflexively picking one. 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. Three of these four map onto the older, overloaded `git checkout`, which remains
|
||
valid on any git a project might be pinned to — but unstaging was never one of `checkout`'s jobs;
|
||
that was always `git reset`'s, from well before `restore` existed. 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 (pre-restore: 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 # or whatever upstream calls its own trunk — not yours to rename
|
||
```
|
||
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.)*
|