Three Uses for Git History

A regression somewhere in a quarter's work is fourteen builds if bisect can land on any commit, and an afternoon of reading diffs if it cannot. This is about the three uses history actually has - bisect, blame, revert - and which git habits serve them and which are only taste. The squash, the unreviewed conflict resolution and the six-week branch spend the record in small amounts nobody notices.

What History Is For

Nobody asks what a line of code does. The code says what it does, and where it does not, you read it again until it does. The question that actually gets asked, two years later, at the worst hour, is why it is there. Why this timeout and not one twice as long. Why the empty case is handled separately here and nowhere else. Why a comparison that looks inverted has been correct since the day it shipped.

The working tree cannot answer that. It holds the outcome of every decision and none of the reasoning, because reasoning is not a thing source code has anywhere to put. The repository can, in principle: every line has a commit, every commit has a message, and the message was written by somebody who at that moment knew exactly why. Intent recorded next to the artifact it produced, as a side effect of work happening anyway. Nothing else in software has that property.

In most repositories I have opened, it is gone. Not deleted, which would at least be honest. The commits are all there, each with a message, and the messages say fix, then fix again, then wip, then Merge branch 'main' into feature/reporting forty times. The record was kept perfectly and it records nothing.

So here is the argument in a form that survives contact with a team which does not care about tidiness, because tidiness is not the point and arguing for it loses. History has three uses. You bisect it to find where a behavior changed, you blame a line to find the change that introduced it and whatever reasoning came attached, and you revert a coherent unit when it turns out to be wrong. Every practice below is good or bad only insofar as it serves one of those three.

Why Bisect Decides It

Bisect is a binary search over your history. You name a commit where the behavior was right and one where it is wrong, git checks out the midpoint, you test, and you say which side it fell on. A thousand commits between good and bad is ten tests. Ten thousand is fourteen. A regression somewhere in a quarter's work stops being an afternoon of reading diffs and becomes fourteen builds, most of which you can hand to git bisect run and walk away from.

It has one precondition and it is absolute. Every commit it might land on has to be testable: it builds, it starts, and the check you are running returns something that means what you think it means. Bisect does not care about your commit messages at all, which is why it is the argument to lead with in a room that finds this subject tedious.

When the precondition fails, the tool degrades rather than failing, and the way it degrades is the problem. You mark a broken midpoint with git bisect skip and git picks a neighbor. The manual is blunt about the cost: skip a commit adjacent to the one you are looking for and git will be unable to tell exactly which of them was the first bad one. You do not get a wrong answer. You get a range, and a range is where the search was supposed to end.

Run that forward. If a meaningful share of commits on the main branch are broken intermediate states, the skips cluster around exactly the churn where regressions live, and the search returns "somewhere in these nine commits". Which is one sentence away from the whole case: the property that makes history usable is not that it is pretty, it is that every unit merged into main works on its own, because that is what makes bisect a search rather than a suggestion.

How the Record Gets Spent

The squash swallowed the reasoning. Six commits reading wip, fix, fix again, lint get squashed into one, which is the right call - none of them built, and keeping them would have poisoned bisect. The loss is not the six. It is that the surviving message is the pull request title, and the one thing anybody knew that week, which is why the second approach was abandoned for the third, was never written down. Squashing is not the mistake. Squashing without writing the message is.

Nobody reviewed the conflict resolution. The one I would fix first, because it is invisible by construction. A pull request review shows the branch against the point it diverged from. The resolution is not in that diff: it is typed later, by one person, alone, and it is the only place in the workflow where somebody chooses between two colleagues' code with no second reader. Git will show a merge as a combined diff if asked, and nobody asks. A resolution that drops one side of a change is not caught, because review was never looking there.

The branch got too large to read. A branch that runs six weeks arrives as a diff nobody can hold in their head, so the review becomes an approval with a comment about naming. Both halves of that matter: nothing in it was genuinely read, and it lands as one unit, so the revert is all of it or none. Long branches get argued about as a conflict problem. Conflicts are the cheap part. The expensive part is trading away every granularity you might want later.

Somebody force-pushed to a shared branch. Rewriting a branch other people built on replaces commits they already have, and their next pull produces a mess that looks like their fault. Recovery exists - the old commits sit in the reflog of whichever machine had them, expiring after ninety days by default, or thirty once unreachable - but it needs somebody already confused to know to look. --force-with-lease turns a silent overwrite into a failed command, and a protected branch removes the option outright.

Merge, Rebase, Squash

A merge commit records what happened: two lines of development existed at once and here is where they met. A rebase produces a straight line that is easier to read and is a partial fiction, because the commits it leaves are not the commits anyone wrote. Each is your change replayed onto a base it was never tested against, so in a branch of eight, seven of those states have never existed on any machine. Usually harmless. When it is not, bisect lands on one and you are testing a state manufactured after the fact.

