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:
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+503
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user