feat: vendor update/diff/remove with per-type change reports

- update advances the pinned rev, rewrites the external area via staging+swap,
  reports added/changed/removed per type; include mode holds its selection,
  vanished include entries abort loudly with the tree untouched
- all mode pulls new upstream items automatically; collision checks re-run on
  every update and honor the rename map
- diff is strictly read-only (temp dir outside the repo) and surfaces new
  upstream items as available-but-not-selected
- remove deletes external area + lockfile entry and prunes empty external root
- update --all reports per source and surfaces every failure
This commit is contained in:
2026-08-22 22:01:06 +02:00
parent b4bd57a22f
commit a755d138da
4 changed files with 790 additions and 0 deletions
+66
View File
@@ -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 [<name>]",
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 <name>",
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 <name>",
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
},
}
}
+3
View File
@@ -17,6 +17,9 @@ func newVendorCmd() *cobra.Command {
cmd.AddCommand(newVendorAddCmd()) cmd.AddCommand(newVendorAddCmd())
cmd.AddCommand(newVendorListCmd()) cmd.AddCommand(newVendorListCmd())
cmd.AddCommand(newVendorInspectCmd()) cmd.AddCommand(newVendorInspectCmd())
cmd.AddCommand(newVendorUpdateCmd())
cmd.AddCommand(newVendorDiffCmd())
cmd.AddCommand(newVendorRemoveCmd())
return cmd return cmd
} }
+218
View File
@@ -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")
}
}
+503
View File
@@ -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
}