ULID checkpoint refs become permanently unreadable when their shard case-collides with an existing shard on a case-insensitive filesystem (macOS/Windows)
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 5.1k
- Forks
- 475
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 178
Description
Summary
On a case-insensitive filesystem (macOS APFS default, Windows NTFS default), a ULID-format checkpoint (git-refs store) can be written successfully to disk but then be permanently invisible to entire checkpoint list / entire checkpoint explain, with no error at write time. The underlying git object is intact — only the read-side parser rejects it.
Environment
entire version:0.10.7-nightly.202609120622.174dca012- OS: macOS (Darwin 25.6.0, arm64), default APFS volume (case-insensitive, case-preserving)
- Git: 2.54.0, object format sha1, loose refs (no reftable extension)
- Checkpoint store: git-refs backend
Root cause
CheckpointID.ShardFor() (cmd/entire/cli/checkpoint/id/id.go:132-138) shards a checkpoint ref by the last two characters of the ID, case preserved:
func (id CheckpointID) ShardFor() string {
s := string(id)
if len(s) < 2 {
return s
}
return s[len(s)-2:]
}
A ULID checkpoint ID is canonically uppercase Crockford base32 (enforced by isULID's round-trip check). A legacy 12-char hex checkpoint ID is always lowercase (hex.EncodeToString in Generate()).
When a repo already has a legacy-hex checkpoint whose shard happens to be, say, 6b, and a new ULID checkpoint's correct shard is 6B (same two characters, different case), git — on a case-insensitive filesystem — resolves both to the same physical directory when creating the loose ref. The new checkpoint's ref is silently written inside the pre-existing lowercase-named directory instead of a new one.
ParseRef() (cmd/entire/cli/checkpoint/refs_naming.go:41-60) then re-derives the expected shard from the ID and requires an exact, case-sensitive match against the ref's actual path component (a deliberate safety check against malformed/tampered refs):
cid := id.CheckpointID(rest)
if cid.ShardFor() != shard {
return id.EmptyCheckpointID, false
}
Since the ID's real shard (6B) doesn't match the directory it was actually written under (6b), this fails and the ref is treated as not a checkpoint ref at all — even though the git object holding the checkpoint is present and uncorrupted.
Net effect: the checkpoint is written with no error, then becomes permanently unreadable, with a confusing "checkpoint not found" at read time and no diagnostic pointing at the cause. entire doctor does not currently detect this condition either.
Reproduction
- In a repo using the git-refs checkpoint store, have at least one existing legacy-hex checkpoint whose shard (last 2 chars) is, e.g.,
6b— this happens naturally over time since legacy shards are always lowercase hex. - On a case-insensitive filesystem, create a new checkpoint that happens to generate a ULID ending in the same two characters case-insensitively (e.g.
...76B, shard6B). This is a 1-in-256-ish chance per new ULID checkpoint once at least one lowercase-hex shard exists in that bucket, so it surfaces over time in ordinary use, not artificially. - Observe:
$ entire checkpoint list (empty — the new checkpoint does not appear) $ entire checkpoint explain <commit-sha-of-the-new-checkpoint> commit <sha> references checkpoint 01M2DCHJCHTR9T9MZTSB7WV76B via its Entire-Checkpoint trailer: checkpoint not found: 01M2DCHJCHTR9T9MZTSB7WV76B - But the ref is actually present:
Both checkpoints share the literal, byte-confirmed ($ git for-each-ref refs/entire/ ... 55efa918... commit refs/entire/checkpoints/6b/01M2DCHJCHTR9T9MZTSB7WV76B 4d64dfdf... commit refs/entire/checkpoints/6b/0da4f302686b0x36 0x62= ASCII6b) directory6b— a legacy-hex checkpoint and a ULID checkpoint whose real shard should have been6B.
Verification performed against the actual source (not just log inspection)
Ran the real production functions from cmd/entire/cli/checkpoint/id and cmd/entire/cli/checkpoint directly against the on-disk data:
cid := id.CheckpointID("01M2DCHJCHTR9T9MZTSB7WV76B")
cid.Kind() // => KindULID
cid.ShardFor() // => "6B" (NOT what's on disk)
checkpoint.ParseRef("refs/entire/checkpoints/6b/01M2DCHJCHTR9T9MZTSB7WV76B")
// -> id="", ok=false <- reproduces the "checkpoint not found" failure exactly
checkpoint.ParseRef("refs/entire/checkpoints/6B/01M2DCHJCHTR9T9MZTSB7WV76B")
// -> id="01M2DCHJCHTR9T9MZTSB7WV76B", ok=true <- confirms this is the only thing standing between "works" and "invisible"
Also confirmed:
- The filesystem in question is case-insensitive (
mkdir /tmp/case_test_UPPERthen[ -d /tmp/case_test_upper ]succeeds). - No reftable extension is in use (plain loose refs), so this is a filesystem-level collision, not a git internals issue.
- Neither
refs_naming_test.gonorid_test.gohas a test covering cross-format (legacy-hex vs ULID) shard collisions or case-insensitive-filesystem behavior. - No existing open issue or PR in this repo covers this (searched for "shard case", "ULID ref", "checkpoint not found", "case-insensitive filesystem").
Suggested fix directions
- Cheapest, partial fix: case-fold the comparison in
ParseRef(strings.EqualFold) so an already-collided ref can still be read back. This does not prevent the underlying collision (two logically distinct checkpoints could still end up sharing one directory going forward), but stops checkpoints from becoming silently invisible. - More complete fix: make ULID shard assignment collision-aware, e.g. lowercase the shard for ULIDs too (giving up some of the "impossible to compute inconsistently" property
ShardFor's doc comment currently claims, but only across formats — same-format collisions on a case-sensitive rule couldn't happen since ULID-vs-ULID or hex-vs-hex never differ only by case), or use separate top-level namespaces for legacy vs. ULID IDs (e.g.refs/entire/checkpoints/legacy/<shard>/<id>vs.../ulid/<shard>/<id>) so they can never collide regardless of filesystem case sensitivity. - Either way,
entire doctorcould detect the drift by scanningrefs/entire/checkpoints/**and flagging any ref whose parsed shard doesn't case-sensitively match its directory, so existing affected checkpoints in the wild can be identified and repaired (e.g. by rewriting the ref to the correctly-cased path) rather than silently orphaned.
Impact
Silent data-loss-adjacent bug: the checkpoint content is never destroyed, but becomes unreachable through every normal CLI path (checkpoint list, checkpoint explain, presumably tokens profile and anything else that discovers checkpoints via ParseRef). Given ULID checkpoints are the current default for the git-refs store and macOS/Windows are both case-insensitive-by-default, this will affect any repo that mixes legacy and ULID checkpoints (i.e., most repos migrated via entire doctor migrate or any repo old enough to carry legacy IDs from before ULIDs were introduced, per #1546/#1566/#1629) on a large enough fraction of new checkpoints.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with cmd/entire/cli/checkpoint/id/id.go, especially CheckpointID.ShardFor, and cmd/entire/cli/checkpoint/refs_naming.go, especially ParseRef; review refs_naming_test.go and id_test.go for existing coverage. Reproduce the mixed legacy-hex and ULID shard case on a case-insensitive filesystem, then add tests covering the chosen fix and verify affected checkpoints remain discoverable through the checkpoint commands.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, go
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100