DEV Community

Cover image for I Piled Up Seventy Branches; Six Were Really Unfinished
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Piled Up Seventy Branches; Six Were Really Unfinished

I typed git branch in the blog repo and the screen started scrolling. I counted:
71 lines. One is main; the other 70 are places where I once started something and
walked away.

71 local branches (including main) · 56 remote branches
tip commit by month:  2026-05: 4   06: 15   07: 21   08: 7   09: 23   10: 1
Enter fullscreen mode Exit fullscreen mode

Every number in this article belongs to a single moment: the morning of 1 October 2026,
with origin/main at 4702bc4e. A branch list is a living thing; by the time you read
this it will have changed on my side too.

Four of them date back to May. They have been sitting there for four and a half months
and I cannot remember what any of them are.

What I felt looking at that list was not a technical feeling. It was the "I have this
much unfinished work" feeling — a to-do list nobody wrote, growing on its own. I wanted,
for once, to turn that background hum into a number. This article is the story of that
count, and the count ended differently than I expected: I asked about unfinished work in
four separate ways, got four separate answers, and the real debt turned out to be exactly
where I had not been looking.

Seventy-one lines, zero information

Let's admit something first: the length of git branch output measures nothing. Creating
a branch is free; it amounts to writing a forty-character commit id somewhere. And I
created one in every agent session, every experiment, every "let me just poke at this"
moment all year. Of the 70 side branches, 23 carry the claude/ prefix — working areas
that were opened automatically.

Still, every time I open that list it says the same thing to me: you left a lot of work
half-done.
Here is the interesting part — git does not manufacture my guilt, I do; git
merely stores it, and I pay interest on it with every branch call. I was also reluctant
to delete the branches, because of the fear that "there might be something in there." I
had never once checked whether that fear was justified. Not in four and a half months.

To get from a number to information, I had to ask a question. I did not know how many
questions it would take.

First question: ancestor or not

The most familiar question, and the easiest. git branch --merged and --no-merged. The
documentation describes what they do without any embellishment:

"With --merged, only branches merged into the named commit (i.e. the branches whose
tip commits are reachable from the named commit) will be listed."

So the test is one single thing: can the branch's tip commit be found by walking
backwards from the named commit? Reachability. A genealogy query.

git branch --merged origin/main    → 46
git branch --no-merged origin/main → 24
Enter fullscreen mode Exit fullscreen mode

Forty-six branches are closed, provably. That leaves 24, and my first reflex was to read
that as "24 unfinished jobs." So this was the numerical equivalent of four and a half
months of humming.

It was not. Because this command does not know how I work.

Second question: is the content the same

Most of the work I do by hand in this repo lands through pull requests, squashed; the rest
of the commit traffic is the bot's content generation. GitHub's own documentation states
the outcome of a squash plainly:

"Squashing turns all commits in the pull request into one commit on the base branch."

GitLab describes the same behaviour on its side as "Squash and merge combines multiple
small commits into a single meaningful commit." So this is not a GitHub quirk but a common
way of merging.

The result: a new commit, a new identity. My branch's tip commit will never appear in
main's history, because what entered main is not a copy of it but a summary. The
content landed; the genealogy did not. --no-merged cannot distinguish this case, and it
is not supposed to — I asked it a different question.

The command that asks the right question is git cherry. It looks at the diff, not the
genealogy:

"The equivalence test is based on the diff, after removing whitespace and line numbers."

A commit marked - has an equivalent upstream; one marked + does not. The idea
underneath is git patch-id; its documentation defines a patch id as "a sum of SHA-1 of
the file diffs associated with a patch, with line numbers ignored," and it ties the two
commands together explicitly: "git-cherry shows what commits from a branch have patch ID
equivalent commits in some upstream branch." So if a patch does the same work, it yields
the same fingerprint regardless of where it was applied or which commit id it carries.

I ran all 24 branches through this question:

commits on the 24 branches that main lacks : 34
of those, with an equivalent upstream      : 20
without an equivalent                      : 14

branches with no unique commit at all      : 15
branches with unique commits               :  9
Enter fullscreen mode Exit fullscreen mode

Now this number says something. Fifteen of the 24 "unfinished" branches are completely
empty
— the work every commit inside them does is already in main, just under a
different identity. fix/pace-date, fix/pace-runner, fix/pace-url-encoding,
fix/devto-mermaid-restore... The output is unambiguous, too; for fix/pace-runner the
command returns a single line, and the mark at the start of that line says everything:

$ git cherry -v origin/main fix/pace-runner
- c36fff1b539c6066e23c02e3d03b2fe99a171f9f fix(ci): pace-check hosted runner yerine self-hosted
Enter fullscreen mode Exit fullscreen mode

