// 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 } // 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 } 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) }