Here is what I want: to see how long each deploy took, which runner it ran on,
and the bundle's sha256 next to that commit. Editing the commit message isn't
an option — once written, the message is fixed; it is an input to the hash.

The CI log isn't a full answer either. This repository's own workflow deletes
them: cleanup-actions-storage.yml runs daily at 01:00 UTC and, with the
default keep_days: 3, wipes every run log older than three days via
DELETE /repos/{repo}/actions/runs/{id}/logs. So I checked how much actually
goes away — on a run from 31 October:

$ gh api -i '/repos/<org>/<repo>/actions/runs/41248981144/logs' HTTP/2.0 404 Not Found $ gh api '/repos/<org>/<repo>/actions/runs/41248981144/jobs' --jq ... post started=2026-10-31T22:36:56Z completed=2026-10-31T22:38:11Z conclusion=success runner=gitsrv-slot7-1793461236 

The log is gone but the job record isn't: the duration (75 seconds) and the
runner name are still readable from the API. So two of the three things I
wanted are in fact recorded; what disappears is everything the job printed,
the bundle's sha256 included. And what remains lives in GitHub's API, not in
the repository: whoever clones it sees none of it. What I need is to write
this data next to the code.

Git has a place for exactly this: git notes. It attaches data to a commit
object without touching it, and git log prints that data under the message.
It sounded like precisely what I needed. So I applied it to this blog's
repository: at my pinned b9bb5246 there are 3,625 commits, 3,583 of them
non-merge. I wrote a measurement note on every one of them, pushed to a bare
repository, and cloned from there.

The clone brought no notes. Zero. And that was the cheapest finding of the day;
the more interesting one showed up after I turned on the setting that makes
rebase carry notes.

The ground the measurements stand on: macOS 26.6.2 (arm64, APFS, 4096-byte
blocks), Homebrew git 2.48.1, pin b9bb5246 (4 Nov 2026, 19:36 +0300).
The current git release as I write this is v2.56.0; the only place that
version gap matters is the rebase section, and I say so there.

One note on the output below: the lab repositories and note bodies use Turkish
names — uzak is the remote, kosu a run, sure a duration, olcum a
measurement, yapi a build. I left them as they came out of the terminal.

What a note is and where it goes

A note is just an ordinary blob. Git keeps it inside a tree as a file whose
name is the object ID it annotates
;
git-notes(1)
gives the path shape in a footnote: bf/fe/30/…/680d5a…, two-hex directory
names followed by the rest of the object ID. The default ref is
refs/notes/commits; core.notesRef changes it.

The document's next sentence explains this article's entire cost section:
"Every notes change creates a new commit at the specified notes ref." Adding
a note is not writing a file — it is producing a commit.

One command tells you how much your own repository holds:

git for-each-ref 'refs/notes/**' | wc -l # mine: 0 

Zero. A repository whose first commit landed on 31 March 2026 and which now
carries 3,625 commits has not a single note. So I started from scratch and
measured.

Measurement 1: writing the notes one at a time

I cloned the repository under /tmp (a local, hardlinked clone, 11 seconds),
pinned a pin branch to origin/main, and wrote a note on every non-merge
commit:

git rev-list --no-merges HEAD > commits.txt # 3583 lines i=0 while read -r c; do i=$((i+1)) git notes add -m "olcum: sira=$i" "$c" done < commits.txt 

Three runs took 37.47 / 37.83 / 38.02 seconds — median 37.83 seconds. (My
first attempt took 53 seconds; in that run the repository had 21 packs, and
after git gc the time settled at this level. I am not publishing a
single-run number, because those 15 seconds were about the repository's state,
not about git.)

I ran the same loop against three separate notes refs; the ref output below is
from the first run. The result is correct: git notes list | wc -l says 3,583
and git log prints the note. The bill is here:

$ git rev-list --count refs/notes/commits 3583 $ git log --oneline -1 refs/notes/commits 05d2b18a Notes added by 'git notes add' 

3,583 notes, 3,583 commits. Exactly what the document said. And how many
objects is that?

commit count= 3583 raw_bytes= 1107099 tree count= 7156 raw_bytes= 26733980 blob count= 3583 raw_bytes= 59804 TOTAL count=14322 raw_bytes=27900883 (26.61 MiB) 

Look at the ratios: the content of the notes is 59,804 bytes, i.e. 58.4 KiB.
The trees are 26.7 MB. 95.8% of the raw bytes are tree. Adding a single note
and watching the loose-object counter showed why — delta 4: the note blob
(10 bytes), the subtree it lands in (924 bytes, 14 entries), the root tree
(7,424 bytes, 256 entries) and the commit. Those four apply once the tree has
fanned out; before the fanout, in a fresh repository, the delta is 3 (blob +
root tree + commit, no subtree). Once fanned out, the root tree has 256 entries
and is rewritten from scratch on every add: a ten-byte note costs 8.3 KB of
tree.

