fix(client): resolve default branch from the remote advertisement
A cached bare clone's HEAD points at refs/heads/<branch>, 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.
This commit is contained in:
1 parent
addca3dd74
commit
2017329015
3 files changed
+92
-2
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+46
-1
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user