On this page

Chapter 3 of 11. Builds on Chapter 2, where gh became the daily interface to a repo's state. This chapter is about the state itself: which branch you're on, why that fact matters more than it looks like it should, and what happens when something changes it out from under you without telling you.

A branch feels like the most boring primitive in Git. You type git checkout -b on autopilot, do the work, open a PR, delete it. Nothing about that ritual looks load-bearing. Then a scheduled job switches branches mid-commit, or a stale worktree quietly holds main hostage for a day, and you learn the hard way that the branch you're standing on is the single fact your entire pipeline depends on getting right. This chapter is about the naming discipline that keeps that fact legible, and two incidents where it wasn't.

What a branch is actually for

GitHub's own docs describe a branch as letting you "develop features, fix bugs, or safely experiment with new ideas in a contained area" of the repository. That's a narrow claim and it's the right one. A branch doesn't make your code better. It makes your code isolated while it's bad, half-finished, or wrong, so that the one branch everyone else reads from never has to see it.

gitgraph · branch-per-task
Git graph: a main line of commits in red with a feature branch in yellow forking after the second commit, carrying two commits, and merging back into mainmainfeature/nav-dropdownsa1f8c2HEAD
A branch is a movable pointer, not a copy. The feature line exists so main never has to hold work that is not finished.

That "one branch everyone else reads from" has a name: the default branch, almost always main. It's created automatically when the repo is created, it's the branch GitHub shows you when you land on the repository, it's checked out automatically on every fresh clone, and it's the base for new pull requests unless you tell GitHub otherwise. Every one of those defaults quietly assumes main is trustworthy. Deploy workflows watch it. Collaborators clone it and expect it to build. Scheduled jobs check it out fresh and expect it to be exactly what the last merged PR left behind. The instant main contains something nobody reviewed, every one of those consumers is compromised at once, silently, because none of them have any way to know the guarantee just broke.

Branching discipline is the practice of never letting that happen. Everything else in this chapter is detail underneath that one sentence.

Branch-per-task, and a naming scheme that means something

The rule is simple: one branch, one task, deleted after merge. The naming prefix is where the discipline actually shows up, because a prefix is metadata that survives in git branch -a, in gh pr list, in a CI log, in a Slack notification, everywhere the branch name gets echoed. Four prefixes cover almost everything this site ships:

git checkout -b feature/newsletter-double-optin
git checkout -b fix/rss-feed-duplicate-guid
git checkout -b docs/roadmap-phase-11
git checkout -b writing/2026-07-25-branching-discipline

feature/ is new capability. fix/ is a bug with a known bad behaviour and a known good one. docs/ is anything that changes prose about the system rather than the system. writing/ is content, specifically a dated article moving through the editorial pipeline. None of these are enforced by Git itself; there's no server-side hook that rejects an unprefixed branch. The value is entirely in the reader. A fix/ branch open for six days is a stalled bug. A writing/ branch open for six days on a date six days out is exactly on schedule. Same shape, opposite meaning, and the prefix is the only thing telling you which.

Never commit to main

The corollary is absolute: nothing lands on main except through a merged pull request. Not a quick fix, not a one-line typo correction, not a "this is basically nothing" change at the end of a long session. The reason isn't etiquette. It's that main is the base every new branch forks from and the artefact every deploy and every scheduled job reads. A direct commit to main has no PR, which means no CI run gated it, no diff was ever reviewed, and nothing recorded why the change happened. If it turns out to be wrong, you're debugging a change with no paper trail, on the one branch you can least afford to be wrong.

In practice this means treating git commit while git branch --show-current reports main as a stop sign, every time, no exceptions for size. The discipline scales down to nothing: even a devlog one-liner goes on a docs/ branch. The muscle memory that "small changes are safe to commit directly" is exactly the muscle memory that produces the second incident later in this chapter.

Long-lived branches are a legitimate pattern, not stale debt

