diff --git a/README.md b/README.md index f062296..605e374 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/git-operations.md b/git-operations.md index c250a6a..e49ba60 100644 --- a/git-operations.md +++ b/git-operations.md @@ -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=` 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 # undo a single commit +git revert -m 1 # 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 ` — move the pointer, keep changes staged. Useful for folding recent commits back + into the index to re-commit them differently. +- `--mixed ` (the default) — move the pointer, unstage changes, leave them in the working tree. +- `--hard ` — 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.)*