Gitfed
bastien-mrq/gitfed/ Commits/ de0beea

Fix CloneMirror leaving a dangling HEAD after import

Found live: a mirrored source's HEAD can point at a branch that doesn't exist among what got mirrored (e.g. renamed master to main upstream, HEAD metadata never caught up). git refuses to advertise a symref for that, breaking anonymous git clone entirely — --depth 1 (what gitfed-install uses) fails hardest, with an empty checkout instead of a clear error. CloneMirror now repoints HEAD at the repo's one real branch after mirroring, reusing resolveDefaultRef the same way InitBareRepo already does for freshly created repos. Two already-imported repos on this instance (bastien-mrq/gitfed, diaguser/diagrepo) had this and were fixed directly on the running server.

bastien-mrq 2026-07-29 18:34 commit de0beea0104bff13c2a8c32d46130d32471b8eb4 parent 765d3e5600ddf4d105210526c0e15c44320a39ef
3 files changed +85 −0
M CHANGELOG.md +4 −0
M internal/gitexec/gitexec.go +30 −0
M internal/gitexec/gitexec_test.go +51 −0
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fc41c..d54eb55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version. +## 1.2.10 + +- Fixed a real bug found live: `CloneMirror` (the "import from URL" one-shot import) copied the source repo's `HEAD` verbatim, which can point at a branch that doesn't actually exist among what got mirrored (e.g. the source's default branch was renamed upstream — master to main — and its `HEAD` metadata never caught up). git refuses to advertise a `symref` for a dangling `HEAD`, breaking anonymous `git clone` for that repo entirely, with `--depth 1` (what `gitfed-install` uses) failing hardest — an empty checkout instead of a clear error. `CloneMirror` now repoints `HEAD` at the repo's one real branch after mirroring, same fix `InitBareRepo` already had for freshly created repos. This only fixes future imports — a repo already imported with a dangling `HEAD` needs a one-time manual fix: `kubectl -n gitfed exec deployment/gitfed -c server -- git --git-dir=/data/repos/<owner>/<repo>.git symbolic-ref HEAD refs/heads/<real-branch>`. + ## 1.2.9 - `gitfed-install` now installs `gitfed-ctl` into `/usr/local/bin` itself (via a throwaway `golang` container, no system Go needed on the host — same trick the fix below uses) right after the pod comes up, so a fresh instance is ready for `sudo gitfed-ctl` updates without a separate manual step. Best-effort: a failure here doesn't block the install, it just falls back to `deploy/update.sh` in the final screen's suggestions.
internal/gitexec/gitexec.go
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index 2de1a65..6f7f183 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -127,6 +127,36 @@ func CloneMirror(ctx context.Context, path, sourceURL string) error { _ = os.RemoveAll(path) return fmt.Errorf("gitexec: detach origin remote: %w: %s", err, out) } + + // A mirrored HEAD is copied verbatim from sourceURL, which can point at + // a branch that doesn't actually exist among what got mirrored (a + // common real case: the source's default branch was renamed at some + // point — e.g. master to main — and its own HEAD metadata never caught + // up). git upload-pack refuses to advertise a symref for a dangling + // HEAD, which breaks `git clone` for anyone downstream even though the + // repo has perfectly good content — same underlying issue + // TestPushToMismatchedDefaultBranch documents for InitBareRepo, just + // arriving via a different path here. + if err := fixDanglingHead(path); err != nil { + _ = os.RemoveAll(path) + return fmt.Errorf("gitexec: fix HEAD after mirror: %w", err) + } + return nil +} + +// fixDanglingHead repoints HEAD at the repo's one unambiguous branch when +// HEAD itself doesn't resolve (see resolveDefaultRef) — a no-op when HEAD +// is already valid, and a deliberate no-op, not an error, when the repo has +// zero or multiple branches, since there's nothing safe to guess there. +func fixDanglingHead(path string) error { + ref, ok := resolveDefaultRef(path) + if !ok || ref == "HEAD" { + return nil + } + out, err := exec.Command("git", "--git-dir="+path, "symbolic-ref", "HEAD", ref).CombinedOutput() + if err != nil { + return fmt.Errorf("symbolic-ref HEAD %s: %w: %s", ref, err, out) + } return nil }
internal/gitexec/gitexec_test.go
diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index fe44b94..c672680 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -1,6 +1,7 @@ package gitexec import ( + "context" "os" "os/exec" "path/filepath" @@ -189,6 +190,56 @@ func TestPushToMismatchedDefaultBranch(t *testing.T) { } } +// TestCloneMirrorFixesDanglingHead reproduces the real bug found on +// production: a mirrored source whose own HEAD points at a branch that +// doesn't actually exist among what got mirrored (e.g. renamed master to +// main upstream, HEAD metadata never caught up) produces a bare repo git +// upload-pack refuses to advertise a symref for, breaking `git clone` for +// every downstream client even though the content is perfectly fine. +// CloneMirror must repoint HEAD at the one real branch, not just leave it +// dangling for resolveDefaultRef's read-time fallback to paper over. +func TestCloneMirrorFixesDanglingHead(t *testing.T) { + tmp := t.TempDir() + + sourcePath := filepath.Join(tmp, "source.git") + if err := InitBareRepo(sourcePath); err != nil { + t.Fatalf("init source bare repo: %v", err) + } + + work := filepath.Join(tmp, "work") + if err := os.Mkdir(work, 0755); err != nil { + t.Fatal(err) + } + run(t, work, "git", "init", "-q", "-b", "trunk") // deliberately not "main" + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Hello\n"), 0644); err != nil { + t.Fatal(err) + } + run(t, work, "git", "add", "README.md") + run(t, work, "git", "commit", "-q", "-m", "initial") + run(t, work, "git", "remote", "add", "origin", sourcePath) + run(t, work, "git", "push", "-q", "origin", "trunk") + + // Sanity check the source itself has the dangling-HEAD problem, or this + // test isn't exercising the fix at all. + if exec.Command("git", "--git-dir="+sourcePath, "rev-parse", "--verify", "-q", "HEAD").Run() == nil { + t.Fatal("test setup invalid: source HEAD unexpectedly resolves, mismatch not reproduced") + } + + mirrorPath := filepath.Join(tmp, "mirror.git") + if err := CloneMirror(context.Background(), mirrorPath, sourcePath); err != nil { + t.Fatalf("CloneMirror: %v", err) + } + + if err := exec.Command("git", "--git-dir="+mirrorPath, "rev-parse", "--verify", "-q", "HEAD").Run(); err != nil { + t.Fatal("mirrored repo's HEAD still doesn't resolve — a real git client's `git clone` against this repo would fail") + } + + branch, ok := DefaultBranchName(mirrorPath) + if !ok || branch != "trunk" { + t.Fatalf("DefaultBranchName: got %q ok=%v, want \"trunk\"", branch, ok) + } +} + func TestDefaultBranchNameViaHEAD(t *testing.T) { tmp := t.TempDir() barePath := filepath.Join(tmp, "repo.git")