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.
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.
// 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 ... }-
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.
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.
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.
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.