diff --git a/internal/cli/update.go b/internal/cli/update.go new file mode 100644 index 0000000..46616ed --- /dev/null +++ b/internal/cli/update.go @@ -0,0 +1,66 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/m3tam3re/agent-lib/internal/vendor" +) + +func newVendorUpdateCmd() *cobra.Command { + var all bool + cmd := &cobra.Command{ + Use: "update []", + Short: "Fetch the latest revision of the pinned ref and rewrite the external area", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if all { + if len(args) != 0 { + return fmt.Errorf("--all takes no source name") + } + if err := vendor.UpdateAll(".", cmd.OutOrStdout()); err != nil { + return fmt.Errorf("vendor update: %w", err) + } + return nil + } + if len(args) != 1 { + return fmt.Errorf("specify a source name or --all") + } + if _, err := vendor.Update(".", args[0], cmd.OutOrStdout()); err != nil { + return fmt.Errorf("vendor update: %w", err) + } + return nil + }, + } + cmd.Flags().BoolVar(&all, "all", false, "update every source and report per source") + return cmd +} + +func newVendorDiffCmd() *cobra.Command { + return &cobra.Command{ + Use: "diff ", + Short: "Compare upstream against the current selection without modifying anything", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if _, err := vendor.Diff(".", args[0], cmd.OutOrStdout()); err != nil { + return fmt.Errorf("vendor diff: %w", err) + } + return nil + }, + } +} + +func newVendorRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Delete a source's external area and lockfile entry", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := vendor.Remove(".", args[0], cmd.OutOrStdout()); err != nil { + return fmt.Errorf("vendor remove: %w", err) + } + return nil + }, + } +} diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index bf24096..06ec04e 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -17,6 +17,9 @@ func newVendorCmd() *cobra.Command { cmd.AddCommand(newVendorAddCmd()) cmd.AddCommand(newVendorListCmd()) cmd.AddCommand(newVendorInspectCmd()) + cmd.AddCommand(newVendorUpdateCmd()) + cmd.AddCommand(newVendorDiffCmd()) + cmd.AddCommand(newVendorRemoveCmd()) return cmd } diff --git a/internal/e2e/update_test.go b/internal/e2e/update_test.go new file mode 100644 index 0000000..8925ddf --- /dev/null +++ b/internal/e2e/update_test.go @@ -0,0 +1,218 @@ +package e2e + +import ( + "os" + "path/filepath" + "testing" + + "github.com/m3tam3re/agent-lib/internal/lockfile" +) + +func bumpUpstream(h *harness, t *testing.T) { + t.Helper() + h.write(t, "skills/fresh-skill/SKILL.md", "---\nname: Fresh\n---\n# Fresh\n") + h.write(t, "skills/good-skill/SKILL.md", "---\nname: Good Skill\ndescription: changed upstream\n---\n# Changed\n") + os.RemoveAll(filepath.Join(h.upstreamDir, "skills/broken-skill")) + os.Remove(filepath.Join(h.upstreamDir, "agents/scout.md")) + h.commitUpstream(t, "fixture: add fresh, change good, remove broken+scout") +} + +func TestVendorUpdateHappyPath(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + oldLock := readFile(t, filepath.Join(h.workDir, lockfile.FileName)) + + bumpUpstream(h, t) + out := h.mustRun(t, "vendor", "update", "superpowers") + + for _, want := range []string{"fresh-skill (added)", "good-skill (changed)", "broken-skill (removed)", "scout (removed)"} { + if !contains(out, want) { + t.Errorf("report missing %q:\n%s", want, out) + } + } + + newSkill := filepath.Join(h.workDir, "external/superpowers/skills/fresh-skill/SKILL.md") + if _, err := os.Stat(newSkill); err != nil { + t.Error("added skill must be materialized") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/skills/broken-skill")); err == nil { + t.Error("removed skill must be gone from the external area") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/agents/scout.md")); err == nil { + t.Error("removed agent must be gone from the external area") + } + + newLock := readFile(t, filepath.Join(h.workDir, lockfile.FileName)) + if newLock == oldLock { + t.Fatal("lockfile must advance its rev") + } + if !contains(newLock, "fresh-skill") || contains(newLock, "broken-skill") { + t.Errorf("inventory must reflect the new selection:\n%s", newLock) + } + if out := h.mustRun(t, "validate"); !contains(out, "valid") { + t.Errorf("updated state must validate:\n%s", out) + } +} + +func TestVendorUpdateIncludeModeHoldsSelection(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--include", "good-skill") + + bumpUpstream(h, t) + + diffOut := h.mustRun(t, "vendor", "diff", "curated") + if !contains(diffOut, "fresh-skill (available upstream, not selected)") { + t.Errorf("diff must surface new upstream items as available:\n%s", diffOut) + } + + out := h.mustRun(t, "vendor", "update", "curated") + if contains(out, "fresh-skill (added)") { + t.Errorf("include mode must not pull new upstream items:\n%s", out) + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/fresh-skill")); err == nil { + t.Error("fresh-skill must not be materialized in include mode") + } + if !contains(out, "good-skill (changed)") { + t.Errorf("included item changes must still arrive:\n%s", out) + } +} + +func TestVendorUpdateVanishedIncludeFailsLoud(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--include", "good-skill") + lockBefore := readFile(t, filepath.Join(h.workDir, lockfile.FileName)) + + os.RemoveAll(filepath.Join(h.upstreamDir, "skills/good-skill")) + h.commitUpstream(t, "fixture: remove good-skill") + + out, err := h.run(t, "vendor", "update", "curated") + if err == nil { + t.Fatalf("vanished include entry must fail the update:\n%s", out) + } + if !contains(out, "good-skill") || !contains(out, "not found upstream") { + t.Errorf("error must name the vanished entry:\n%s", out) + } + if got := readFile(t, filepath.Join(h.workDir, lockfile.FileName)); got != lockBefore { + t.Error("failed update must leave the lockfile untouched") + } +} + +func TestVendorUpdateUpToDateIdempotent(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + lockBefore := readFile(t, filepath.Join(h.workDir, lockfile.FileName)) + + out := h.mustRun(t, "vendor", "update", "superpowers") + if !contains(out, "already up to date") { + t.Errorf("unchanged upstream must report up-to-date:\n%s", out) + } + if got := readFile(t, filepath.Join(h.workDir, lockfile.FileName)); got != lockBefore { + t.Error("up-to-date update must not touch the lockfile") + } +} + +func TestVendorDiffReadOnly(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + lockBefore := readFile(t, filepath.Join(h.workDir, lockfile.FileName)) + out := h.mustRun(t, "vendor", "diff", "superpowers") + if !contains(out, "up to date") { + t.Errorf("unchanged upstream diff:\n%s", out) + } + + bumpUpstream(h, t) + out = h.mustRun(t, "vendor", "diff", "superpowers") + for _, want := range []string{"fresh-skill", "good-skill", "broken-skill"} { + if !contains(out, want) { + t.Errorf("diff must list %q:\n%s", want, out) + } + } + if got := readFile(t, filepath.Join(h.workDir, lockfile.FileName)); got != lockBefore { + t.Error("diff must not modify the lockfile") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/skills/fresh-skill")); err == nil { + t.Error("diff must not materialize anything") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/skills/broken-skill")); err != nil { + t.Error("diff must not remove anything") + } +} + +func TestVendorRemoveLeavesNoResidue(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + + out := h.mustRun(t, "vendor", "remove", "superpowers") + if !contains(out, "removed") { + t.Errorf("remove must confirm:\n%s", out) + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers")); err == nil { + t.Error("external area must be gone") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external")); err == nil { + t.Error("empty external root must be cleaned up") + } + lock := readFile(t, filepath.Join(h.workDir, lockfile.FileName)) + if contains(lock, "superpowers") { + t.Errorf("lockfile entry must be gone:\n%s", lock) + } +} + +func TestVendorUpdateAll(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "alpha", h.upstreamDir) + h.mustRun(t, "vendor", "add", "beta", h.upstreamDir, "--include", "good-skill", + "--rename", "good-skill=beta-good") + + bumpUpstream(h, t) + out := h.mustRun(t, "vendor", "update", "--all") + if !contains(out, "alpha") || !contains(out, "beta") { + t.Errorf("update --all must report per source:\n%s", out) + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/alpha/skills/fresh-skill/SKILL.md")); err != nil { + t.Error("alpha (all mode) must receive fresh-skill") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/beta/skills/fresh-skill")); err == nil { + t.Error("beta (include mode) must not receive fresh-skill") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/beta/skills/beta-good/SKILL.md")); err != nil { + t.Error("beta rename must survive the update") + } +} + +func TestVendorUpdateCollisionWithOwn(t *testing.T) { + h := newHarness(t) + standardFixture(h, t) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + lockBefore := readFile(t, filepath.Join(h.workDir, lockfile.FileName)) + + ownDir := filepath.Join(h.workDir, "skills", "fresh-skill") + if err := os.MkdirAll(ownDir, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(ownDir, "SKILL.md"), []byte("---\nname: My Own Fresh\n---\n"), 0o644) + + h.write(t, "skills/fresh-skill/SKILL.md", "---\nname: Upstream Fresh\n---\n") + h.commitUpstream(t, "fixture: add fresh-skill that collides") + + out, err := h.run(t, "vendor", "update", "superpowers") + if err == nil { + t.Fatalf("upstream collision with own item must fail:\n%s", out) + } + if !contains(out, "collides") || !contains(out, "own") { + t.Errorf("error must name both parties:\n%s", out) + } + if got := readFile(t, filepath.Join(h.workDir, lockfile.FileName)); got != lockBefore { + t.Error("failed update must not touch the lockfile") + } + if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/skills/good-skill/SKILL.md")); err != nil { + t.Error("previous external area must survive the failed update") + } +} diff --git a/internal/vendor/update.go b/internal/vendor/update.go new file mode 100644 index 0000000..e03e26a --- /dev/null +++ b/internal/vendor/update.go @@ -0,0 +1,503 @@ +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 +}