Both are defensible, and the readability complaint against merges is mostly answered by a flag nobody uses: git log --first-parent shows one entry per merged unit and hides the internals, which is the linear history the rebase argument is asking for, on demand, without giving up the record. Squash on merge is the third option and serves the three uses most directly - one tested commit per unit, revert in one command - at the cost of intermediate steps that a large refactor genuinely needed and now cannot show.

What is not defensible is a repository where nobody knows which of the three is in use. Then the log carries all three shapes, --first-parent means nothing because half the units were rebased in flat, revert is a coin flip on whether the target is one commit or fourteen, and every developer resolves the ambiguity privately and differently. The failure is not picking the wrong convention. It is the absence of one, which is the state a repository defaults to while nobody says anything.

What a Message Is For

Almost every commit message I read describes the diff. "Add retry to the payment client." I can see that. The diff is right there, it is authoritative, and it will still be accurate in five years. Restating it is not a small waste; it is the whole space you had, spent on the one thing the repository already knows.

What the diff cannot hold is what the author considered and rejected. That the obvious fix was a longer timeout, which was tried and moved the failure somewhere worse. That the retry count is three because the upstream gateway drops the connection at four and nothing in its documentation says so. That the ugly branch exists for one customer's malformed data and should go when that is corrected. None of it is derivable from the code, and all of it is what the next person needs at two in the morning.

The usual objection is that this belongs in the ticket, and the ticket is where I would look last. Trackers get migrated, keys get renumbered, and a link in a five-year-old commit lands on a login page for a tool nobody pays for. The commit travels with the code, attached to the lines it explains. Conventional Commits is worth having for what it does, which is let a machine generate a changelog and pick a version bump. It puts a grammar on the subject line and says nothing about whether the body is empty.

What a Machine Can Enforce

Some of this can be enforced by a machine, which is the only version that holds after the person who cared moves on.

A merge queue tests the state you will actually get. GitHub's merge queue became generally available in July, and the guarantee is more specific than keeping main green. A green check on your branch means green against the base you branched from, which is not the base you land on if three other pull requests merge while you are at lunch. The queue tests each change together with the ones ahead of it, in landing order. That is the mechanical fix for the precondition bisect needs.

Required linear history buys the absence of ambiguity. Turning it on forbids merge commits and does nothing else. It will not make a commit build, will not make a message say anything, and will not stop a six-week branch arriving as one unreadable unit. It is worth setting once you have chosen rebase or squash, precisely because it stops a third convention appearing quietly, and worth being honest that ambiguity is the entire purchase.

Blame has hygiene settings almost nobody turns on. The day somebody reformats the codebase, every line is attributed to that commit and the record you were relying on is buried. Git takes --ignore-rev, and blame.ignoreRevsFile points at a list of hashes checked in beside the code, so the reformat becomes permanently transparent to blame for everyone. git rerere does the equivalent for a conflict you keep resolving on a long branch. Both take minutes, and both only get done by whoever was annoyed most recently.

The Part That Is Taste

A great deal of git discipline is aesthetic preference defended in engineering language. Subject lines under fifty characters, imperative mood, no period at the end, wrapping the body at seventy-two columns. I have opinions about all of it, and not one of those opinions serves bisect, blame or revert. They serve my sense of how a log should look, which nobody else is obliged to fund with their afternoon.

Worse, the argument is a reliable proxy for something else. The person insisting on a convention is often the one who wants to decide conventions, and a review comment about commit formatting is a cheap way to be senior in a thread. That is legible to everyone in the room, and it is why an engineer on the receiving end of it will assume anyone raising git practice is playing the same game. A team that ships steadily with an ugly history is doing better work than one with a beautiful history and no users.

So the claim gets smaller, and here is how small. Two things I would enforce with a machine: main stays green, and a merged unit is coherent enough to revert on its own. One thing I would ask for and not enforce: when a decision had a rejected alternative, the alternative goes in the commit body. Everything else here - merge against rebase, formatting, whether the log reads linearly - is taste, and saying so when you propose it is what buys you the two rules that matter.

The manager's version is one sentence, and not the one people expect. You are trading a small continuous tax now against an unbounded cost during an incident later, and either side of that trade can be right for a given team. What is never right is not deciding, because the repository does not stay neutral while you think. It accumulates three conventions, and then the question is no longer what to adopt but how to unpick what four people did while nobody was saying anything.

I should be careful how much I claim for the record itself, because I have watched a team with immaculate history debug an incident entirely from logs and never open a terminal. History fixes nothing. It is an instrument, and instruments pay off only in the specific hour when you need to know something the running system has stopped telling you. Most weeks that hour does not come, the tax gets paid anyway, and anyone who tells you the tax is zero has stopped counting.

What stays with me is the asymmetry in who pays. The message goes unwritten by somebody with a deploy waiting, in about ninety seconds; the cost lands on somebody else, two years later, on a night when the answer would have taken one command. That gap is why this never fixes itself, and why it is a process decision rather than a matter of individual discipline. Somewhere in your repository is the commit that explains the whole thing, and its message says fix.