On disk it looks even wilder. Before the loop, loose objects were 240 / 5.65
MiB; after, 14,562 / 74.48 MiB. If the raw data is 26.61 MiB, where does the
rest come from? APFS has a 4096-byte block size: a 10-byte blob still eats 4
KiB. git gc took nine seconds to bring loose objects to zero, and the pack total
actually dropped from 328.64 MiB to 322.30 MiB — because the same gc went
from 21 packs to 2, and that saving outweighed the 26.6 MiB just added.

Packed, the notes ref costs this much on its own:

What Objects Packed size
3,583-commit notes history 14,322 1,498,535 bytes (1.43 MiB)
Only the final state (1 commit + 257 trees + 3,583 blobs) 3,841 293,658 bytes (287 KiB)

The history is five times more expensive than the data it carries. Why pack
sizes move around this much is a separate story; that side got its own
measurement in packing the same commit twice.

Why the root tree has 256 entries: the fanout threshold

That 7,424-byte root tree has 256 entries — one directory per possible two-hex
prefix. But git doesn't build it that way from the start. I added notes one at
a time to an empty ref and watched when the root tree split into directories:

FANOUT started at note 73; root entry count: 63 

Up to the seventy-second note everything sits flat in the root; at the
seventy-third git converts the layout to 2/38. The root shows 63 entries
rather than 73 for a simple reason: among 73 commit IDs there are ten
collisions in the two-hex prefix, and colliding IDs share a directory. The deciding code is
determine_fanout in notes.c,
and its comment describes a simple heuristic: if all sixteen buckets at the
first nibble level are internal nodes or subtrees, there are probably plenty of
notes below, so increment the fanout. Since filling all sixteen buckets is a
question of distribution, the threshold is not universal; your repository will
produce a number other than 73. What doesn't change: once fanned out, every add
rewrites both the subtree and the 256-entry root — that is why 95.8% of the raw
bytes are tree.

Measurement 2: writing the same 3,583 notes in one commit

The 3,583 commits produced by the git notes add loop carry no information;
they all have the same message. Git has its own tool for bulk writes:
git-fast-import(1)
accepts a notemodify command in the commit grammar. I generated the stream
with Python:

out = ['commit refs/notes/batch\n', 'committer Mustafa Erbay <mail> 1762322000 +0300\n'] msg = 'Notes added by fast-import (one commit)\n' out += ['data %d\n' % len(msg.encode()), msg] for i, c in enumerate(commits, 1): body = 'olcum: sira=%d\n' % i out.append('N inline %s\ndata %d\n%s' % (c, len(body.encode()), body)) 

A 267,749-byte stream, then git fast-import --quiet < notes.fi. Three runs:
0.105 / 0.099 / 0.098 seconds — median 0.099 seconds, one commit.

Is the data byte-for-byte the same? I compared the trees the two paths
produced:

$ git rev-parse 'refs/notes/commits^{tree}' 'refs/notes/batch^{tree}' 880a8cdef5b617b6bbd22e9b60b25c11bb149152 880a8cdef5b617b6bbd22e9b60b25c11bb149152 

Same ID. So the same 3,583 notes can be written in 0.099 seconds instead of
37.83, and in 1 commit instead of 3,583 — a factor of 382. If you are about to
write a CI script that adds notes one by one, look at that number first.

The read side is nearly free

Seeing the write cost, I got worried about reads too: does git log slow down
in a repository with 3,583 notes? The measurement walked all 3,625 commits
twice, once without notes and once with the %N placeholder that prints them:

--no-notes : 0.023 0.023 0.023 0.024 0.024 | median 0.023 s notes + %N : 0.026 0.027 0.027 0.027 0.027 | median 0.027 s 

Five runs after one warm-up pass; four milliseconds between the medians. And that is the run where all 3,583 note lines really were
printed (git log --notes --format='%h|%N' | grep -c returns exactly 3,583).
The reason is clear: the notes live in a single tree, which git loads once and
then searches in memory. The asymmetry is sharp — writing a note costs as
much as producing a commit, reading one costs as much as loading a tree. So
notes should be designed as "write once at the end of the run", not "append a
line on every step".

No default carries it

This is where believing notes are "data next to the commit" ends. I created a
bare repository and pushed the branch:

$ git push uzak pin:refs/heads/main $ git --git-dir=uzak.git for-each-ref --format='%(refname)' refs/heads/main 

