504 lines
14 KiB
Go
504 lines
14 KiB
Go
package vendor
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"crypto/sha256"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/m3tam3re/agent-lib/internal/discovery"
|
||
|
|
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
||
|
|
"github.com/m3tam3re/agent-lib/internal/lockfile"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ChangeReport describes one update (or diff preview) for one source.
|
||
|
|
type ChangeReport struct {
|
||
|
|
Source string `json:"source"`
|
||
|
|
FromRev string `json:"from_rev,omitempty"`
|
||
|
|
ToRev string `json:"to_rev,omitempty"`
|
||
|
|
UpToDate bool `json:"up_to_date,omitempty"`
|
||
|
|
Added map[string][]string `json:"added,omitempty"`
|
||
|
|
Removed map[string][]string `json:"removed,omitempty"`
|
||
|
|
Changed map[string][]string `json:"changed,omitempty"`
|
||
|
|
Available map[string][]string `json:"available,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update refreshes one source to the latest revision of its pinned ref,
|
||
|
|
// rewrites the external area deterministically and reports the changes.
|
||
|
|
func Update(workDir, name string, stdout io.Writer) (*ChangeReport, error) {
|
||
|
|
lf, err := lockfile.Load(filepath.Join(workDir, lockfile.FileName))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
src, ok := lf.Sources[name]
|
||
|
|
if !ok {
|
||
|
|
return nil, fmt.Errorf("source %q is not in the lockfile", name)
|
||
|
|
}
|
||
|
|
|
||
|
|
plan, err := computeUpdate(workDir, lf, name, true)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if plan.Report.UpToDate {
|
||
|
|
fmt.Fprintf(stdout, "source %s already up to date (rev %s)\n", name, src.Rev)
|
||
|
|
return plan.Report, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
cleanupStaleStaging(workDir)
|
||
|
|
staging := filepath.Join(workDir, externalDir, ".staging-"+name)
|
||
|
|
if err := os.RemoveAll(staging); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := materializeTree(plan.Tree, plan.Selected, src.Renames, staging); err != nil {
|
||
|
|
os.RemoveAll(staging)
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := fillChanged(plan.Report, name, lf, staging); err != nil {
|
||
|
|
os.RemoveAll(staging)
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
oldDir := filepath.Join(workDir, externalDir, name)
|
||
|
|
if err := os.RemoveAll(oldDir); err != nil {
|
||
|
|
return nil, fmt.Errorf("replacing %s: %w", oldDir, err)
|
||
|
|
}
|
||
|
|
if err := os.Rename(staging, oldDir); err != nil {
|
||
|
|
return nil, fmt.Errorf("swapping in new external area: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
src.Rev = plan.Repo.Rev
|
||
|
|
src.Inventory = inventoryOf(plan.Selected)
|
||
|
|
src.Warnings = dedupe(warningsOf(plan.Items))
|
||
|
|
if err := lf.Save(filepath.Join(workDir, lockfile.FileName)); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
printChangeReport(stdout, plan.Report)
|
||
|
|
return plan.Report, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// UpdateAll refreshes every source; a failing source does not block the
|
||
|
|
// others, but the run reports every failure and exits non-zero.
|
||
|
|
func UpdateAll(workDir string, stdout io.Writer) error {
|
||
|
|
lf, err := lockfile.Load(filepath.Join(workDir, lockfile.FileName))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(lf.Sources) == 0 {
|
||
|
|
fmt.Fprintln(stdout, "no sources to update")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
var failures []string
|
||
|
|
for _, name := range sortedSourceNames(lf) {
|
||
|
|
if _, err := Update(workDir, name, stdout); err != nil {
|
||
|
|
fmt.Fprintf(stdout, "source %s: FAILED: %v\n", name, err)
|
||
|
|
failures = append(failures, name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(failures) > 0 {
|
||
|
|
return fmt.Errorf("update failed for: %s", strings.Join(failures, ", "))
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Diff previews an update without touching the work repository, surfacing
|
||
|
|
// new upstream items that the current selection would not bring in.
|
||
|
|
func Diff(workDir, name string, stdout io.Writer) (*ChangeReport, error) {
|
||
|
|
lf, err := lockfile.Load(filepath.Join(workDir, lockfile.FileName))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
src, ok := lf.Sources[name]
|
||
|
|
if !ok {
|
||
|
|
return nil, fmt.Errorf("source %q is not in the lockfile", name)
|
||
|
|
}
|
||
|
|
|
||
|
|
plan, err := computeUpdate(workDir, lf, name, false)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if plan.Report.UpToDate {
|
||
|
|
fmt.Fprintf(stdout, "source %s up to date (rev %s); no changes upstream\n", name, src.Rev)
|
||
|
|
return plan.Report, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
tmp, err := os.MkdirTemp("", "agent-lib-diff-")
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer os.RemoveAll(tmp)
|
||
|
|
staged := filepath.Join(tmp, "staged")
|
||
|
|
if err := materializeTree(plan.Tree, plan.Selected, src.Renames, staged); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := fillChanged(plan.Report, name, lf, staged); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprintf(stdout, "source %s: pending changes vs upstream %s…\n", name, short(plan.Report.ToRev))
|
||
|
|
printChangeReport(stdout, plan.Report)
|
||
|
|
return plan.Report, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove deletes a source's external area and lockfile entry.
|
||
|
|
func Remove(workDir, name string, stdout io.Writer) error {
|
||
|
|
lockPath := filepath.Join(workDir, lockfile.FileName)
|
||
|
|
lf, err := lockfile.Load(lockPath)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if _, ok := lf.Sources[name]; !ok {
|
||
|
|
return fmt.Errorf("source %q is not in the lockfile", name)
|
||
|
|
}
|
||
|
|
if err := os.RemoveAll(filepath.Join(workDir, externalDir, name)); err != nil {
|
||
|
|
return fmt.Errorf("removing external area: %w", err)
|
||
|
|
}
|
||
|
|
delete(lf.Sources, name)
|
||
|
|
if err := lf.Save(lockPath); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
externalRoot := filepath.Join(workDir, externalDir)
|
||
|
|
if entries, err := os.ReadDir(externalRoot); err == nil && len(entries) == 0 {
|
||
|
|
os.Remove(externalRoot)
|
||
|
|
}
|
||
|
|
fmt.Fprintf(stdout, "source %s removed (external area and lockfile entry)\n", name)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type updatePlan struct {
|
||
|
|
Report *ChangeReport
|
||
|
|
Selected []discovery.Item
|
||
|
|
Items []discovery.Item
|
||
|
|
Tree discovery.Tree
|
||
|
|
Repo *gitsource.Repo
|
||
|
|
}
|
||
|
|
|
||
|
|
// computeUpdate clones the pinned ref, rediscovers and reselects. With
|
||
|
|
// withCollisions it also runs the namespace checks that must gate a real
|
||
|
|
// update; diff skips them to stay a pure preview.
|
||
|
|
func computeUpdate(workDir string, lf *lockfile.Lockfile, name string, withCollisions bool) (*updatePlan, error) {
|
||
|
|
src := lf.Sources[name]
|
||
|
|
repo, err := gitsource.Clone(src.URL, src.Ref)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
tree, err := repo.Tree()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items, err := discovery.Scan(tree, discovery.FromLockfileDiscovery(derefDiscovery(src)))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if src.Selection.Mode == lockfile.ModeInclude {
|
||
|
|
if err := checkIncludeEntries(items, src.Selection.Include, name); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
selected := applySelection(items, src.Selection)
|
||
|
|
if withCollisions {
|
||
|
|
if err := checkCollisions(workDir, lf, name, selected, src.Renames); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
report := &ChangeReport{
|
||
|
|
Source: name,
|
||
|
|
FromRev: src.Rev,
|
||
|
|
ToRev: repo.Rev,
|
||
|
|
UpToDate: src.Rev == repo.Rev,
|
||
|
|
}
|
||
|
|
report.Added, report.Removed = membershipDiff(inventorySet(src.Inventory), idSet(selected))
|
||
|
|
report.Available = availableSince(inventorySet(src.Inventory), items, selected)
|
||
|
|
return &updatePlan{Report: report, Selected: selected, Items: items, Tree: tree, Repo: repo}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// availableSince lists upstream items that appeared since the pinned
|
||
|
|
// inventory and that the current selection would not bring in (relevant in
|
||
|
|
// include mode; empty in all mode by construction).
|
||
|
|
func availableSince(old map[string]map[string]bool, items []discovery.Item, selected []discovery.Item) map[string][]string {
|
||
|
|
inSelection := idSet(selected)
|
||
|
|
out := map[string][]string{}
|
||
|
|
for _, it := range items {
|
||
|
|
if old[it.Type][it.UpstreamID] || inSelection[it.Type][it.UpstreamID] {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
out[it.Type] = append(out[it.Type], it.UpstreamID)
|
||
|
|
}
|
||
|
|
for _, ids := range out {
|
||
|
|
sort.Strings(ids)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func cleanupStaleStaging(workDir string) {
|
||
|
|
entries, err := os.ReadDir(filepath.Join(workDir, externalDir))
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
for _, e := range entries {
|
||
|
|
if strings.HasPrefix(e.Name(), ".staging-") {
|
||
|
|
os.RemoveAll(filepath.Join(workDir, externalDir, e.Name()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func materializeTree(tree discovery.Tree, selected []discovery.Item, renames map[string]string, dst string) error {
|
||
|
|
for _, it := range selected {
|
||
|
|
dep := deployedName(it.UpstreamID, renames)
|
||
|
|
base := filepath.Join(dst, it.Type)
|
||
|
|
var target string
|
||
|
|
var err error
|
||
|
|
switch it.Type {
|
||
|
|
case lockfile.TypeSkill:
|
||
|
|
target = filepath.Join(base, dep)
|
||
|
|
err = copyTreeDir(tree, it.RelPath, target)
|
||
|
|
default:
|
||
|
|
target = filepath.Join(base, dep+filepath.Ext(it.RelPath))
|
||
|
|
err = copyTreeFile(tree, it.RelPath, target)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("materializing %s/%s: %w", it.Type, it.UpstreamID, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// fillChanged byte-compares the staged tree against the previous external
|
||
|
|
// area to populate the changed-section of the report.
|
||
|
|
func fillChanged(report *ChangeReport, name string, lf *lockfile.Lockfile, staging string) error {
|
||
|
|
report.Changed = map[string][]string{}
|
||
|
|
src := lf.Sources[name]
|
||
|
|
for _, typ := range lockfile.Types {
|
||
|
|
for _, id := range src.Inventory[typ] {
|
||
|
|
if report.itemIn(typ, id, report.Added) || report.itemIn(typ, id, report.Removed) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
dep := deployedName(id, src.Renames)
|
||
|
|
ext := extFor(typ, staging, dep)
|
||
|
|
oldDir := filepath.Join(externalDir, name, typ)
|
||
|
|
newDir := filepath.Join(staging, typ)
|
||
|
|
same, err := treesEqual(filepath.Join(oldDir, dep+ext), filepath.Join(newDir, dep+ext))
|
||
|
|
if typ == lockfile.TypeSkill {
|
||
|
|
same, err = treesEqual(filepath.Join(oldDir, dep), filepath.Join(newDir, dep))
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("comparing %s/%s: %w", typ, id, err)
|
||
|
|
}
|
||
|
|
if !same {
|
||
|
|
report.Changed[typ] = append(report.Changed[typ], id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func extFor(typ, staging, dep string) string {
|
||
|
|
switch typ {
|
||
|
|
case lockfile.TypeSkill:
|
||
|
|
return ""
|
||
|
|
case lockfile.TypeMcp:
|
||
|
|
if _, err := os.Stat(filepath.Join(staging, typ, dep+".yaml")); err == nil {
|
||
|
|
return ".yaml"
|
||
|
|
}
|
||
|
|
return ".yml"
|
||
|
|
default:
|
||
|
|
return ".md"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *ChangeReport) itemIn(typ, id string, m map[string][]string) bool {
|
||
|
|
for _, x := range m[typ] {
|
||
|
|
if x == id {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func membershipDiff(oldSets, newSets map[string]map[string]bool) (added, removed map[string][]string) {
|
||
|
|
added = map[string][]string{}
|
||
|
|
removed = map[string][]string{}
|
||
|
|
for _, typ := range lockfile.Types {
|
||
|
|
for id := range newSets[typ] {
|
||
|
|
if !oldSets[typ][id] {
|
||
|
|
added[typ] = append(added[typ], id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for id := range oldSets[typ] {
|
||
|
|
if !newSets[typ][id] {
|
||
|
|
removed[typ] = append(removed[typ], id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Strings(added[typ])
|
||
|
|
sort.Strings(removed[typ])
|
||
|
|
}
|
||
|
|
return added, removed
|
||
|
|
}
|
||
|
|
|
||
|
|
func inventorySet(inv map[string][]string) map[string]map[string]bool {
|
||
|
|
out := map[string]map[string]bool{}
|
||
|
|
for typ, ids := range inv {
|
||
|
|
if out[typ] == nil {
|
||
|
|
out[typ] = map[string]bool{}
|
||
|
|
}
|
||
|
|
for _, id := range ids {
|
||
|
|
out[typ][id] = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func idSet(items []discovery.Item) map[string]map[string]bool {
|
||
|
|
out := map[string]map[string]bool{}
|
||
|
|
for _, it := range items {
|
||
|
|
if out[it.Type] == nil {
|
||
|
|
out[it.Type] = map[string]bool{}
|
||
|
|
}
|
||
|
|
out[it.Type][it.UpstreamID] = true
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func treesEqual(a, b string) (bool, error) {
|
||
|
|
filesA, errA := listFilesRecursive(a)
|
||
|
|
filesB, errB := listFilesRecursive(b)
|
||
|
|
if errA != nil || errB != nil {
|
||
|
|
if os.IsNotExist(errA) || os.IsNotExist(errB) {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
return false, fmt.Errorf("walking %s or %s: %w/%w", a, b, errA, errB)
|
||
|
|
}
|
||
|
|
if len(filesA) != len(filesB) {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
for i := range filesA {
|
||
|
|
if filesA[i] != filesB[i] {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
ha, err := hashFile(filepath.Join(a, filesA[i]))
|
||
|
|
if err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
hb, err := hashFile(filepath.Join(b, filesB[i]))
|
||
|
|
if err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
if !bytes.Equal(ha, hb) {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func listFilesRecursive(root string) ([]string, error) {
|
||
|
|
var out []string
|
||
|
|
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if d.IsDir() {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
rel, err := filepath.Rel(root, p)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
out = append(out, filepath.ToSlash(rel))
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
sort.Strings(out)
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func hashFile(path string) ([]byte, error) {
|
||
|
|
f, err := os.Open(path)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
h := sha256.New()
|
||
|
|
if _, err := io.Copy(h, f); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return h.Sum(nil), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func derefDiscovery(src *lockfile.Source) lockfile.Discovery {
|
||
|
|
if src.Discovery == nil {
|
||
|
|
return lockfile.Discovery{}
|
||
|
|
}
|
||
|
|
return *src.Discovery
|
||
|
|
}
|
||
|
|
|
||
|
|
func inventoryOf(selected []discovery.Item) map[string][]string {
|
||
|
|
out := map[string][]string{}
|
||
|
|
for _, it := range selected {
|
||
|
|
out[it.Type] = append(out[it.Type], it.UpstreamID)
|
||
|
|
}
|
||
|
|
for _, ids := range out {
|
||
|
|
sort.Strings(ids)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func warningsOf(items []discovery.Item) []string {
|
||
|
|
var out []string
|
||
|
|
for _, it := range items {
|
||
|
|
out = append(out, it.Warnings...)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func printChangeReport(w io.Writer, r *ChangeReport) {
|
||
|
|
empty := true
|
||
|
|
for _, typ := range lockfile.Types {
|
||
|
|
added := r.Added[typ]
|
||
|
|
removed := r.Removed[typ]
|
||
|
|
changed := r.Changed[typ]
|
||
|
|
available := r.Available[typ]
|
||
|
|
if len(added)+len(removed)+len(changed)+len(available) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if empty {
|
||
|
|
if r.FromRev != "" && r.ToRev != "" && r.FromRev != r.ToRev {
|
||
|
|
fmt.Fprintf(w, "source %s: %s… → %s…\n", r.Source, short(r.FromRev), short(r.ToRev))
|
||
|
|
} else {
|
||
|
|
fmt.Fprintf(w, "source %s:\n", r.Source)
|
||
|
|
}
|
||
|
|
empty = false
|
||
|
|
}
|
||
|
|
fmt.Fprintf(w, " %s:\n", typ)
|
||
|
|
for _, id := range added {
|
||
|
|
fmt.Fprintf(w, " + %s (added)\n", id)
|
||
|
|
}
|
||
|
|
for _, id := range changed {
|
||
|
|
fmt.Fprintf(w, " ~ %s (changed)\n", id)
|
||
|
|
}
|
||
|
|
for _, id := range removed {
|
||
|
|
fmt.Fprintf(w, " - %s (removed)\n", id)
|
||
|
|
}
|
||
|
|
for _, id := range available {
|
||
|
|
fmt.Fprintf(w, " ? %s (available upstream, not selected)\n", id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if empty {
|
||
|
|
fmt.Fprintf(w, "source %s: no item changes\n", r.Source)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func short(rev string) string {
|
||
|
|
if len(rev) > 8 {
|
||
|
|
return rev[:8]
|
||
|
|
}
|
||
|
|
return rev
|
||
|
|
}
|