Contents

Git Branches Are Just Sticky Notes on Commits

The whole confusing vocabulary collapses once you see what a branch actually points at

Contents

For years I pictured a Git branch as a separate copy of the codebase, quietly diverging in its own little folder somewhere inside .git. That mental model is wrong, and it’s wrong in a way that makes half of Git’s vocabulary feel harder than it is. A branch holds no code, no history, and no files of its own. It’s a sticky note with a commit hash written on it, sitting in .git/refs/heads/. That’s the entire data structure. Once that clicks, merging, rebasing, detached HEAD and “lost” commits stop being separate mysteries and become one mechanism viewed from different angles.

The commit graph exists whether or not you have branches

Advertisement

Git’s actual history is a graph of commits, each one pointing at its parent (or parents, for a merge commit). That graph would exist even if branches didn’t. Every commit knows where it came from; nothing in a commit object knows what branch it “belongs to,” because commits don’t belong to branches at all. A branch is nothing more than a label pointing at one specific commit in that graph — a sticky note, literally forty bytes on disk, containing a commit hash and a newline.

You can verify this yourself in seconds:

1
2
3
$ git branch feature-x
$ cat .git/refs/heads/feature-x
a3f1c9e2b8d4f0e1a2b3c4d5e6f7890123456789

That’s it. That’s the whole branch. Forty characters pointing at one commit. When you run git commit while feature-x is checked out, Git creates a new commit whose parent is the commit the note currently points at, then moves the note forward to the new commit. The note slides along the graph; it never contains anything.

HEAD is the “you are here” note

If a branch is a sticky note pointing at a commit, HEAD is a second sticky note pointing at whichever branch note you’re currently standing on. Checking out a branch just moves HEAD to point at that branch’s note instead of a different one. Nothing about your files changes because you moved a label — Git changes your working directory to match whatever commit the label you moved to now resolves to, but the move itself is just repointing a reference.

This is also exactly what “detached HEAD” means, and why it sounds scarier than it is. Normally HEAD points at a branch note, which points at a commit. In detached HEAD state, HEAD points directly at a commit, skipping the branch note entirely — you’ve peeled the sticky note off and stuck HEAD straight onto the wall. You can look around, make commits, poke at old code, and everything works, but if you check out a different branch afterwards, any commits you made in detached HEAD have no note pointing at them anymore. They’re still in the object database, safe, but nothing labels them, so git log and gitk won’t show them by default. This is the single most common source of “Git lost my commits” panic, and it’s also why it’s almost never true — git reflog still remembers where HEAD has been, and a lost commit is nearly always recoverable with git checkout <hash> or git branch recovery <hash> from the reflog entry.

What a merge actually does to the notes

Advertisement

Merging two branches sounds like it should be complicated. It’s genuinely two operations, and which one happens depends entirely on the shape of the graph at that moment.

A fast-forward merge happens when the branch you’re merging in is simply ahead of the branch you’re merging into, with no divergence — main’s note and feature-x’s note are on the same line of commits, feature-x just further along. Merging here is nothing more than sliding main’s sticky note forward to wherever feature-x’s note already points. No new commit is created. No history is combined, because there was never a second line of history to combine.

A true merge happens when the two branches have diverged — both have commits the other doesn’t. Here Git creates a genuinely new commit with two parents, one pointing at each branch’s most recent commit, and moves the target branch’s note to point at this new merge commit. The “combining” people talk about is real at this point, but it’s still just graph surgery: a new node with two parent edges, and one sticky note moved to point at it.

Rebasing is the operation people find hardest to picture, and the sticky-note model makes it concrete. git rebase main while on feature-x takes every commit unique to feature-x, replays them one at a time on top of wherever main’s note currently points, creating new commits with new hashes as it goes, and then moves feature-x’s note to point at the last of these new commits. The original commits aren’t touched — they’re abandoned, unreferenced, and eventually garbage collected — while an entirely new sequence of commits with the same changes but different parents and different hashes takes their place. This is exactly why rebasing commits that have already been pushed and shared causes pain: anyone else’s branch note still points at the old commits, and now two provably different histories claim to represent the same work.

A worked example

Here’s the full sequence, run end to end, with what’s happening to the notes at each step:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ git switch -c feature-x        # new sticky note, pointing at current commit
$ echo "change" >> file.txt
$ git commit -am "add change"    # feature-x note slides forward one commit
$ git switch main
$ git switch -c hotfix           # another sticky note, same starting point
$ echo "urgent fix" >> file.txt
$ git commit -am "urgent fix"    # hotfix note slides forward
$ git switch main
$ git merge hotfix                # fast-forward: main note slides to hotfix's commit
$ git switch feature-x
$ git rebase main                 # feature-x's commit replayed on top of the new main

Every one of those commands either creates a new note, slides an existing note along the graph, or replays commits and then slides a note onto the result. There’s no other category of thing a branch operation does.

Tags: a sticky note that never moves

