entireio / entireio/cli

ULID checkpoint refs become permanently unreadable when their shard case-collides with an existing shard on a case-insensitive filesystem (macOS/Windows)

Open
#2,401 0 comments 0 reactions 0 assignees View on GitHub

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
  1. 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.
  2. 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, shard 6B). 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.
  3. 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
    
  4. But the ref is actually present:
    $ git for-each-ref refs/entire/
    ...
    55efa918... commit  refs/entire/checkpoints/6b/01M2DCHJCHTR9T9MZTSB7WV76B
    4d64dfdf... commit  refs/entire/checkpoints/6b/0da4f302686b
    
    Both checkpoints share the literal, byte-confirmed (0x36 0x62 = ASCII 6b) directory 6b — a legacy-hex checkpoint and a ULID checkpoint whose real shard should have been 6B.
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_UPPER then [ -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.go nor id_test.go has 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 doctor could detect the drift by scanning refs/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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.