Git backed by object storage because you can't stop me
git object-storage kefka
10

Configure Feed

Select the types of activity you want to include in your feed.

fix(protocol): heal dangling HEAD on load and after push

Repositories initialized with HEAD -> refs/heads/main (the project
default) but populated by pushing a repo with a different default
branch (golang/go uses master) end up with HEAD pointing at a
nonexistent branch. Git clients cannot check out a worktree and
warn: "remote HEAD refers to nonexistent ref, unable to checkout".

Fix: repoint HEAD at an existing branch when its symbolic target is
missing. Prefer refs/heads/main, then master, then trunk; fall back
to the lexicographically smallest branch. Heal is idempotent — a
detached HEAD, already-valid HEAD, or branch-less repo is left
untouched.

Heal on every repository load (fixes repos already in the bucket with
no re-push) and after successful receive-pack (fixes new pushes
immediately). Both call sites are now guarded by ensureHEAD(), which
is no-op when HEAD is already valid.

Fixes: #99 (clone checkout abort with dangling HEAD warning)
Assisted-by: Claude Opus 4.8 via claude.ai/code

Xe Iaso (May 30, 2026, 4:33 PM EDT) a488d3c7 e8e749fc

+345 -10
+89 -3
cmd/objgitd/git_protocol.go
··· 144 144 func (d *daemon) serveGit(ctx context.Context, conn net.Conn, r io.ReadCloser, req packp.GitProtoRequest, gitProtocol string) error { 145 145 switch req.RequestCommand { 146 146 case transport.UploadPackService: 147 - st, err := d.loader.Load(&url.URL{Path: req.Pathname}) 147 + st, err := d.load(req.Pathname) 148 148 if err != nil { 149 149 _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname)) 150 150 return fmt.Errorf("loading %q: %w", req.Pathname, err) ··· 154 154 }) 155 155 156 156 case transport.UploadArchiveService: 157 - st, err := d.loader.Load(&url.URL{Path: req.Pathname}) 157 + st, err := d.load(req.Pathname) 158 158 if err != nil { 159 159 _, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname)) 160 160 return fmt.Errorf("loading %q: %w", req.Pathname, err) ··· 177 177 } 178 178 } 179 179 180 + // load opens the storer for repoPath and heals a dangling HEAD before returning 181 + // it (see ensureHEAD). It preserves the loader's error verbatim — notably 182 + // transport.ErrRepositoryNotFound, which callers map to a 404 — and treats a 183 + // heal failure as non-fatal so a clone is never broken by a transient HEAD write. 184 + func (d *daemon) load(repoPath string) (storage.Storer, error) { 185 + st, err := d.loader.Load(&url.URL{Path: repoPath}) 186 + if err != nil { 187 + return nil, err 188 + } 189 + if err := ensureHEAD(st); err != nil { 190 + slog.Warn("could not repoint dangling HEAD", "path", repoPath, "err", err) 191 + } 192 + return st, nil 193 + } 194 + 195 + // ensureHEAD repoints a repository's HEAD at an existing branch when its symbolic 196 + // target is missing. objgitd initializes every repo with HEAD -> refs/heads/main 197 + // (loadOrInit), but a repo populated by pushing a project whose default branch 198 + // differs — golang/go uses master — leaves HEAD dangling: clients fetch every 199 + // object yet cannot check out a worktree ("remote HEAD refers to nonexistent 200 + // ref"). Git hosts repoint HEAD on push; we heal idempotently on every load and 201 + // after each push, so repos already in the bucket recover on their next clone 202 + // without a re-push. A detached or already-valid HEAD, or a repo with no branches 203 + // yet, is left untouched. 204 + func ensureHEAD(st storage.Storer) error { 205 + head, err := st.Reference(plumbing.HEAD) 206 + if err != nil { 207 + return err 208 + } 209 + if head.Type() != plumbing.SymbolicReference { 210 + return nil // detached HEAD: nothing to repoint 211 + } 212 + if _, err := st.Reference(head.Target()); err == nil { 213 + return nil // target exists: HEAD is already valid 214 + } else if !errors.Is(err, plumbing.ErrReferenceNotFound) { 215 + return err 216 + } 217 + target, err := pickDefaultBranch(st) 218 + if err != nil || target == "" { 219 + return err // no branches yet (target == ""): leave HEAD as-is 220 + } 221 + return st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, target)) 222 + } 223 + 224 + // pickDefaultBranch chooses a branch for HEAD: prefer refs/heads/main, then 225 + // master, then trunk; otherwise the lexicographically smallest branch so the 226 + // choice is deterministic. Returns "" when the repo has no branches. 227 + func pickDefaultBranch(st storage.Storer) (plumbing.ReferenceName, error) { 228 + iter, err := st.IterReferences() 229 + if err != nil { 230 + return "", err 231 + } 232 + defer iter.Close() 233 + 234 + rank := map[plumbing.ReferenceName]int{ 235 + plumbing.Main: 0, 236 + plumbing.Master: 1, 237 + plumbing.NewBranchReferenceName("trunk"): 2, 238 + } 239 + var ( 240 + first plumbing.ReferenceName 241 + best plumbing.ReferenceName 242 + bestRank = len(rank) 243 + ) 244 + err = iter.ForEach(func(r *plumbing.Reference) error { 245 + if r.Type() != plumbing.HashReference || !r.Name().IsBranch() { 246 + return nil 247 + } 248 + name := r.Name() 249 + if first == "" || name < first { 250 + first = name 251 + } 252 + if rk, ok := rank[name]; ok && rk < bestRank { 253 + best, bestRank = name, rk 254 + } 255 + return nil 256 + }) 257 + if err != nil { 258 + return "", err 259 + } 260 + if best != "" { 261 + return best, nil 262 + } 263 + return first, nil 264 + } 265 + 180 266 // loadOrInit returns the storer for repoPath, creating an empty bare repository 181 267 // on demand. Git's daemon never auto-creates; objgitd does, so a first push to 182 268 // a new path just works. 183 269 func (d *daemon) loadOrInit(repoPath string) (storage.Storer, error) { 184 - st, err := d.loader.Load(&url.URL{Path: repoPath}) 270 + st, err := d.load(repoPath) 185 271 if err == nil { 186 272 return st, nil 187 273 }
+233
cmd/objgitd/head_test.go
··· 1 + package main 2 + 3 + import ( 4 + "io" 5 + "net/http" 6 + "net/http/httptest" 7 + "os/exec" 8 + "path/filepath" 9 + "strings" 10 + "testing" 11 + 12 + "github.com/go-git/go-billy/v6" 13 + "github.com/go-git/go-billy/v6/memfs" 14 + "github.com/go-git/go-git/v6/plumbing" 15 + "github.com/go-git/go-git/v6/plumbing/cache" 16 + "github.com/go-git/go-git/v6/plumbing/transport" 17 + "github.com/go-git/go-git/v6/storage/filesystem" 18 + "tangled.org/xeiaso.net/objgit/internal/auth" 19 + ) 20 + 21 + // dummyHash is a stand-in object id for branch refs in unit tests; ensureHEAD 22 + // never dereferences it, it only needs the refs to exist. 23 + var dummyHash = plumbing.NewHash("1111111111111111111111111111111111111111") 24 + 25 + // TestEnsureHEAD exercises the dangling-HEAD heal in isolation: objgitd inits 26 + // every repo with HEAD -> refs/heads/main, so a repo populated by pushing a 27 + // project whose default branch differs (golang/go uses master) leaves HEAD 28 + // pointing at a branch that does not exist, and clients cannot check out a 29 + // worktree. ensureHEAD repoints HEAD at an existing branch. 30 + func TestEnsureHEAD(t *testing.T) { 31 + tests := []struct { 32 + name string 33 + branches []string // branch short names to create 34 + head *plumbing.Reference // initial HEAD 35 + wantTarget plumbing.ReferenceName 36 + wantHash plumbing.Hash // non-zero ⇒ expect HEAD to stay this detached hash 37 + }{ 38 + { 39 + name: "dangling main heals to master", 40 + branches: []string{"master"}, 41 + head: plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.Main), 42 + wantTarget: plumbing.Master, 43 + }, 44 + { 45 + name: "valid head left unchanged", 46 + branches: []string{"master"}, 47 + head: plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.Master), 48 + wantTarget: plumbing.Master, 49 + }, 50 + { 51 + name: "detached head left unchanged", 52 + branches: []string{"master"}, 53 + head: plumbing.NewHashReference(plumbing.HEAD, dummyHash), 54 + wantHash: dummyHash, 55 + }, 56 + { 57 + name: "no branches leaves head as-is", 58 + branches: nil, 59 + head: plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.Main), 60 + wantTarget: plumbing.Main, 61 + }, 62 + { 63 + name: "prefers main when both present", 64 + branches: []string{"master", "main"}, 65 + head: plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("trunk")), 66 + wantTarget: plumbing.Main, 67 + }, 68 + { 69 + name: "prefers master over other branches when main absent", 70 + branches: []string{"zzz", "master"}, 71 + head: plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.Main), 72 + wantTarget: plumbing.Master, 73 + }, 74 + { 75 + name: "falls back to lexicographically smallest branch", 76 + branches: []string{"zebra", "alpha", "mango"}, 77 + head: plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.Main), 78 + wantTarget: plumbing.NewBranchReferenceName("alpha"), 79 + }, 80 + } 81 + 82 + for _, tt := range tests { 83 + t.Run(tt.name, func(t *testing.T) { 84 + st := filesystem.NewStorage(memfs.New(), cache.NewObjectLRUDefault()) 85 + for _, b := range tt.branches { 86 + ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(b), dummyHash) 87 + if err := st.SetReference(ref); err != nil { 88 + t.Fatalf("seed branch %q: %v", b, err) 89 + } 90 + } 91 + if err := st.SetReference(tt.head); err != nil { 92 + t.Fatalf("seed HEAD: %v", err) 93 + } 94 + 95 + if err := ensureHEAD(st); err != nil { 96 + t.Fatalf("ensureHEAD: %v", err) 97 + } 98 + 99 + got, err := st.Reference(plumbing.HEAD) 100 + if err != nil { 101 + t.Fatalf("read HEAD back: %v", err) 102 + } 103 + if !tt.wantHash.IsZero() { 104 + if got.Type() != plumbing.HashReference || got.Hash() != tt.wantHash { 105 + t.Errorf("HEAD = %v %q, want detached hash %s", got.Type(), got.Hash(), tt.wantHash) 106 + } 107 + return 108 + } 109 + if got.Type() != plumbing.SymbolicReference || got.Target() != tt.wantTarget { 110 + t.Errorf("HEAD target = %q (type %v), want %q", got.Target(), got.Type(), tt.wantTarget) 111 + } 112 + }) 113 + } 114 + } 115 + 116 + // TestEnsureHEADIdempotent verifies a second heal is a no-op once HEAD resolves. 117 + func TestEnsureHEADIdempotent(t *testing.T) { 118 + st := filesystem.NewStorage(memfs.New(), cache.NewObjectLRUDefault()) 119 + if err := st.SetReference(plumbing.NewHashReference(plumbing.Master, dummyHash)); err != nil { 120 + t.Fatalf("seed branch: %v", err) 121 + } 122 + if err := st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.Main)); err != nil { 123 + t.Fatalf("seed HEAD: %v", err) 124 + } 125 + 126 + for i := range 2 { 127 + if err := ensureHEAD(st); err != nil { 128 + t.Fatalf("ensureHEAD pass %d: %v", i, err) 129 + } 130 + got, err := st.Reference(plumbing.HEAD) 131 + if err != nil { 132 + t.Fatalf("read HEAD pass %d: %v", i, err) 133 + } 134 + if got.Target() != plumbing.Master { 135 + t.Errorf("pass %d: HEAD target = %q, want %q", i, got.Target(), plumbing.Master) 136 + } 137 + } 138 + } 139 + 140 + // TestSmartHTTPHealsDanglingHEAD reproduces the reported bug end-to-end: a repo 141 + // already sitting in the bucket whose HEAD points at a nonexistent branch (the 142 + // init default refs/heads/main, while only master was pushed — as for a mirror 143 + // of golang/go) must heal on the next clone so git checks out a worktree instead 144 + // of printing "remote HEAD refers to nonexistent ref, unable to checkout". 145 + func TestSmartHTTPHealsDanglingHEAD(t *testing.T) { 146 + if _, err := exec.LookPath("git"); err != nil { 147 + t.Skip("git not installed") 148 + } 149 + 150 + fs := memfs.New() 151 + ts := httptest.NewServer(&daemon{ 152 + fs: fs, 153 + loader: transport.NewFilesystemLoader(fs, false), 154 + authz: auth.AllowAnonymous{AllowWrite: true}, 155 + }) 156 + t.Cleanup(ts.Close) 157 + 158 + // Push a single "master" branch (no "main"), like a project whose default 159 + // branch is master. 160 + work := t.TempDir() 161 + runGit(t, work, "init", "-b", "master") 162 + runGit(t, work, "config", "user.email", "test@example.com") 163 + runGit(t, work, "config", "user.name", "Test") 164 + writeFile(t, filepath.Join(work, "README.md"), "hello\n") 165 + runGit(t, work, "add", ".") 166 + runGit(t, work, "commit", "-m", "initial") 167 + if out, err := tryGit(work, "push", ts.URL+"/go.git", "master"); err != nil { 168 + t.Fatalf("push failed: %v\n%s", err, out) 169 + } 170 + 171 + // Re-break HEAD to simulate a repo created before this fix (post-push heal 172 + // would otherwise have already fixed it): point HEAD back at the dangling 173 + // refs/heads/main directly in the backing store. The very next load (this 174 + // clone) must heal it on the way to serving the advertisement. 175 + breakHEAD(t, fs, "/go.git") 176 + 177 + dst := t.TempDir() 178 + out, err := tryGit(dst, "clone", ts.URL+"/go.git", "cloned") 179 + if err != nil { 180 + t.Fatalf("clone failed: %v\n%s", err, out) 181 + } 182 + if strings.Contains(out, "nonexistent ref") { 183 + t.Errorf("clone still warned about nonexistent HEAD ref; output:\n%s", out) 184 + } 185 + 186 + cloned := filepath.Join(dst, "cloned") 187 + if _, err := exec.Command("git", "-C", cloned, "rev-parse", "HEAD").Output(); err != nil { 188 + t.Fatalf("cloned repo has no checked-out HEAD: %v", err) 189 + } 190 + if got := strings.TrimSpace(runGit(t, cloned, "rev-parse", "--abbrev-ref", "HEAD")); got != "master" { 191 + t.Errorf("checked out branch = %q, want master", got) 192 + } 193 + if _, err := exec.Command("git", "-C", cloned, "cat-file", "-e", "HEAD:README.md").Output(); err != nil { 194 + t.Errorf("worktree missing README.md after clone: %v", err) 195 + } 196 + 197 + // After a load-healed clone, the advertisement now carries the symref. 198 + if body := getInfoRefs(t, ts.URL+"/go.git"); !strings.Contains(body, "symref=HEAD:refs/heads/master") { 199 + t.Errorf("expected symref=HEAD:refs/heads/master after heal; advertisement:\n%q", body) 200 + } 201 + } 202 + 203 + // breakHEAD points the bare repo at repoPath's HEAD back at the dangling 204 + // refs/heads/main, simulating a repository created before the heal existed. 205 + func breakHEAD(t *testing.T, fs billy.Filesystem, repoPath string) { 206 + t.Helper() 207 + sub, err := fs.Chroot(repoPath) 208 + if err != nil { 209 + t.Fatalf("chroot %q: %v", repoPath, err) 210 + } 211 + st := filesystem.NewStorage(sub, cache.NewObjectLRUDefault()) 212 + if err := st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.Main)); err != nil { 213 + t.Fatalf("break HEAD: %v", err) 214 + } 215 + if _, err := st.Reference(plumbing.Main); err == nil { 216 + t.Fatalf("test setup invalid: refs/heads/main exists, HEAD would not dangle") 217 + } 218 + } 219 + 220 + // getInfoRefs fetches the smart-HTTP upload-pack advertisement for repoURL. 221 + func getInfoRefs(t *testing.T, repoURL string) string { 222 + t.Helper() 223 + resp, err := http.Get(repoURL + "/info/refs?service=git-upload-pack") 224 + if err != nil { 225 + t.Fatalf("GET info/refs: %v", err) 226 + } 227 + defer resp.Body.Close() 228 + body, err := io.ReadAll(resp.Body) 229 + if err != nil { 230 + t.Fatalf("read info/refs: %v", err) 231 + } 232 + return string(body) 233 + }
+20 -2
cmd/objgitd/hooks.go
··· 87 87 // a capability-hiding wrapper. 88 88 func (d *daemon) receivePack(ctx context.Context, st storage.Storer, repoPath string, r io.ReadCloser, w io.WriteCloser, req *transport.ReceivePackRequest) error { 89 89 if !d.allowHooks { 90 - return receivePackStreaming(ctx, st, r, w, req, nil) 90 + err := receivePackStreaming(ctx, st, r, w, req, nil) 91 + d.healHEADAfterPush(err, st, repoPath) 92 + return err 91 93 } 92 94 93 95 before, err := snapshotRefs(st) ··· 112 114 d.runHooks(repoPath, "receive-pack", st, updates, progress) 113 115 } 114 116 115 - return receivePackStreaming(ctx, st, r, w, req, onUpdated) 117 + err = receivePackStreaming(ctx, st, r, w, req, onUpdated) 118 + d.healHEADAfterPush(err, st, repoPath) 119 + return err 120 + } 121 + 122 + // healHEADAfterPush repoints a dangling HEAD once a push succeeds, so the first 123 + // push to a repo whose default branch is not main (e.g. golang/go uses master) 124 + // leaves HEAD resolvable for the next clone. The HEAD write thus lands during the 125 + // push rather than during a later clone. No-op when the receive failed or HEAD is 126 + // already valid (see ensureHEAD). 127 + func (d *daemon) healHEADAfterPush(recvErr error, st storage.Storer, repoPath string) { 128 + if recvErr != nil { 129 + return 130 + } 131 + if err := ensureHEAD(st); err != nil { 132 + slog.Warn("could not repoint HEAD after push", "path", repoPath, "err", err) 133 + } 116 134 } 117 135 118 136 // runHooks executes the receive-pack hook once per non-deleted branch update,
+1 -2
cmd/objgitd/http.go
··· 7 7 "io" 8 8 "log/slog" 9 9 "net/http" 10 - "net/url" 11 10 "strings" 12 11 "time" 13 12 ··· 187 186 return st, true 188 187 } 189 188 190 - st, err := d.loader.Load(&url.URL{Path: repoPath}) 189 + st, err := d.load(repoPath) 191 190 if err != nil { 192 191 if errors.Is(err, transport.ErrRepositoryNotFound) { 193 192 http.Error(w, "repository not found", http.StatusNotFound)
+2 -3
cmd/objgitd/ssh.go
··· 8 8 "fmt" 9 9 "io" 10 10 "log/slog" 11 - "net/url" 12 11 "os" 13 12 "path/filepath" 14 13 "strings" ··· 189 188 // forwarded yet; v0/v1 is sufficient. See plan. 190 189 switch service { 191 190 case transport.UploadPackService: 192 - st, err := d.loader.Load(&url.URL{Path: repoPath}) 191 + st, err := d.load(repoPath) 193 192 if err != nil { 194 193 fmt.Fprintf(s.Stderr(), "objgitd: repository %q not found\n", repoPath) 195 194 _ = s.Exit(1) ··· 201 200 } 202 201 203 202 case transport.UploadArchiveService: 204 - st, err := d.loader.Load(&url.URL{Path: repoPath}) 203 + st, err := d.load(repoPath) 205 204 if err != nil { 206 205 fmt.Fprintf(s.Stderr(), "objgitd: repository %q not found\n", repoPath) 207 206 _ = s.Exit(1)