Most branching advice treats branch age as a smell: the older the branch, the more it has drifted from main, the harder the eventual merge. That's true for a feature/ branch sitting open for three weeks while you get around to it. It is not true for writing/2026-07-14-* branches on this site, several of which live for days by design, because the branch itself is the delivery vehicle for a dated article. An article slotted for a Tuesday morning publish is drafted, reviewed, and sat on a branch through the weekend before it ships. Deleting that branch early because "old branches are debt" would delete the only place the article exists before it's merged.

GitHub's own guidance for long-lived topic branches is to merge the base branch into them frequently, to keep the eventual diff small and the merge mergeable. That's the discipline that makes a multi-day writing/ branch safe rather than risky: it isn't exempt from staying current, it just has a legitimate reason to exist past the lifespan of a typical task branch. The distinction that matters isn't age, it's whether the branch is stale because nobody's touched it, or current and simply waiting on a clock.

The cron that collided with its own worktree

Here's where the theory meets a real 09:00 publish slot. This site runs a scheduled publisher: a cron-driven job that checks out a fresh git worktree, drafts an article onto a writing/ branch, commits, and opens a PR, unattended. The failure mode traced back to how those worktrees got created. A task-starting helper was calling git worktree add <path> main to spin up each cron worktree, checking out the local main branch directly instead of branching off it with -b. A worktree that checks out an existing local branch holds that branch exclusively, non-detached, until it's switched to something else or removed. On one occasion a leftover worktree from a previous run was still sitting on main this way, never cleaned up.

That's a collision. Git refuses to let a second worktree check out a local branch that's already checked out elsewhere, so when the publisher's own repo root tried to git checkout main to start its next cycle, the checkout failed immediately with fatal: 'main' is already used by worktree at '<path>', before the cycle, and any commit, ever began. The scheduled 09:00 slot exited on that failure: no crash surfaced beyond the fatal line, no Telegram alert (a separate, since-fixed gap), just an article that never appeared. It was noticed by a human checking the site roughly 40 minutes later.

The root cause, once found, was narrow and fixable at the point of creation: always pass -b <branch-name> on git worktree add, so a worktree is never checking out an existing branch directly and holding it exclusively for whoever else needs it.

# Wrong: checks out the local main branch directly, holding it exclusively
git worktree add ../scratch main

# Right: a new branch is claimed at creation time, main stays free
git worktree add ../scratch -b writing/2026-07-25-branching-discipline main

The fix closes the trigger, but a second, defence-in-depth check was added on top: a pre-flight git rev-parse --show-toplevel plus a git worktree list --porcelain scan for any worktree already holding main, run before any error-trap is armed, so a collision is caught with a clear error and a clean exit rather than a bare fatal line with no context. That ordering matters. Arming the trap first would have let a failed checkout trigger an automatic stash-and-retry, silently discarding work instead of stopping.

The commit that turned out to already be inside a squash

The second incident is quieter and it's about trusting a branch's existence over checking what it actually contains. A routine cleanup pass found six stale local branches, four of them writing/ branches from articles days old, plus two leftover PR branches. The obvious move is git branch -D on all six and move on. That move was deliberately not taken.

Each branch's content had already landed on main, but through a squash merge, which means the branch's own commits are never ancestors of main. Run git branch --merged main after a squash and every one of those six branches reports as unmerged, because technically it is: the squash produced a brand-new commit on main with the same tree state but no ancestry link back to the branch it came from. Deleting an "unmerged" branch on the strength of that check alone is exactly how you lose an article that never made it onto disk, if the squash silently failed or picked up the wrong branch.

So the cleanup verified content over ancestry: for each branch, confirm the article actually exists in src/content/posts/ under the specific squash-merge commit hash before deleting anything.

# Ancestry lies after a squash merge; verify the file, not the graph
git show dc200c0 --stat | grep "src/content/posts/"
git branch -D writing/2026-06-05-some-slug   # only after the file is confirmed on main

All six checked out clean. The branches were deleted safely, but the lesson survives the specific incident: after any squash merge, git branch --merged cannot tell you a branch's work is safe to discard. Only checking the actual tree can.

