Files
agent-lib/internal/gitsource/git.go
T
m3ta-chiron 49dbcf469e feat: client sync core — config, pull, inventory, manifest, OpenCode target
- admin-protected client config (flag > AGENT_LIB_CONFIG > platform default),
  token used for basic auth only, redacted from all errors and files
- work-repo pull via go-git (bare cache clone + fetch, remote re-pointed on
  config change), clean failure keeps previous state
- inventory: own items + external areas from lockfile; MCP inventoried but
  never deployed
- manifest v1 records name/type/origin/revision/content-hash per item,
  deterministic bytes; folder hashes over sorted relpath+filehash lines so
  tree and disk hashes agree
- shared deploy package (atomic temp+rename, exec-bit preserving) now backs
  both curator materialization and client deployment
- sync validates and hashes everything before the first mutation; second run
  is a byte-level no-op
2026-08-23 10:31:00 +02:00

191 lines
4.7 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
}
// 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)
}