Skip to content

fix(gitrepo): support reftable ref storage#1723

Open
entire[bot] wants to merge 7 commits into
mainfrom
fix/547-reftable-support
Open

fix(gitrepo): support reftable ref storage#1723
entire[bot] wants to merge 7 commits into
mainfrom
fix/547-reftable-support

Conversation

@entire

@entire entire Bot commented Jul 12, 2026

Copy link
Copy Markdown

https://entire.io/gh/entireio/cli/trails/830

Summary

entire enable (and every subsequent capture operation) aborts on any repository that uses the reftable ref backend:

failed to setup strategy: failed to open git repository: ... unknown extension: refstorage

Reftable is created by git init --ref-format=reftable and git refs migrate --ref-format=reftable, and is slated to become git's default. This PR makes the full enable → session → commit → checkpoint flow work on reftable repositories. Fixes #547.

Root cause

The vendored go-git/go-git/v6 alpha has no reftable reader:

  • its filesystem storer reads refs from .git/refs, .git/packed-refs and .git/HEAD — in a reftable repo the real refs live in a binary reftable stack and .git/HEAD is only a ref: refs/heads/.invalid stub;
  • its open-time extension check (verifyExtensions) rejects extensions.refstorage=reftable outright, which is the exact error users see.

Upstream is not fixing this soon: go-git PR #1970 ("Reftable support") was closed unmerged, and go-git issue #1827 is still open. Waiting on the dependency is not viable.

Approach

Detect reftable repositories in gitrepo.OpenPath (presence of a reftable/ directory under the worktree or common git dir) and wrap go-git's filesystem storage with a reftableStorer:

  • object, config, index and shallow storage keep flowing through go-git — reftable changes only ref storage, not object storage, so tree/commit building is unaffected;
  • the ReferenceStorer methods (SetReference, CheckAndSetReference, Reference, IterReferences, RemoveReference, CountLooseRefs, PackRefs) are overridden to shell out to git ref plumbing (update-ref, symbolic-ref, rev-parse, for-each-ref);
  • the storer implements SupportsExtension("refstorage") so go-git's extension check passes on open.

This extends the codebase's existing "prefer the git CLI where go-git is unsafe or unsupported" seam (already used for reset/checkout via HardResetWithProtection, and for fetch) rather than inventing a new architecture. Because every ref read/write in the CLI funnels through gitrepo.OpenPath, one wrapper makes all callers — enable, session start, shadow branches, condensation, the entire/checkpoints/v1 branch, and checkpoint list — work unchanged. The ref plumbing used does not fire git hooks, so it cannot recurse back into entire.

Scope note: this covers the local capture flow. Network transport of reftable stacks is out of scope and unaffected (push/fetch already route through the git CLI). No behavior changes for classic files-backend or sha256 repositories.

Testing

Unit tests (cmd/entire/cli/gitrepo/reftable_test.go) and integration tests (cmd/entire/cli/integration_test/reftable_repo_test.go) are gated on git reftable support and skip on older git (same pattern as the existing sha256 repo test). Live-verified against the built binary with real git state inspection:

Case Result
Reftable repo — entire enable succeeds, creates entire/checkpoints/v1
Reftable repo — session start → file change → stop → user commit shadow branch + metadata branch advance; user commit carries Entire-Checkpoint: trailer
Reftable repo — entire checkpoint list lists the checkpoint
Reftable repo with pre-existing history before enable works
Linked worktree of a reftable repo HEAD/branch resolve correctly; enable + metadata branch resolve
Files-backend repo (regression) unchanged
sha256 object-format repo (regression) unchanged

Gates: gofmt clean, golangci-lint clean on touched packages, and the full integration + strategy suites pass (1119 tests).

Hardening (audit follow-up)

