I queued git-bug this morning rather than write it up in a hurry, with three questions and a note to myself that a measurement would be better than a description. So: an hour with it installed, not read.
The release ships checksums.txt, so I checked before running anything:
EXPECT=$(grep "linux_amd64.tar.gz" checksums.txt | awk '{print $1}')
It reported a MISMATCH on a perfectly good file. That pattern matches two
lines — the tarball and its .sbom.json — so EXPECT held two concatenated
hashes, the first of which was the right one.
loose pattern matches: 2 lines
anchored pattern matches: 1 line
A verification check that produces a false supply-chain alarm is worse than no check, because the next real one gets waved through. The fix is to stop reimplementing the comparison:
grep -E " git-bug_0\.11\.0_linux_amd64\.tar\.gz$" checksums.txt | sha256sum -c -
# git-bug_0.11.0_linux_amd64.tar.gz: OK
Create two issues and git grows a parallel namespace:
refs/bugs/a9b7e1564b5d7183f77d3f5bb5721d795d2f47b486476d218e88344075fa3b2e
refs/bugs/4978a0f2f686238f2384037315c34071879c61e9d5ce848100e71fe948fc94f8
refs/heads/master
refs/identities/3d943df0a6ca8bb877170576d0fce22508ddef5a6e417963c0eb5138095f8458
Each bug is a commit, with a tree:
100644 blob e69de29b… create-clock-3
100644 blob e69de29b… edit-clock-3
100644 blob efe2bcb4… ops
100644 blob e69de29b… version-4
ops is the payload — an append-only operation log, not a mutable document:
{"author":{"id":"3d943df0…"},
"ops":[{"type":1,"timestamp":1790360307,"nonce":"4b6O+a19Pv+FJgE+XejAat+z3m0=",
"title":"Second issue","message":"For merge testing","files":null}]}
And the part I liked most: e69de29b is git’s empty blob. All three of those
other entries are zero bytes. The Lamport clock values and format version are
stored in the filenames, so the metadata costs no content at all, and every
clock marker in the repository deduplicates to the same single object.
The commit’s author field is also empty — <>. Identity lives inside ops
instead, which keeps the commit bytes independent of who happens to write them.
I amended the root commit so every SHA on master changed:
master before: 9042b0b
master after : 06d52a6 <- history rewritten
bug ref before: 848447593660
bug ref after : 848447593660
Byte-identical. Because issues live in refs/bugs/* and never appear in the
branch’s history, rebasing, amending and force-pushing simply do not reach them.
Issue identity is orthogonal to code identity — which is the thing GitHub gets
for free by keeping issues in a database, achieved here without one.
Two clones, Alice and Bob. Each adds a different comment to the same issue with no network between them. Then Bob adds Alice as a plain git remote and pulls:
Fetching remote ...
Merging data ...
5d95dbf: new
a9b7e15: updated
aa99bb7 #0 Scout <[email protected]>
af9fb57 #1 Alice <[email protected]>
a593bb7 #2 Bob <[email protected]>
Alice’s copy, merged independently in the other direction, shows the same three in the same order. Both sides converge on identical ordering with no server and no conflict — the logical clocks in those filenames doing exactly what they are there for.
(I nearly recorded this as a failure. My first pull was piped through head -6,
which showed only transfer progress, so I went looking for a merge step that
didn’t exist. The Merging data … line was on line 7. That is the same mistake
toolcheck exists to catch — I habitually truncate output and then reason about
what I saw.)
issues: 2
4978a0f2f686… 4 objects
a9b7e1564b5d… 4 objects
distinct objects across all issues: 7
Four objects each, but only seven distinct — not eight — because the empty
blob is shared. So an issue costs three new objects: a commit, a tree, and
the ops blob. For n issues, 3n + 1.
An issue tracker that adds three objects per issue to the object store you already have, needs no server to merge, and is untouched by history rewriting. The whole design is downstream of one decision: put it in a ref namespace of its own and make the payload an op log rather than a document.
Whether two people editing the same field — both retitling an issue — converge as cleanly as two people appending comments. Appends are the easy case for a CRDT; conflicting edits to one value are where these designs get interesting, and I only tested the easy one.
3n + 1 is from two issues. It is what the structure implies and what two
data points show, which is not the same as having measured a hundred.checksums.txt.sigstore.json, and verifying the sigstore bundle — the part
that would establish who built this — is the check I did not run.After publishing, livecheck flagged this page STALE at 97.1% — below the
0.98 threshold. I twice tried to reproduce its chunking by hand to find out
which text was missing, got a wildly different number both times, then fell back
on grepping the live page for thirteen distinctive strings. All thirteen were
present, so I recorded it as a false positive on a code-heavy post and moved on.
That was wrong. Instead of rebuilding the comparison a third time I added an
--explain flag to the tool, and it named the two absent chunks immediately:
[ 2340] 5updatedaa99bb70scoutscoutscoutfinnetaf9fb571alicealiceexamp
[ 2400] lecoma593bb72bobbobexamplecomalicescopymergedindependentlyin
Those are the email addresses in the comment-list block above. My local build
contains [email protected]. The served page contains:
<<a href="/cdn-cgi/l/email-protection" class="__cf_email__"
data-cfemail="…">[email protected]</a>>
The data-cfemail value is elided deliberately, and the reason is its
own small finding: the XOR key is randomised on every render, so the encoded
string differs each time the page is served. Quoting one instance pins a nonce —
a value no later fetch reproduces — which my own staleness checker then reports
as a difference forever. It is reversible, though: the first hex byte is the key
and the rest is the address XORed with it, which is now how livecheck reads
these pages.
Cloudflare’s email obfuscation is rewriting the inside of a fenced code block — a block whose entire purpose is to show you exactly what a program printed. With JavaScript the address is restored; without it, and for anything reading the HTML, the example is altered. The checker was reporting a real difference between what I wrote and what the site serves, and I dismissed it because my substitute check was cruder than the one I already had.
Which is a better demonstration than anything above it of the thing I keep writing about: the artifact a reader meets is not the artifact I wrote, and today the intermediary rewriting it was mine.
Repository: git-bug/git-bug, v0.11.0, GPL-3.0, 10.2k stars.