From 2017329015b8b8d1da83a9b5665d7e621c9561cc Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Fri, 2 Oct 2026 12:34:41 +0200 Subject: [PATCH] fix(client): resolve default branch from the remote advertisement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cached bare clone's HEAD points at refs/heads/, which is written once at clone time and never updated again — fetch only touches refs/remotes/*. Syncs without --ref therefore resolved the frozen clone-time revision forever: the literal "cached version of the repo" symptom. Resolve the default branch via remote.List instead: the advertised HEAD (plain hash or symref target) is the only source of truth. Verified against the production Forgejo instance; e2e test fails on the pre-fix binary and passes now. --- README.md | 5 ++- internal/client/pull.go | 47 +++++++++++++++++++++++- internal/e2e/sync_default_branch_test.go | 42 +++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 internal/e2e/sync_default_branch_test.go diff --git a/README.md b/README.md index 4c95c18..d12dfbb 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,10 @@ repository, the local manifest and the on-disk content: - upstream deleted & modified → **skip + warn** — your version stays Sync is idempotent (a second run is a byte-level no-op) and fails cleanly: -an unreachable repository never leaves a half-deployed machine. +an unreachable repository never leaves a half-deployed machine. The default +branch is resolved from the remote's advertised `HEAD`, so a sync without +`--ref` always tracks the server's current default branch — even after the +cache has been re-fetched many times. ### Configuration diff --git a/internal/client/pull.go b/internal/client/pull.go index 202534c..cdb94e0 100644 --- a/internal/client/pull.go +++ b/internal/client/pull.go @@ -31,7 +31,15 @@ func Pull(cacheDir, url, ref, token, tokenUser string) (*PullResult, error) { if err != nil { return nil, err } - hash, err := resolveRemoteRef(repo, ref) + var hash plumbing.Hash + if ref == "" { + // The cached bare clone's HEAD is frozen at clone time (fetch never + // updates refs/heads/*), so the default branch must be asked from the + // remote directly — never resolved through local refs. + hash, err = resolveDefaultBranch(repo, auth) + } else { + hash, err = resolveRemoteRef(repo, ref) + } if err != nil { return nil, fmt.Errorf("resolving %q in %s: %w", refLabel(ref), gitsource.RedactURL(url), err) } @@ -86,6 +94,43 @@ func openOrCreate(cacheDir, url, ref string, auth gittransport.AuthMethod) (*git return repo, nil } +// resolveDefaultBranch asks the remote for its advertised HEAD — the only +// source of truth for the default branch tip. HEAD may be advertised as a +// plain hash or as a symbolic reference to one of the advertised refs; both +// are handled. Local refs are never consulted: the cached bare clone's HEAD +// is frozen at clone time because fetch updates only refs/remotes/*. +func resolveDefaultBranch(repo *git.Repository, auth gittransport.AuthMethod) (plumbing.Hash, error) { + remote, err := repo.Remote(git.DefaultRemoteName) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("reading remote: %w", err) + } + refs, err := remote.List(&git.ListOptions{Auth: auth}) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("listing remote refs: %w", err) + } + hashes := map[string]plumbing.Hash{} + var head *plumbing.Reference + for _, ref := range refs { + if ref.Name() == plumbing.HEAD { + head = ref + continue + } + if ref.Type() == plumbing.HashReference { + hashes[ref.Name().String()] = ref.Hash() + } + } + if head == nil { + return plumbing.ZeroHash, errors.New("remote advertises no HEAD") + } + if head.Type() == plumbing.HashReference { + return head.Hash(), nil + } + if h, ok := hashes[head.Target().String()]; ok { + return h, nil + } + return plumbing.ZeroHash, fmt.Errorf("advertised HEAD points at %s, which was not advertised", head.Target()) +} + // repointRemote aligns the cached origin with the configured URL so a fleet // re-point takes effect on the next sync. func repointRemote(repo *git.Repository, url string) error { diff --git a/internal/e2e/sync_default_branch_test.go b/internal/e2e/sync_default_branch_test.go new file mode 100644 index 0000000..ca2c14d --- /dev/null +++ b/internal/e2e/sync_default_branch_test.go @@ -0,0 +1,42 @@ +package e2e + +import ( + "os" + "path/filepath" + "testing" +) + +// TestSyncDefaultBranchStaysFresh pins the fix for the stale-cache bug: with +// no ref configured, sync must resolve the default branch from the remote +// advertisement, never from the cached bare clone's frozen refs/heads ref. +func TestSyncDefaultBranchStaysFresh(t *testing.T) { + h := newHarness(t) + env := newSyncEnv(t, h) + buildWorkRepo(t, h, env.workRepo) + + // config without "ref": the default-branch resolution path + env.config = filepath.Join(env.configDir, "config.json") + os.WriteFile(env.config, []byte(`{"repo_url": "`+env.workRepo+`"}`), 0o600) + + if _, err := env.run(t); err != nil { + t.Fatal(err) + } + + // upstream change on the default branch + p := filepath.Join(env.workRepo, "commands/deploy.md") + os.WriteFile(p, []byte("---\nname: Deploy\ndescription: updated\n---\nfresh body\n"), 0o644) + h.git(t, env.workRepo, "add", "-A") + h.git(t, env.workRepo, "commit", "-m", "update command on default branch") + + out, err := env.run(t) + if err != nil { + t.Fatalf("second sync (default branch) failed: %v\n%s", err, out) + } + if !contains(out, "updated: 1") { + t.Errorf("default-branch sync must pick up the upstream change:\n%s", out) + } + deployed := readFile(t, filepath.Join(env.home, ".config/opencode/commands/deploy.md")) + if !contains(deployed, "fresh body") { + t.Error("default-branch sync must deploy the fresh content, not the clone-time revision") + } +}