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.
185 lines
5.6 KiB
Go
185 lines
5.6 KiB
Go
package client
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/config"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
gittransport "github.com/go-git/go-git/v5/plumbing/transport"
|
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
|
|
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
|
)
|
|
|
|
// PullResult is one successful work-repo pull: the resolved revision plus
|
|
// read access to its commit tree.
|
|
type PullResult struct {
|
|
Rev string
|
|
Tree *gitsource.GitTree
|
|
}
|
|
|
|
// Pull fetches the work repository into the cache dir (bare clone, then
|
|
// fetch) and resolves ref — the default branch when empty. An unreachable
|
|
// repository yields a clean error before any deployment state is touched.
|
|
func Pull(cacheDir, url, ref, token, tokenUser string) (*PullResult, error) {
|
|
auth := basicAuth(token, tokenUser)
|
|
repo, err := openOrCreate(cacheDir, url, ref, auth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
}
|
|
commit, err := repo.CommitObject(hash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading commit %s: %w", hash, err)
|
|
}
|
|
tree, err := commit.Tree()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading tree of %s: %w", hash, err)
|
|
}
|
|
return &PullResult{Rev: hash.String(), Tree: gitsource.NewGitTree(tree)}, nil
|
|
}
|
|
|
|
func basicAuth(token, tokenUser string) gittransport.AuthMethod {
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
user := tokenUser
|
|
if user == "" {
|
|
user = "agent-lib"
|
|
}
|
|
return &http.BasicAuth{Username: user, Password: token}
|
|
}
|
|
|
|
func openOrCreate(cacheDir, url, ref string, auth gittransport.AuthMethod) (*git.Repository, error) {
|
|
if _, err := os.Stat(filepath.Join(cacheDir, "HEAD")); err == nil {
|
|
repo, err := git.PlainOpen(cacheDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening cached work repo: %w", err)
|
|
}
|
|
if err := repointRemote(repo, url); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := repo.Fetch(&git.FetchOptions{Auth: auth}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
return nil, fmt.Errorf("fetching %s: %w", gitsource.RedactURL(url), err)
|
|
}
|
|
return repo, nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(cacheDir), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
opts := &git.CloneOptions{URL: url, Auth: auth, NoCheckout: true}
|
|
if ref != "" {
|
|
opts.ReferenceName = plumbing.NewBranchReferenceName(ref)
|
|
}
|
|
repo, err := git.PlainClone(cacheDir, true, opts)
|
|
if err != nil {
|
|
os.RemoveAll(cacheDir)
|
|
return nil, fmt.Errorf("cloning %s: %w", gitsource.RedactURL(url), err)
|
|
}
|
|
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 {
|
|
remote, err := repo.Remote(git.DefaultRemoteName)
|
|
if err != nil {
|
|
return fmt.Errorf("reading cached remote: %w", err)
|
|
}
|
|
if len(remote.Config().URLs) == 0 || remote.Config().URLs[0] == url {
|
|
return nil
|
|
}
|
|
if err := repo.DeleteRemote(git.DefaultRemoteName); err != nil {
|
|
return fmt.Errorf("replacing cached remote: %w", err)
|
|
}
|
|
_, err = repo.CreateRemote(&config.RemoteConfig{
|
|
Name: git.DefaultRemoteName,
|
|
URLs: []string{url},
|
|
Fetch: []config.RefSpec{
|
|
config.RefSpec("+refs/heads/*:refs/remotes/origin/*"),
|
|
},
|
|
})
|
|
return err
|
|
}
|
|
|
|
func resolveRemoteRef(repo *git.Repository, ref string) (plumbing.Hash, error) {
|
|
candidates := remoteRefCandidates(ref)
|
|
for _, name := range candidates {
|
|
if h, err := repo.ResolveRevision(plumbing.Revision(name)); err == nil {
|
|
return *h, nil
|
|
}
|
|
}
|
|
return plumbing.ZeroHash, fmt.Errorf("reference not found (tried %v)", candidates)
|
|
}
|
|
|
|
func remoteRefCandidates(ref string) []string {
|
|
if ref == "" {
|
|
return []string{"refs/remotes/origin/HEAD", "HEAD"}
|
|
}
|
|
return []string{
|
|
"refs/remotes/origin/" + ref,
|
|
"refs/tags/" + ref,
|
|
"refs/heads/" + ref,
|
|
ref,
|
|
}
|
|
}
|
|
|
|
func refLabel(ref string) string {
|
|
if ref == "" {
|
|
return "default branch"
|
|
}
|
|
return ref
|
|
}
|