Minus. It has an equivalent. The work inside a branch I had been reluctant to delete for
sixteen days had already landed in main. Fifteen of the 24 suspects dissolved at this
step.

I could have stopped here and congratulated myself. Nine instead of 24, how nice. But
there is something git cherry cannot tell you either, and this time it errs in the
opposite direction: it compares diffs, not intentions. If I rewrote the same work, the
two patches look different and the commit gets marked +. So 14 does not mean "open
work"; it means "possibly open work."

That leaves exactly one method, and it cannot be automated: open the nine branches one by
one and read them.

Third question: was this work really not done

I opened the nine branches in order, looked at which files they touched, then searched for
those files in origin/main. For three of them the answer was clear — the work landed, the
branch is redundant:

claude/calendar-atomic-write — the commit message reads "wip: local working session
sync." Inside it are AnimatedPostCover.astro and copy-blog-assets.mjs. Both are sitting
in main. The patch looks different because this commit froze half a session as-is; the
work itself landed through a proper PR.

fix/topic-research-quota-fallback — two commits, 894 added lines; the job of decoupling
topic verification from the Gemini quota. I searched main for the SEARCH_STOPWORDS and
TR_FOLD definitions; both are there. It landed, but then evolved, which is why the diff
no longer matches.

fix/self-host-workflows — on 21 July I opened this to move the keep-alive and lighthouse
jobs onto my own runner. In main today, line 27 of keep-alive.yml and line 17 of
lighthouse-audit.yml say the same thing: runs-on: [self-hosted, itwise-mac]. The goal
was reached — two months later, by a different commit, with a note dated 26 September. The
branch's intention came true; the branch itself became redundant.

Three branches, four commits, zero debt. The remaining six are another story.

Six that are real

Diagram

For each of the six I looked for the evidence in main and failed to find it:

Branch Work Evidence
claude/elegant-bassi-fa298f never-404 hardening, 5 commits, 9 June 500.astro, en/500.astro, maintenance.html absent; zero trace of 500 in the middleware
fix/callout-structure-gate Callout structure gate, 27 July mdx-structure.ts absent; validateMdxStructure is imported by neither of the two validators
fix/accuracy-source-coverage widening the source allowlist, 21 August the allowlist lacks caddyserver.com, libvirt.org, linux-kvm.org, isc.org, letsencrypt.org; source-policy.mjs is 242 lines in main, 292 on the branch
fix/unconfirmed-rejection-not-permanent stopping an unverified rejection from eating the queue, 2 September topic-research-chain.mjs in main is 283 lines, zero relevant trace
claude/dreamy-maxwell-17b977 caption timeout + outage breaker, 18 September provider-outage.mjs absent; main still uses 5- and 15-second timeouts
claude/beautiful-noether-e1c690 the ispmanager review article, 21 September file absent from main; zero hits across the 1300 records of the live feed; URL returns 404

Seventy came down to six. So 91 percent of the humming was not real.

Where age says nothing

Once the count was done I was left holding an intuition, and I wanted to test that too:
an old branch is a dead branch, a new branch is live work. It sounds reasonable. I sorted
both lists by date.

Of the 15 empty branches, the oldest is 16 May (138 days) and the newest is 23 September.
Of the 6 genuinely open ones, the oldest is 9 June (114 days) and the newest is
21 September. The two ranges almost entirely overlap.

The intuition was wrong — but exactly how it was wrong is the interesting part. One side
of it holds: of the four branches left over from May, one had already been merged and the
remaining three are still on the list, and all three are empty; so the fear that "they
have been sitting there for four and a half months" had precisely zero substance behind
it. But the 114-day-old elegant-bassi is sitting right there too, carrying five commits
of real work. Age indicates neither innocence nor debt. An aged branch has either found
its own way in or is waiting somewhere nobody looks, and you cannot read which from its
date.

That is why there is no shortcut around the third step. Whatever filter I tried, the only
thing that answered the question was looking at the file.

What the minus sign does not say

The method's most important blind spot was sitting inside my own data.

claude/beautiful-noether-e1c690 carries two commits. One +, one -:

$ git cherry -v origin/main claude/beautiful-noether-e1c690   # SHAs abbreviated
+ fd33e470  feat: yeni makale — ispmanager Lite'ın Altına Baktım: 23 Bulgu
- ea466a4b  fix(ispmanager): final denetim — yayın 25 Eyl, disclosure, kanıt düzeltmeleri
Enter fullscreen mode Exit fullscreen mode

The equivalent of the minus-marked ea466a4b really does exist in main's history:
6003feb2. But there is one more line right after it:

