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 }