Merge branch 'docs/git-operations-handbook'
Composes git-operations.md: branching, committing, merging vs. rebasing, reverting and undoing, remotes and collaboration, tags and releases.
This commit is contained in:
@@ -29,4 +29,5 @@ document — the same shape a human vouches for at KB admission time.
|
||||
|
||||
## Status
|
||||
|
||||
Started 2026-08-21. First handbook: general git operations.
|
||||
Started 2026-08-21. First handbook, `git-operations.md`, composed 2026-08-21 – 2026-08-22: branching,
|
||||
committing, merging vs. rebasing, reverting and undoing, remotes and collaboration, tags and releases.
|
||||
|
||||
+306
-21
@@ -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.)*
|
||||
|
||||
Reference in New Issue
Block a user