fd17e0ff Revert "fix(ispmanager): final denetim — yayın 25 Eyl, ..."
         This reverts commit 6003feb2381fc9f5877c6ef687ed1f58dac3e77b.
Enter fullscreen mode Exit fullscreen mode

So the minus in git cherry does not mean "this work is live"; it means "an equivalent of
this entered upstream at some point." If it entered and was later reverted, the mark does
not change. I caught it only because this branch also carries a + — had the branch held
that - commit alone, my four-step method would have called it "empty, safe to delete."
And the work is not live.

Catching your own method with your own data is a strange feeling. The thing you are
measuring turns out to be measuring your instrument too.

Two correct notes, two jobs never done

Two of the six are where I actually stopped — but not for the reason I expected.

For claude/dreamy-maxwell-17b977 I wrote this on 18 September: the social posting
pipeline hung for 40 minutes, the cause was a fetch without a timeout, the fix is this
module. The note's last line reads: "Awaiting verification: check this line in the first
social-post log after the merge."
I asked gh:

$ gh pr view 13 --json number,state,mergedAt,headRefName
{"headRefName":"claude/dreamy-maxwell-17b977","mergedAt":null,"number":13,"state":"OPEN"}
Enter fullscreen mode Exit fullscreen mode

Still open. Open for thirteen days. The note was not wrong — on the contrary, it said
plainly that it was waiting for a merge. That merge simply never happened, and nobody
went back to check. The code in main today still runs with the old timeouts; that
40-minute hang could happen again tomorrow.

The second one is starker. For the ispmanager review, my note records in three separate
places that the article never went live, including the verification of the live 404. The
note even contains the command to run on 25 September: revert the two revert commits, push,
trigger the deploy run. The command was written correctly. It was never run. Today the
article is absent from the 1300 records of the live feed and the URL still returns 404.

The story I expected was "my notes misled me." The real story is more uncomfortable:
neither git nor my notes were wrong. Both were correct, both were right there, and nobody
went back to either. I had nothing at all that distinguishes a decision written down from
a job carried out — and that gap was sitting inside the branch list the whole time,
invisible because a list of names cannot show it.

The feeling was familiar. Five days ago I wrote about forming the right hypothesis two
months early and losing it
; the day
after that, about rolling back in ninety seconds and leaving things broken for five
hours
. All three share
one thing: the record was there, and nobody read it.

How I count now

Four steps, in order. If you want to try it on your own setup:

  1. Do not mistake a number for information. git branch | wc -l produces a feeling, not data. The only job of this step is to accept that counting is not enough.
  2. Ask the genealogy (--merged / --no-merged). Which branches closed is proven. The ones that look open are suspects, not convicts.
  3. Ask the diff (git cherry origin/main <branch>). This is where you catch work that landed via squash. For me, 15 of the 24 suspects dissolved at this step.
  4. Read the survivors by hand. Rewritten work does not resemble the diff; no automation that skips this step can answer "was this work done."

Three traps caught me while applying the fourth step, and I am writing all of them down:

  • A - does not mean "live" (the section above). Work whose equivalent landed and was then reverted also shows as -. If in doubt, look at what came after the equivalent commit.
  • git branch -d refuses these branches. The documentation's condition is explicit: the branch must be fully merged in its upstream branch or in HEAD. Those 15 branches are --no-merged by definition, so you have to use -D. The evidence you gather does not complement the safety of -d; it replaces it — so do not type -D without gathering it.
  • A branch attached to a worktree cannot be deleted. In this repo four branches are currently held by a worktree, one of them from the "genuinely open" six. You have to remove the worktree first.

And one area the recipe does not cover at all: all of this is local. I have 56 remote
branches against 70 local ones, and deleting a local branch does not delete
origin/<branch>. A branch that exists only on the remote never appears in git branch
output at all — so this count is itself incomplete.

What I took away

The count did not show me my unfinished work. It showed me how I audit myself.

For four and a half months I looked at a seventy-line list and felt vaguely bad, because
staying vague was free. Reading nine things one by one was uncomfortable — and it was
precisely that discomfort that found two jobs never done. Vague guilt turns out to be a
defense mechanism: as long as I know nothing for certain, I never have to admit that
something clearly did not get done either.

A number is a feeling, a diff is a fact, and only reading tells you which one is right.
But I saved the real lesson for last: in this count, no record lied. Git was right, my
notes were right, the PR's status was in plain view. The only thing missing was someone
going back after writing. Black boxes spend their worst nights without telling anyone;
mine had recorded everything properly — nobody opened the lid.

Official Sources

Top comments (0)