Fix: clone leaves warning: remote HEAD refers to nonexistent ref, unable to checkout #
Context #
Cloning a mirror of golang/go from objgitd downloads every object and resolves all
deltas, then aborts the checkout with:
warning: remote HEAD refers to nonexistent ref, unable to checkout
Root cause — a main vs master HEAD mismatch that is never healed.
loadOrInit (cmd/objgitd/git_protocol.go:198) initializes every new repository
with git.Init(..., git.WithDefaultBranch(refs/heads/main)), so its HEAD file is
the symbolic ref ref: refs/heads/main. Receive-pack only writes the branch refs the
client pushed (updateReferences in receivepack.go:287) and never touches HEAD.
When you push a project whose default branch is not main — golang/go uses
master — the repo ends up with refs/heads/master (plus release branches) but a
HEAD that still points at the nonexistent refs/heads/main. HEAD dangles forever.
Confirmed empirically (both reproduced this turn):
- go-git v6.0.0-alpha.4's advertiser (
plumbing/transport/serve.go:96addReferences) resolves HEAD's symbolic target and, onErrReferenceNotFound, drops HEAD from the advertisement entirely (nosymref=HEAD:...capability). Pointing HEAD at an existing branch instead produces the correctsymref=HEAD:refs/heads/master. - A real bare repo (
git init --bare -b main, then push onlymaster) reproduces the exact client warning overfile://. So the trigger is purely the dangling HEAD; it is not a go-git wire bug.
This is a generic correctness gap, not specific to golang/go: any pushed repo whose
default branch ≠ main is unclonable-to-worktree. Real git hosts (GitHub/GitLab/Gitea)
repoint HEAD on push; objgitd does not.
Intended outcome: after a push, and for repos already sitting in the bucket, HEAD
resolves to a branch that exists, so git clone checks out a worktree with no warning.
Approach #
Add an idempotent heal HEAD step and call it (a) on every load and (b) after every
push. Healing only acts when HEAD is symbolic and its target is missing; a detached
HEAD, an already-valid HEAD, or a branch-less repo is left untouched. This fixes future
pushes immediately and lets repos already broken in the bucket (e.g. golang/go)
recover on their next clone — one HEAD rewrite, then self-correcting — without a
re-push.
New helpers (in cmd/objgitd/git_protocol.go, next to loadOrInit) #
// ensureHEAD repoints a repo's HEAD at an existing branch when its symbolic target
// is missing. objgitd inits every repo with HEAD -> refs/heads/main, but pushing a
// project whose default branch differs (golang/go uses master) leaves HEAD dangling:
// clients fetch every object yet cannot check out a worktree. Idempotent and best-
// effort; a detached/valid HEAD or a branch-less repo is left alone.
func ensureHEAD(st storage.Storer) error {
head, err := st.Reference(plumbing.HEAD)
if err != nil { return err }
if head.Type() != plumbing.SymbolicReference { return nil } // detached: leave
if _, err := st.Reference(head.Target()); err == nil { return nil } // already valid
else if !errors.Is(err, plumbing.ErrReferenceNotFound) { return err }
target, err := pickDefaultBranch(st)
if err != nil || target == "" { return err } // no branches: leave
return st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, target))
}
// pickDefaultBranch chooses a branch to point HEAD at: prefer refs/heads/main, then
// master, then trunk; otherwise the lexicographically smallest branch (deterministic).
func pickDefaultBranch(st storage.Storer) (plumbing.ReferenceName, error) { ... IterReferences, IsBranch ... }
Wiring (minimal, centralized) #
-
New method
(d *daemon) load(repoPath string) (storage.Storer, error)— wraps the existingd.loader.Load(&url.URL{Path: repoPath})and, on success, callsensureHEAD, logging (not failing) on heal error so a clone is never broken by a transient write failure. Returns the loader's error verbatim (preservestransport.ErrRepositoryNotFound→ 404 semantics). -
Replace the five direct read-path
d.loader.Loadcalls withd.load:git_protocol.go:147(git:// upload-pack),:157(upload-archive)ssh.go:192(ssh upload-pack),:204(upload-archive)http.go:190(resolve, the shared read path for both info/refs advertise and RPC)
-
loadOrInit(git_protocol.go:184) — calld.loadinstead ofd.loader.Loadfor its found path, so the receive-pack advertise phase and pre-push load also heal. The create path is unchanged (fresh repo has no branches →pickDefaultBranchreturns "" → HEAD staysmainuntil a branch is pushed). -
Post-push heal in
(d *daemon) receivePack(hooks.go:88) — afterreceivePackStreamingreturnsnil, callensureHEAD(st)(log on error). This makes HEAD correct the moment a first push creates branches, so the write lands during a push (a write op) rather than during the first subsequent clone.
No changes to the vendored go-git fork (receivepack.go) are needed.
Critical files #
cmd/objgitd/git_protocol.go— addensureHEAD+pickDefaultBranch+d.load; routeloadOrInitand the two git:// read sites throughd.load.cmd/objgitd/ssh.go— two read sites →d.load.cmd/objgitd/http.go—resolveread site (line 190) →d.load.cmd/objgitd/hooks.go—receivePackcallsensureHEADafter a successful receive.
Tests #
Follow the xe-go:go-table-driven-tests skill (per project memory) and the existing
tt table style; reuse runGit/tryGit/seedRepo from git_protocol_test.go.
- Unit (fast, no git binary) —
ensureHEAD/pickDefaultBranchover a memfsfilesystem.Storage: danglingHEAD->mainwith onlymasterpresent → HEAD repoints tomaster;mainpresent → unchanged; detached HEAD → unchanged; no branches → unchanged; main+master both present → prefersmain. - End-to-end (gated on
giton PATH) — drive the daemon (HTTP and/or SSH like the existing protocol tests): create a repo and push a singlemasterbranch (nomain), thengit cloneit and assert the worktree checked out (clone exit 0, expected file present, no "nonexistent ref" warning) and thatinfo/refsadvertisessymref=HEAD:refs/heads/master.
Verification #
go build ./...andgo test ./...(andgo test ./cmd/objgitd/...withgiton PATH).- Manual against the live setup (local Garage at
:3903, bucketxe-git-repos,SSH_BIND=:2222): withgolang/goalready in the bucket, rungit clone ssh://localhost:2222/github.com/golang/go.gitand confirm it checks outmasterwith no warning. The first clone heals HEAD (oneHEADwrite); subsequent clones find HEAD already valid.