A follow-up commit hardens the ref-plumbing seam after an adversarial review:

  • CAS error mapping (correctness). CheckAndSetReference previously mapped every git update-ref failure to storage.ErrReferenceHasChanged. strategy.atomicSetV1Ref keys a privacy-critical pre-push decision on that sentinel (concurrency conflict → abort as V1RefMovedError) and documents that all other errors must be surfaced as-is. The coarse mapping misreported storage errors (nonexistent object, invalid ref name, lock contention, timeout, git spawn failure) as concurrency conflicts, producing a bogus V1RefMovedError ("ref moved from X to X"). Now only a genuine compare-and-swap conflict (is at X but expected Y, or reference already exists for a create race) maps to the sentinel; every other failure is wrapped and surfaced as itself.
  • Ref-name argument hardening (defense-in-depth). All user-controlled ref names are now passed after --end-of-options, so a name beginning with - is treated as a literal argument rather than a git option. Args were already passed as argv (never a shell string), so there was no command-injection exposure; this closes off option-injection for exotic ref names.
  • Ref-lookup failure classification (correctness). Reference, the IterReferences HEAD path, and RemoveReference previously treated every git failure as a definitive negative — a spawn failure, the reftableGitTimeout kill, or an I/O error was reported as ErrReferenceNotFound / a silently-dropped HEAD / a successful deletion. Because strategy.SafelyAdvanceLocalRef blind-overwrites a ref it believes absent (no CAS), a transient git failure on a critical ref (e.g. entire/checkpoints/v1) could discard real history. A new refLookupAbsent classifier maps only a genuine "git ran and exited non-zero with empty stderr" (git's --quiet not-found contract) to absence; timeouts (now re-wrapped with the context error), spawn/I-O errors, and non-zero exits carrying stderr are surfaced. RemoveReference no longer swallows an empty-stderr failure (git exits 0 for an already-absent ref).
  • Tests. Added mutation-verified regression tests for the CAS conflict-vs-error mapping, ref-lookup failure classification (absence/timeout/spawn/fatal), delete-failure surfacing, HEAD-resolution failure surfacing, and an argv-safety test proving ref names are never shell-evaluated.

Opening a repository that uses the reftable ref backend (git init
--ref-format=reftable, or git refs migrate --ref-format=reftable) failed
with "core.repositoryformatversion does not support extension:
refstorage", so `entire enable` and every capture operation aborted.

Root cause: the vendored go-git v6 alpha has no reftable reader. Its
filesystem storer reads refs from .git/refs, .git/packed-refs and
.git/HEAD (a reftable repo's HEAD is a "ref: refs/heads/.invalid" stub
and the real refs live in the binary reftable stack), and its extension
verification rejects extensions.refstorage=reftable outright. Upstream
reftable support has not landed: go-git PR #1970 was closed unmerged and
go-git issue #1827 is still open, so we cannot wait for the dependency.

Fix: detect reftable repositories (presence of a reftable/ directory
under the worktree or common git dir) in gitrepo.OpenPath and wrap
go-git's filesystem storage with a reftableStorer. Object, config, index
and shallow storage keep flowing through go-git (reftable changes only
ref storage, not object storage); the ReferenceStorer methods
(SetReference, CheckAndSetReference, Reference, IterReferences,
RemoveReference, CountLooseRefs, PackRefs) are overridden to shell out
to git plumbing (update-ref, symbolic-ref, rev-parse, for-each-ref),
and the storer advertises SupportsExtension("refstorage") so go-git's
open-time extension check passes. This extends the existing "prefer the
git CLI where go-git is unsafe" seam already used for reset/checkout
(HardResetWithProtection) and fetch rather than inventing a new
architecture, and routes every ref read/write through a single point so
all callers (enable, session start, shadow branches, condensation, the
checkpoints branch, checkpoint list) work unchanged. Ref plumbing does
not fire git hooks, so this cannot recurse back into entire.

Verified end to end against reftable repos: enable, session start, file
changes, stop, user commit with Entire-Checkpoint trailer, checkpoint
list, and a linked worktree of a reftable repo, with no regression on
the classic files backend or sha256 object format.

Fixes #547
@suhaanthayyil suhaanthayyil changed the title Fix #547: reftable ref storage support fix(gitrepo): support reftable ref storage Jul 12, 2026
@suhaanthayyil
suhaanthayyil marked this pull request as ready for review July 12, 2026 01:05
@suhaanthayyil
suhaanthayyil requested a review from a team as a code owner July 12, 2026 01:05
Copilot AI review requested due to automatic review settings July 12, 2026 01:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for Git repositories using the reftable ref backend by detecting reftable storage at repo-open time and routing ref reads/writes through the git CLI while keeping object storage on go-git. This unblocks entire enable and the full capture lifecycle on reftable repos (issue #547).

Changes:

  • Detect reftable repositories in gitrepo.OpenPath and wrap go-git storage with a reftableStorer.
  • Implement a reftableStorer that overrides go-git ref operations using git plumbing commands (update-ref, symbolic-ref, rev-parse, for-each-ref).
  • Add unit and integration coverage for enable → checkpoint flow (including linked worktrees) on reftable repos.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
cmd/entire/cli/gitrepo/repository.go Detects reftable repos and opens them with a ref-CLI-backed storer wrapper.
cmd/entire/cli/gitrepo/reftable.go New reftable-aware storer that shells out to git for ref CRUD/iteration and extension support.
cmd/entire/cli/gitrepo/reftable_test.go Unit tests for opening reftable repos and verifying ref read/write/list/remove behavior.
cmd/entire/cli/integration_test/reftable_repo_test.go Integration tests covering end-to-end capture flow + linked worktree behavior on reftable repos.

Comment thread cmd/entire/cli/gitrepo/reftable.go Outdated
Comment thread cmd/entire/cli/gitrepo/reftable.go
Comment thread cmd/entire/cli/integration_test/reftable_repo_test.go
Comment thread cmd/entire/cli/integration_test/reftable_repo_test.go Outdated
suhaanthayyil and others added 6 commits July 11, 2026 22:23
The reftable ReferenceStorer mapped every git update-ref failure to
storage.ErrReferenceHasChanged. strategy.atomicSetV1Ref keys a
privacy-critical pre-push decision on that sentinel: it treats it as
"another worktree advanced the ref" and aborts the push as a concurrency
event, while wrapping every other error as a real failure. The coarse
mapping therefore misreported storage errors (nonexistent object, invalid
ref name, lock contention, timeout, git spawn failure) as concurrency
conflicts, producing a bogus V1RefMovedError ("ref moved from X to X").

Map only a genuine compare-and-swap conflict ("is at X but expected Y",
or "reference already exists" for a create race) to
ErrReferenceHasChanged; surface every other failure as itself.

Also pass user-controlled ref names after --end-of-options so a ref name
beginning with "-" is treated as a literal argument rather than a git
option (defense-in-depth; args were already argv, never a shell string,
so there was no command-injection exposure).

Add mutation-verified regression tests: the CAS conflict-vs-error
mapping, and an argv-safety test proving ref names are never
shell-evaluated.
Reference, IterReferences (HEAD resolution), and RemoveReference treated
every git failure as a definitive negative: a spawn failure, our timeout,
or an I/O error was reported as ErrReferenceNotFound, a silently-dropped
HEAD, or a successful deletion. A transient git failure masquerading as
"ref absent" can make the strategy treat a live checkpoint ref as gone
(orphan reset, lost linkage) or believe a ref was deleted when it was not.

Classify failures via refLookupAbsent: git genuinely reports absence only
when it ran and exited non-zero with empty stderr (its --quiet not-found
contract). A wrapped context error (execGit now re-wraps the timeout
kill), a non-*exec.ExitError (spawn/I/O), or a non-zero exit carrying
stderr is surfaced as a real error instead. RemoveReference no longer
treats an empty-stderr failure as idempotent success — git exits 0 for an
already-absent ref, so a non-zero exit here is a genuine failure.

Add an injectable runGitFn so the classification is covered by
mutation-verified unit tests: absence, timeout, spawn failure, and
non-zero-with-stderr for Reference; empty-stderr and timeout not swallowed
for RemoveReference; and a git failure resolving HEAD surfaced by
IterReferences.
…eference

RemoveReference probes symbolic-ref -q to choose between deleting via
symbolic-ref -d (symbolic) or update-ref -d (hash). It treated ANY probe
error as "not symbolic" and fell through to update-ref -d. But update-ref
-d on a symbolic ref deletes the ref it points at, not the symref itself
(e.g. update-ref -d HEAD deletes the current branch). A transient probe
failure (timeout, spawn error, I/O) on a symbolic ref would therefore be
routed into a destructive delete of the wrong ref, silently losing a
branch pointer.

Apply the same refLookupAbsent classification used for lookups: a genuine
"not a symbolic ref / not found" is exit-non-zero with empty stderr and
falls through to update-ref -d; a spawn/timeout/I-O failure is surfaced
instead of being assumed non-symbolic.

The Reference and IterReferences symbolic-ref probes do not share this
hazard: their failure falls through to a classified rev-parse that
surfaces genuine failures, and the path is a non-destructive read.

Mutation-verified test injects failing symbolic-ref probes and asserts the
error is surfaced and never routed into update-ref -d.
…able

Two related robustness fixes to the reftable git-CLI ref storer:

1. Force LC_ALL=C / LANG=C for every reftable git plumbing invocation (new
   gitPlumbingEnv). git's diagnostics are i18n'd, and the reftable error
   classification matches English substrings ("is at X but expected Y",
   "reference already exists", "does not exist"). On a localized machine
   those messages are translated, so isRefCASConflict and RemoveReference
   idempotency would silently misclassify CAS conflicts and delete
   outcomes. Mirrors checkpoint/shadow_ref.go. refLookupAbsent already keys
   on exit code + empty stderr and is locale-independent.

2. Classify the symbolic-ref -q probe failure in Reference and
   IterReferences, matching the RemoveReference fix. Previously any probe
   error fell through to the hash path, so a transient failure (timeout,
   spawn, I/O) on a symbolic ref such as HEAD downgraded it to a Hash
   reference literally named "HEAD" — making go-git's ResolveReference stop
   there and callers that check head.Name().IsBranch() read the repo as
   detached. Now a genuine "not symbolic" (exit non-zero, empty stderr)
   falls through, but a real git failure is surfaced.

Mutation-verified tests: locale env forcing overrides inherited
LANG/LC_ALL, refLookupAbsent locale-independence, and transient
symbolic-probe surfacing for both Reference and IterReferences.
Detach runCLIIn's spawned entire binary from any controlling TTY via
execx.NonInteractive, matching TestEnv.RunCLIWithError and every other
integration helper, so the linked-worktree reftable test can't hang on
an interactive prompt path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

entire cli does NOT support repositories using reftable storage

2 participants