What a branch protects you from

Put the two incidents next to the naming rules and the shape of the whole chapter comes into focus. A branch protects you from three distinct failures, and each rule in this chapter maps to one of them. Naming conventions protect you from ambiguity: a glance at git branch -a should tell you what kind of work is in flight without opening anything. Never committing to main protects you from unreviewed change reaching the one place everything else trusts. And treating long-lived writing/ branches as legitimate, while still verifying content over graph position during cleanup, protects you from the two ways automation can go wrong in opposite directions: a job that mistakes a branch for available when it isn't (the worktree collision), and a human process that mistakes a branch for expendable when it holds the only copy of something not yet confirmed elsewhere (the squash cleanup).

None of this needs branch protection rules, CODEOWNERS, or anything server-enforced to start paying off, though later chapters build exactly those. It needs a prefix you actually use, a habit of checking git branch --show-current before you type commit, and the discipline to verify a branch's content before you trust either its age or its merge status to tell you it's safe to delete.

// EXERCISE

Audit your branch hygiene and reproduce the worktree collision

Run a real audit of your own repository's branches against the four rules, then deliberately reproduce the worktree collision from this chapter so you know what it looks like before a scheduled job finds it for you.

Expected behaviour
  • git branch -a lists at least one branch under each prefix you actually use (feature/, fix/, docs/, or a project-specific equivalent to writing/) and none unprefixed
  • git log main --since='7 days ago' --oneline shows every commit on main carrying a merge or squash-merge commit message referencing a PR, none authored directly
  • git worktree add ../scratch-test main with no -b flag checks out the real local main branch directly (confirmed via git worktree list showing no detached marker), and git worktree add ../scratch-test-2 main run from anywhere else immediately fails with fatal: 'main' is already used by worktree at ...
  • Running git worktree remove ../scratch-test frees main immediately, confirmed by git checkout main succeeding in your root worktree where it would have collided moments before; recreating the scratch worktree with git worktree add ../scratch-test -b test/worktree-fix main claims a new branch instead and never touches main's exclusive lock

PROVE IT Run `git log main --since='7 days ago' --pretty=format:'%h %s' | grep -v -E '(Merge pull request|\\(#[0-9]+\\))'` and show it returns nothing, proving no direct commits reached main in the last week.

// CHECKPOINT — BRANCHING DISCIPLINE
multiple choice · auto-checked

A scheduled publisher's 09:00 slot exits with no article published. git worktree list shows a leftover worktree from a previous run still sitting on main, checked out directly (not detached, not on a task branch). What actually happened?

exact answer · auto-checked

What flag must you always pass to git worktree add to avoid checking out an existing branch directly, and holding it exclusively, instead of creating a new one?

open · self-checked

A cleanup pass finds six branches that git branch --merged main reports as NOT merged, even though you're confident their content is already on main via squash merges. Why does the ancestry check mislead here, and what should you check instead before deleting?

Show answer

A squash merge produces a brand-new commit on main with the same resulting tree state as the branch, but with no commit-graph ancestry back to the branch's own commits. git branch --merged walks ancestry, so every squash-merged branch will report as unmerged even though its work genuinely landed. Deleting on the strength of that check risks losing work if the squash actually failed or merged the wrong branch. The safer check is to confirm the specific file or change exists on main at the known squash-merge commit hash, verifying content directly rather than trusting graph position.

↺ re-read: “The commit that turned out to already be inside a squash

Sources

  • About branches
    GitHub Docs
    Confirms GitHub's own framing of a branch as a contained area to develop, fix, or experiment in, separate from the default branch
    docs.github.com
  • About branches
    GitHub Docs
    Confirms the default branch is created automatically, checked out automatically on clone, and is the base for new pull requests unless told otherwise
    docs.github.com
  • About branches
    GitHub Docs
    Source for the recommended discipline of merging the base branch into a long-lived topic branch frequently, the rationale behind treating scheduled-content branches as a legitimate pattern rather than debt
    docs.github.com
Back to guide overview