vendor select <name> --add/--remove/--mode all changes what a source contributes without advancing the pinned rev: ids are validated against the pinned upstream (typos abort listing available ids), collisions re-run, renames from the lockfile are honored, and the external area is rewritten via the same staging-swap as update. include mode grows and shrinks the include list; all mode --remove moves ids onto exclude; --mode all resets to everything. Failed selects mutate nothing. Closes beads: agent-lib-cd3
213 lines
5.3 KiB
Go
213 lines
5.3 KiB
Go
// Package gitsource clones external git sources in-process via go-git and
|
|
// exposes their commit trees for discovery — no git subprocess at runtime.
|
|
package gitsource
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/go-git/go-billy/v5/memfs"
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/filemode"
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
"github.com/go-git/go-git/v5/storage/memory"
|
|
|
|
"github.com/m3tam3re/agent-lib/internal/discovery"
|
|
)
|
|
|
|
// Env names for optional basic auth on HTTPS sources (deploy/personal token).
|
|
const (
|
|
TokenEnv = "AGENT_LIB_GIT_TOKEN"
|
|
UserEnv = "AGENT_LIB_GIT_USER"
|
|
)
|
|
|
|
// Repo is a cloned source pinned to one resolved revision.
|
|
type Repo struct {
|
|
Repository *git.Repository
|
|
Rev string
|
|
Ref string
|
|
}
|
|
|
|
// Clone fetches url — at ref (branch or tag), otherwise the default branch —
|
|
// fully in-process and pins the resolved revision.
|
|
func Clone(url, ref string) (*Repo, error) {
|
|
repo, err := git.Clone(memory.NewStorage(), memfs.New(), cloneOptions(url, ref))
|
|
if err != nil && ref != "" {
|
|
repo, err = git.Clone(memory.NewStorage(), memfs.New(), tagOptions(url, ref))
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cloning %s: %w", url, err)
|
|
}
|
|
return resolveHead(repo, ref)
|
|
}
|
|
|
|
func cloneOptions(url, ref string) *git.CloneOptions {
|
|
opts := &git.CloneOptions{URL: url}
|
|
if ref != "" {
|
|
opts.ReferenceName = plumbing.NewBranchReferenceName(ref)
|
|
}
|
|
applyAuth(url, opts)
|
|
return opts
|
|
}
|
|
|
|
func tagOptions(url, ref string) *git.CloneOptions {
|
|
opts := &git.CloneOptions{URL: url, ReferenceName: plumbing.NewTagReferenceName(ref)}
|
|
applyAuth(url, opts)
|
|
return opts
|
|
}
|
|
|
|
func applyAuth(url string, opts *git.CloneOptions) {
|
|
if !strings.HasPrefix(url, "https://") {
|
|
return
|
|
}
|
|
token := os.Getenv(TokenEnv)
|
|
if token == "" {
|
|
return
|
|
}
|
|
user := os.Getenv(UserEnv)
|
|
if user == "" {
|
|
user = "agent-lib"
|
|
}
|
|
opts.Auth = &http.BasicAuth{Username: user, Password: token}
|
|
}
|
|
|
|
func resolveHead(repo *git.Repository, requestedRef string) (*Repo, error) {
|
|
head, err := repo.Head()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving HEAD: %w", err)
|
|
}
|
|
r := &Repo{Repository: repo, Rev: head.Hash().String()}
|
|
switch {
|
|
case head.Name().IsBranch() || head.Name().IsTag():
|
|
r.Ref = head.Name().Short()
|
|
case requestedRef != "":
|
|
r.Ref = requestedRef
|
|
default:
|
|
r.Ref = head.Name().String()
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Pin returns a copy of the repo resolved to an explicit revision, so a
|
|
// selection can be re-materialized at the pinned rev without advancing it.
|
|
func (r *Repo) Pin(rev string) (*Repo, error) {
|
|
if _, err := r.Repository.CommitObject(plumbing.NewHash(rev)); err != nil {
|
|
return nil, fmt.Errorf("pinned revision %s not found in %s: %w", rev, r.URL(), err)
|
|
}
|
|
pinned := *r
|
|
pinned.Rev = rev
|
|
return &pinned, nil
|
|
}
|
|
|
|
func (r *Repo) URL() string {
|
|
if r.Repository == nil {
|
|
return ""
|
|
}
|
|
remote, err := r.Repository.Remote(git.DefaultRemoteName)
|
|
if err != nil || len(remote.Config().URLs) == 0 {
|
|
return ""
|
|
}
|
|
return remote.Config().URLs[0]
|
|
}
|
|
|
|
// Tree returns the commit tree of the pinned revision as a discovery.Tree.
|
|
func (r *Repo) Tree() (discovery.Tree, error) {
|
|
commit, err := r.Repository.CommitObject(plumbing.NewHash(r.Rev))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving commit %s: %w", r.Rev, err)
|
|
}
|
|
tree, err := commit.Tree()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading tree of %s: %w", r.Rev, err)
|
|
}
|
|
return &GitTree{tree: tree}, nil
|
|
}
|
|
|
|
// GitTree adapts a git commit tree to the discovery.Tree interface.
|
|
type GitTree struct {
|
|
tree *object.Tree
|
|
}
|
|
|
|
// NewGitTree wraps a commit tree for discovery and materialization.
|
|
func NewGitTree(t *object.Tree) *GitTree {
|
|
return &GitTree{tree: t}
|
|
}
|
|
|
|
func (g *GitTree) Walk(fn func(rel string, isDir bool) error) error {
|
|
walker := object.NewTreeWalker(g.tree, true, nil)
|
|
defer walker.Close()
|
|
for {
|
|
name, entry, err := walker.Next()
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if name == "." {
|
|
continue
|
|
}
|
|
if err := fn(name, entry.Mode == filemode.Dir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (g *GitTree) ReadFile(rel string) ([]byte, error) {
|
|
f, err := g.tree.File(rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r, err := f.Blob.Reader()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer r.Close()
|
|
return io.ReadAll(r)
|
|
}
|
|
|
|
// FilesUnder lists every file (recursively) below prefix, sorted. A prefix of
|
|
// "" lists the whole tree.
|
|
func (g *GitTree) FilesUnder(prefix string) ([]string, error) {
|
|
var out []string
|
|
walker := object.NewTreeWalker(g.tree, true, nil)
|
|
defer walker.Close()
|
|
for {
|
|
name, entry, err := walker.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "." || entry.Mode == filemode.Dir {
|
|
continue
|
|
}
|
|
if prefix == "" || strings.HasPrefix(name, strings.TrimSuffix(prefix, "/")+"/") {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
// MaterializeFile writes the blob at rel to dst, preserving the executable
|
|
// bit from the tree entry.
|
|
func (g *GitTree) MaterializeFile(rel, dst string) error {
|
|
data, err := g.ReadFile(rel)
|
|
if err != nil {
|
|
return fmt.Errorf("reading %s: %w", rel, err)
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
entry, err := g.tree.FindEntry(rel)
|
|
if err == nil && entry.Mode == filemode.Executable {
|
|
mode = 0o755
|
|
}
|
|
return os.WriteFile(dst, data, mode)
|
|
}
|