A tag is the same underlying object as a branch — a name pointing at a commit hash, stored the same way in .git/refs/, just in a tags/ directory instead of heads/. The difference is entirely behavioural, not structural: nothing ever moves a tag automatically. A branch note slides forward every time you commit while it’s checked out. A tag note is written once, usually to mark a release, and stays exactly where you put it forever, which is exactly why tags are the right tool for “this is what shipped as v1.4.0” and branches are the wrong one — a branch called v1.4.0 would happily keep sliding forward the next time someone committed to it, quietly invalidating the thing it was supposed to freeze.

Annotated tags go one step further and create an actual tag object with a message, a date and a signature, rather than just a bare reference — the difference between a Post-it with a hash on it and a Post-it with a hash, a note explaining why, and a signature underneath. For anything you intend to keep around as a permanent marker, an annotated tag (git tag -a v1.4.0 -m "message") is worth the extra keystroke over a lightweight one.

Cherry-picking: copying one commit without moving the note that owns it

Cherry-picking answers a narrower question than merging or rebasing: “I want this one commit’s changes, here, without dragging in everything else on that branch.” Mechanically it’s the smallest possible version of what rebase does at scale — Git takes the diff introduced by a single commit elsewhere in the graph, applies it on top of whatever commit your current branch note points at, and creates one new commit with a new hash. The original commit is untouched, its own branch’s note doesn’t move, and your branch gains one new commit that happens to contain the same change.

This is the tool for the awkward situation where a fix landed on the wrong branch, or a hotfix needs to reach both main and a long-lived release branch without merging the whole feature branch it originated on into either. It’s also a common source of “why does this file conflict, I didn’t touch it” confusion, because a cherry-picked commit is replayed against a different parent than the one it was originally written against — the same class of surprise as a rebase, for the same underlying reason: the commit is new, even though the change looks identical.

Troubleshooting: the confusions this model resolves

“I deleted a branch and lost my work.” Deleting a branch removes the sticky note, not the commits. As long as you have the commit hash — from git reflog, from a teammate’s terminal history, or from git fsck --lost-found — the commits are still sitting in the object database and a new branch note can be stuck straight back onto them: git branch recovered <hash>.

“My branch says it’s up to date but the code is wrong.” This is nearly always HEAD pointing somewhere other than where you think, usually after an interrupted rebase or a checkout to a commit hash rather than a branch name. git status will tell you plainly if you’re in detached HEAD state; git log --oneline --graph --all shows every note in the repository and exactly which commit each one points at, which resolves the confusion faster than any other single command.

“Two branches show different files for the same commit.” They can’t — a commit’s contents are fixed the moment it’s created, which is worth understanding at the object level if this still feels uncertain; I’ve written separately about what actually happens inside Git when you type git commit and about how Git stores your code as snapshots rather than diffs, both of which go one level deeper than branches into what a commit object actually contains. If two branches genuinely show different content for what looks like “the same point,” one of the notes is pointing somewhere other than where you assumed, and git log --oneline --graph --all will show exactly where.

“I force-pushed and now everyone’s confused.” A force push moves the remote’s sticky note to point somewhere that isn’t a descendant of where it used to point — usually after a rebase — and anyone who already had the old note cached locally now has a branch that looks like it’s diverged from itself. There’s no way around this except communication: force-pushing shared branches is safe only when everyone downstream knows to git fetch and reset their own local note to match, rather than trying to merge two histories that were never meant to coexist.

git branch -d refused to delete a branch, but -D feels dangerous.” The lowercase flag is Git protecting you: it refuses to remove a note if doing so would leave commits unreachable from any other note, because deleting the last reference to a commit is the one case where a commit genuinely does become hard to find again — still present in the object store, yet no longer listed anywhere git log looks by default. The uppercase flag skips that check entirely. It’s not more destructive in what it does to the object database, since the commits still linger until garbage collection, but it does mean you’re relying on the reflog rather than an ordinary git log if you change your mind five minutes later.

Why this model is worth carrying around

None of this changes how you use Git day to day. You’ll still type git switch, git merge, git rebase exactly as before. What changes is what happens when something goes wrong, which for most people is the only time Git’s internals matter at all. If you’re self-hosting your own Git server rather than renting space on someone else’s, the same model applies identically — a repository on Gitea or Forgejo stores exactly the same sticky-note-on-a-graph structure as one on a hosted platform, because the data structure is Git’s, not the hosting provider’s, and I found this out first-hand moving off a hosted platform to run my own Git server at home — nothing about branches, tags or the reflog behaves any differently once the repository lives on a box in a rack instead of someone else’s data centre.

Branches are cheap, disposable, and recoverable precisely because they’re labels rather than containers — the actual history is the graph underneath, and the graph almost never loses anything you didn’t explicitly tell it to garbage collect. The next time a rebase looks like it ate a commit, or a deleted branch looks like it took history with it, the useful question isn’t “where did my work go” so much as “which sticky note am I not looking at,” and git log --oneline --graph --all will answer it faster than panic will.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.