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 }