No notes. git push --all didn't bring them either; that means refs/heads/*,
not refs/notes/*. With an explicit refspec they went:

$ git push uzak 'refs/notes/*:refs/notes/*' * [new reference] refs/notes/batch -> refs/notes/batch * [new reference] refs/notes/commits -> refs/notes/commits 

Then I cloned that bare repository. In the clone, git notes list | wc -l
returned 0, and a plain git fetch origin left it at 0. No surprise:
git-clone(1)
says a clone initializes remote.origin.fetch, and
git-fetch(1)
gives that variable's typical value as
+refs/heads/*:refs/remotes/origin/* — notes refs don't match that pattern.
With the refspec given by hand, all 3,583 notes arrived. git clone --mirror
did bring them, because a mirror copies every ref.

Diagram

The durable fix is to write the refspecs into the remote:

git config --add remote.origin.fetch '+refs/notes/*:refs/notes/*' git config --add remote.origin.push 'refs/heads/*:refs/heads/*' git config --add remote.origin.push 'refs/notes/*:refs/notes/*' 

After that, a plain git fetch origin brought the notes and a plain git push
tried to push the notes ref. One warning: the + on the fetch line means
force, so the remote notes ref overwrites your local one. If you have a note
you haven't pushed yet, you lose it silently.

Rebase: drops them by default, misplaces them when configured

I set up two twin repositories: one with defaults, one with notes.rewriteRef
configured. Both have two annotated commits on an is branch, and in both the
branch is rebased onto an advanced main.

Repository Notes after rebase
defaults gone on both new commits
notes.rewriteRef=refs/notes/commits both copied

git-config(1)
states the reason in one sentence: "Does not have a default value; you must
configure this variable to enable note rewriting."
notes.rewrite.rebase
defaults to on, but since nobody says which ref to carry, nothing is carried.
This is why people say "rebase deleted my notes" — nothing is deleted; the note
stays on the old commit that is no longer in the branch.

Turning the setting on revealed a second, nastier behaviour. The scenario comes
straight from real life: a small fix on my branch also landed on main, so
during the rebase that commit becomes empty and is dropped.

$ git rebase main ... dropping B: tek satirlik duzeltme -- patch contents already upstream $ git notes show HEAD olcum: A olcum: B 

The measurement of the dropped commit B has stuck to commit A, the one picked
right before it. Since notes.rewriteMode defaults to concatenate, the two
measurements are stacked on top of each other. So I now have a measurement
sitting next to commit A that does not belong to A — nearly impossible to
notice unless you know it is a reported bug.

It is a git bug, fixed in
42554b78fd.
The patch message describes exactly this: because the dropped commit is
recorded as rewritten, its notes get copied to the commit picked immediately
before it. The
Git 2.56 release notes
list the fix. The measurement above ran on 2.48.1 and shows that version's
behaviour. On 2.56 and later the dropped commit's note is no longer copied — I
could not verify that on my own machine, because the git I have is 2.48.1 and I
did not upgrade for this measurement. The patch and the test it adds
(t3400-rebase.sh: "rebase drops notes of dropped commits") are the evidence.

The practical upshot: turning on notes.rewriteRef saves your notes, but if
your git is below 2.56 it also brings a mis-attachment risk. My own preference
would be to keep measurement notes only on commits that land on main, not on
branches that get rebased.

If you'd rather not leave the carrying to git, there is a manual path; you feed
old/new ID pairs through a pipe:

printf '%s %s\n' "$old" "$new" | git notes copy --stdin -f 

This also adds one commit to the notes ref (mine went from 3,583 to 3,584), but
you decide which note goes where. Feeding mapping lists such as
.git/rebase-merge/rewritten-list into this command is the hand-made version
of what notes.rewriteRef does.

Separate namespaces and non-commit objects

Notes don't only attach to commits. I tried the example from the docs: I wrote
a build output as a blob, made it HEAD's note, and then added a note to that
blob itself.

blob=$(echo "build output" | git hash-object -w --stdin) git notes --ref=yapi add --allow-empty -C "$blob" HEAD git notes --ref=yapi add -m "this is a blob note" "$blob" 

Both worked. That opens the way to splitting measurements across refs by
subject: refs/notes/olcum, refs/notes/inceleme, refs/notes/yapi. But
there is a price — git log's default display shows only
refs/notes/commits. For the others you need notes.displayRef or
--notes=<ref>:

$ git log -1 | grep '^Notes' Notes: $ git -c notes.displayRef='refs/notes/*' log -1 | grep '^Notes' Notes: Notes (olcum): 

(From a clean repository with three refs set up; refs/notes/yapi is missing
from the list because its note is attached to a blob, not to HEAD.)

So if you use separate namespaces, whoever reads the data also has to configure
their side. Telling your team "the measurements are inside git" does not mean
they will see them.

If two runs write to the same commit

A notes ref is a ref, so the concurrency rules are the same. Two separate
clones wrote a note to the same commit without seeing each other. The second
one's push:

 ! [rejected] refs/notes/commits -> refs/notes/commits (non-fast-forward) 

It behaves like a branch: fetch, merge, push again. git notes merge defaults
to the manual strategy, i.e. it makes you resolve the conflict:

CONFLICT (add/add): Merge conflict in notes for object 77f7d0ff... Automatic notes merge failed. Fix conflicts in .git/NOTES_MERGE_WORKTREE... 

For a script running inside CI, that is death. -s union kept both:

$ git notes merge -s union refs/remotes/origin/notes/commits Concatenating local and remote notes for 77f7d0ff... $ git notes show HEAD kosu3: sure=52s kosu2: sure=39s 

The permanent form is git config notes.mergeStrategy union. If not losing
data matters to you, that's the right family; if you want a single correct
value, ours/theirs exist, but then which run wins depends on the race. For
the long run I would write cat_sort_uniq rather than union: union
concatenates on every retry, while cat_sort_uniq won't keep the same line
twice.

This is not a remote possibility. The organisation that runs this repository's
workflows currently has eleven registered runners
(gh api orgs/<org>/actions/runners counts them); if two jobs try to write a
note to the same commit in the same minute, the second one sees that rejection
unless a fetch lands in between. For the record: no workflow in this repository
writes notes today (grep -rn "git notes" .github/ returns nothing), so the
scenario is for a pipeline I am designing. So the CI step that writes notes needs to be a loop, not a
one-shot push:

pushed=0 for attempt in 1 2 3; do git fetch -q origin 'refs/notes/*:refs/notes/*' || true git notes merge -q -s union FETCH_HEAD 2>/dev/null || true git push -q origin 'refs/notes/*:refs/notes/*' && { pushed=1; break; } sleep $((attempt * 2)) done [ "$pushed" = 1 ] || { echo "::error::notes push failed"; exit 1; } 

That last line isn't decoration. My first version didn't have it, and running
the loop against an unreachable remote returned exit code 0: with all three
attempts failing, the exit status comes from sleep, and not even
set -euo pipefail catches it. The recipe itself was producing the silent loss
I am about to complain about.

The branch version of that loop is already written in this repository:
social-post.yml carries a five-attempt fetch + rebase + push wrapper,
editorial-series-publish.yml five, content-generate.yml three. The notes
ref needs the same, because the rules of being a ref are identical. The
difference: when a branch push is rejected, CI turns red and you find out; when
a notes push is rejected, it disappears inside a quiet || true.

Orphan notes and cleaning them up

After the rebase, the notes stayed on the old commits. So do those old commits
survive because of their notes? No — because the path name in the tree is
an ID, not a pointer to the object:

$ git reflog expire --expire=now --all && git gc --prune=now $ git cat-file -t 3f7a7de fatal: Not a valid object name 3f7a7de $ git notes list | wc -l 2 

The object is gone, its note remains. The only thing linking them was a
filename. git notes prune does the cleanup (the doc: "Remove all notes for
non-existing/unreachable objects"
) and it took both orphan notes, dropping the
count to 0. So a notes ref won't bloat your repository, but it won't take out
its own garbage either.

How much it grows: a derived estimate

An estimate stood here first: 293,658 bytes / 3,583 notes = 82 bytes per note,
a realistic note is sixteen times that, so 400-500 KB. I deleted the estimate
and measured instead, because measuring takes 0.1 seconds. The same 3,583
commits, this time with a real measurement note (deploy duration + runner name

  • bundle sha256 + workflow name, 160 bytes on average), packed as a snapshot: 569,593 bytes, 3,841 objects. A note sixteen times larger grew the file only 1.9 times — because of those 293,658 bytes, 243,902 are tree and only 59,804 are note content; growing the note grows the blob side alone. That is why my linear estimate was inflated.

Next to a 328.64 MiB repository that is noise. The place to watch isn't the
snapshot but the history: when I wrote the same 3,583 notes one at a time, the
packed history was 1.43 MiB, five times the final state. A pipeline that adds a
note per commit on every CI run will, over years, grow a notes ref many times
larger than the data it carries. Flattening a notes ref's history is possible
(drop the old commits, rebuild with a single commit-tree), but then you lose
the "who wrote this note and when" information. It's a trade-off; my preference
is to keep the history and reduce note writing to one commit per run.

Why I didn't pick the alternatives

The same need has three other solutions; notes won on the strength of the
others' flaws:

A commit trailer (a line like Deploy-Duration: 41s). The most visible
path, readable with git log --format=%(trailers), requires no refspec. But
writing data into the commit message means knowing the data while the commit
is being written. Deploy duration is born after the commit; adding a trailer
means amending, i.e. changing the hash. Changing the hash of a published commit
breaks everything running in this blog's publishing pipeline.

A file in the repository (something like measurements.jsonl). It travels,
it's visible, merges resolve its conflicts. But every measurement is a new
commit, every commit triggers a new deploy, and the commit I make to record a
measurement produces its own measurement. I don't want to chase the tail of the
tail.

An external database. The most flexible option, the easiest to report on.
The price: the data doesn't come with a clone, a database that isn't backed up
loses the measurements, and the "next to the commit" property disappears
entirely.

git notes sits in the gap between those three: it doesn't touch the commit, it
doesn't trigger a commit, and it lives with the repository. In exchange it
leaves the transport to you — the rest of this article is exactly that invoice.

The usage frame I ended up with

I decide with four questions:

  1. Does the data belong to the commit or to the run? Deploy duration belongs to the commit, the runner's name to the run. Writing the second as a note produces a conflict the moment the same commit is deployed twice.
  2. Who will read it? What displays notes is the git log family; the notes.displayRef documentation says so explicitly. Only refs/notes/commits shows up on the default screen, so if you use a separate ref like refs/notes/olcum, the reading side has to be configured too.
  3. How many, and how often? A few notes per run is fine with git notes add. For bulk writes switch to fast-import: the difference here was a factor of 382.
  4. Does it need to travel? If it does, configure the refspecs today. Otherwise the notes stay on the machine that wrote them.

These are the six lines the setup needs:

# NO leading +: a forced fetch overwrites the local note before any merge git config --add remote.origin.fetch 'refs/notes/*:refs/notes/*' git config --add remote.origin.push 'refs/heads/*:refs/heads/*' git config --add remote.origin.push 'refs/notes/*:refs/notes/*' git config notes.mergeStrategy cat_sort_uniq git config notes.displayRef 'refs/notes/*' # Rebase carrying: ONLY on git >= 2.56 git config notes.rewriteRef refs/notes/commits 

The last line is separated on purpose: on a git below 2.56 that setting can
paste a dropped commit's note onto a different commit.

Three questions I am leaving open

Three things I could not answer while finishing this, written down here so that
nothing unmeasured reads as measured.

Who protects the notes ref? GitHub's ruleset documentation speaks only of
branches and tags as targets, and branch protection only of branches;
refs/notes/* is not mentioned anywhere in those pages. So I cannot say, on
documentation alone, whether a job carrying contents: write could force-push
the notes history away. For data meant to serve as measurement evidence, that
is the first question to ask.

When should the history be flattened? git gc does not shrink a notes
ref's history. I did not measure the threshold (how many commits, how many MB).

What does fetching cost? When a notes ref reaches tens of thousands of
commits, how long does git fetch 'refs/notes/*' take? I did not measure that
either.

Closing

Three things came out of this measurement, and all three are more concrete than
"git leaves the configuration to you". First, the asymmetry: writing a note
costs as much as producing a commit (3,583 notes = 3,583 commits, 37.83
seconds), reading one costs as much as loading a tree (4 ms). That alone
dictates how the tool should be used — write in bulk, read often. Second,
transport is left entirely to me: no push, no fetch, no clone. Third, and the
nastiest, when I do hand the carrying to git the data can stick to the wrong
commit; that is worse than losing it, because it answers wrongly in silence.

So here is the decision: notes go into this pipeline, but on a short leash —
only on commits that land on main, one fast-import write per run, a separate
refs/notes/olcum ref, and rebase carrying left off. Rebase carrying comes
back on the agenda the day I move to git 2.56.

My previous measurement on this repository found that
commit-graph had skipped the changed-path Bloom filters;
the lesson there was "git provides the capability and leaves the configuration
to you", and it holds here too. But what this article adds isn't that
generalisation — it is the third point above: even with the configuration
right, a git older than 2.56 can file my measurement next to somebody else's
commit. (A small footnote: the neighbouring
commit-message measurement
had a pin an hour older and a non-merge count of 3,580; this one's pin gives
3,583. Of the three commits in between, two came from my social-posting bot and
one is my own commit publishing that article.)

Official Sources

The source-code and release-note links in the body are pinned to the v2.56.0
tag; the list below points at the current version of the same documents.