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.
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
@@ -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
@@ -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
@@ -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")