feat(vendor): select command — manage include/exclude selection after initial add
vendor select <name> --add/--remove/--mode all changes what a source contributes without advancing the pinned rev: ids are validated against the pinned upstream (typos abort listing available ids), collisions re-run, renames from the lockfile are honored, and the external area is rewritten via the same staging-swap as update. include mode grows and shrinks the include list; all mode --remove moves ids onto exclude; --mode all resets to everything. Failed selects mutate nothing. Closes beads: agent-lib-cd3
This commit is contained in:
@@ -69,11 +69,40 @@ never executed.
|
|||||||
- Default: vendor **everything** (`mode: all`).
|
- Default: vendor **everything** (`mode: all`).
|
||||||
- `--include a,b,c` switches the source to **include mode**: only those ids
|
- `--include a,b,c` switches the source to **include mode**: only those ids
|
||||||
arrive, and new upstream items do **not** arrive automatically.
|
arrive, and new upstream items do **not** arrive automatically.
|
||||||
- Hard errors, enforced by `vendor add`, `vendor update` and `validate`:
|
- Hard errors, enforced by `vendor add`, `vendor update`, `vendor select`
|
||||||
|
and `validate`:
|
||||||
- `all` combined with an include list → error
|
- `all` combined with an include list → error
|
||||||
- an exclude list without `all` → error (not supported in v2 selections)
|
- an exclude list without `all` → error (not supported in v2 selections)
|
||||||
- an include entry that no longer exists upstream → error naming the item
|
- an include entry that no longer exists upstream → error naming the item
|
||||||
|
|
||||||
|
(`vendor select` changes selections later — see
|
||||||
|
[Changing your selection later](#changing-your-selection-later) below.)
|
||||||
|
|
||||||
|
### Changing your selection later
|
||||||
|
|
||||||
|
`vendor select` manages the selection after the initial add — without ever
|
||||||
|
advancing the pinned revision (that is `vendor update`'s job):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
agent-lib vendor select superpowers --add tdd,brainstorm # include mode: grow
|
||||||
|
agent-lib vendor select superpowers --remove review,debug # include mode: shrink
|
||||||
|
agent-lib vendor select superpowers --remove review # all mode: move to exclude
|
||||||
|
agent-lib vendor select superpowers --mode all # reset: take everything again
|
||||||
|
```
|
||||||
|
|
||||||
|
- **include mode**: `--add` appends ids (validated against the pinned
|
||||||
|
upstream — typos abort with the list of available ids), `--remove` drops
|
||||||
|
ids and deletes their folders from `external/`.
|
||||||
|
- **all mode**: `--remove` moves ids onto the exclude list (lockfile v2
|
||||||
|
supports exclude in all mode) and deletes their folders; `--add` is a hard
|
||||||
|
error — everything is already selected.
|
||||||
|
- `--mode all` clears include/exclude and re-materializes the full pinned
|
||||||
|
upstream, reporting what appeared.
|
||||||
|
|
||||||
|
Every select re-runs the collision checks and honors the lockfile rename map
|
||||||
|
— you can hand-add a rename for a not-yet-selected item, then `--add` it
|
||||||
|
under its deployed name. Failed selects mutate nothing.
|
||||||
|
|
||||||
### Collisions and renames
|
### Collisions and renames
|
||||||
|
|
||||||
Every item deploys into a flat per-type namespace. If a vendored id collides
|
Every item deploys into a flat per-type namespace. If a vendored id collides
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func newVendorCmd() *cobra.Command {
|
|||||||
Short: "Curate external sources into this work repository",
|
Short: "Curate external sources into this work repository",
|
||||||
}
|
}
|
||||||
cmd.AddCommand(newVendorAddCmd())
|
cmd.AddCommand(newVendorAddCmd())
|
||||||
|
cmd.AddCommand(newVendorSelectCmd())
|
||||||
cmd.AddCommand(newVendorListCmd())
|
cmd.AddCommand(newVendorListCmd())
|
||||||
cmd.AddCommand(newVendorInspectCmd())
|
cmd.AddCommand(newVendorInspectCmd())
|
||||||
cmd.AddCommand(newVendorUpdateCmd())
|
cmd.AddCommand(newVendorUpdateCmd())
|
||||||
@@ -71,3 +72,39 @@ func splitList(in []string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newVendorSelectCmd() *cobra.Command {
|
||||||
|
var add, remove []string
|
||||||
|
var mode string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "select <name>",
|
||||||
|
Short: "Change a source's selection without advancing the pinned revision",
|
||||||
|
Long: "Manages include/exclude selection after the initial vendor add.\n" +
|
||||||
|
"Include mode: --add appends ids (validated against the pinned upstream),\n" +
|
||||||
|
"--remove drops ids and deletes their folders. All mode: --remove moves ids\n" +
|
||||||
|
"onto the exclude list; --add is an error (everything is selected already).\n" +
|
||||||
|
"--mode all resets to everything: include/exclude lists are cleared and the\n" +
|
||||||
|
"full pinned upstream is re-materialized. Collisions re-run on every change;\n" +
|
||||||
|
"renames from the lockfile are honored. The revision never moves — that is\n" +
|
||||||
|
"vendor update's job.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if mode != "" && mode != "all" {
|
||||||
|
return fmt.Errorf("--mode accepts only \"all\" (got %q)", mode)
|
||||||
|
}
|
||||||
|
opts := vendor.SelectOptions{
|
||||||
|
Add: splitList(add),
|
||||||
|
Remove: splitList(remove),
|
||||||
|
ModeAll: mode == "all",
|
||||||
|
}
|
||||||
|
if _, err := vendor.Select(".", args[0], opts, cmd.OutOrStdout()); err != nil {
|
||||||
|
return fmt.Errorf("vendor select: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringSliceVar(&add, "add", nil, "comma-separated upstream ids to add to the selection")
|
||||||
|
cmd.Flags().StringSliceVar(&remove, "remove", nil, "comma-separated upstream ids to remove from the selection")
|
||||||
|
cmd.Flags().StringVar(&mode, "mode", "", "switch selection mode (only \"all\": clear include/exclude, take everything)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildTwentySkillUpstream(t *testing.T, h *harness) {
|
||||||
|
t.Helper()
|
||||||
|
for i := 1; i <= 20; i++ {
|
||||||
|
h.write(t, fmt.Sprintf("skills/skill-%02d/SKILL.md", i), fmt.Sprintf("---\nname: Skill %02d\ndescription: s%d\n---\n# %02d\n", i, i, i))
|
||||||
|
}
|
||||||
|
h.commitUpstream(t, "twenty skills")
|
||||||
|
}
|
||||||
|
|
||||||
|
type lockView struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Sources map[string]struct {
|
||||||
|
Rev string `json:"rev"`
|
||||||
|
Renames map[string]string `json:"renames"`
|
||||||
|
Selection struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Exclude []string `json:"exclude"`
|
||||||
|
Include []string `json:"include"`
|
||||||
|
} `json:"selection"`
|
||||||
|
Inventory map[string][]string `json:"inventory"`
|
||||||
|
} `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLock(t *testing.T, workDir string) lockView {
|
||||||
|
t.Helper()
|
||||||
|
data := readFile(t, filepath.Join(workDir, "agent-lib.lock.json"))
|
||||||
|
var v lockView
|
||||||
|
if err := json.Unmarshal([]byte(data), &v); err != nil {
|
||||||
|
t.Fatalf("lockfile not parseable: %v", err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalSkillIDs(t *testing.T, workDir, name string) []string {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(workDir, "external", name, "skills")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotTree(t *testing.T, root string) string {
|
||||||
|
t.Helper()
|
||||||
|
var paths []string
|
||||||
|
filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
paths = append(paths, p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(paths)
|
||||||
|
out := ""
|
||||||
|
for _, p := range paths {
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err == nil {
|
||||||
|
out += p + ":" + fmt.Sprint(len(data)) + "\n"
|
||||||
|
} else {
|
||||||
|
out += p + ":dir\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func addWithInclude(t *testing.T, h *harness, firstFive string) {
|
||||||
|
t.Helper()
|
||||||
|
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir, "--include", firstFive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectAddGrowsSelection(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05")
|
||||||
|
revBefore := readLock(t, h.workDir).Sources["superpowers"].Rev
|
||||||
|
|
||||||
|
out := h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-06,skill-07,skill-08")
|
||||||
|
if !contains(out, "skill-06 (added)") || !contains(out, "skill-08 (added)") {
|
||||||
|
t.Errorf("report must list added items:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := externalSkillIDs(t, h.workDir, "superpowers")
|
||||||
|
if len(got) != 8 {
|
||||||
|
t.Errorf("external area must hold exactly 8 skills, got %d: %v", len(got), got)
|
||||||
|
}
|
||||||
|
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||||
|
if len(v.Selection.Include) != 8 {
|
||||||
|
t.Errorf("include list must hold 8 ids, got %v", v.Selection.Include)
|
||||||
|
}
|
||||||
|
if v.Rev != revBefore {
|
||||||
|
t.Error("select must never advance the pinned rev")
|
||||||
|
}
|
||||||
|
if out := h.mustRun(t, "validate"); contains(out, "invalid") {
|
||||||
|
t.Errorf("validate must stay green:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectRemoveInIncludeMode(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05")
|
||||||
|
|
||||||
|
out := h.mustRun(t, "vendor", "select", "superpowers", "--remove", "skill-02,skill-04")
|
||||||
|
if !contains(out, "skill-02 (removed)") || !contains(out, "skill-04 (removed)") {
|
||||||
|
t.Errorf("report must list removed items:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := externalSkillIDs(t, h.workDir, "superpowers")
|
||||||
|
want := []string{"skill-01", "skill-03", "skill-05"}
|
||||||
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||||
|
t.Errorf("external skills = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||||
|
if fmt.Sprint(v.Selection.Include) != fmt.Sprint(want) {
|
||||||
|
t.Errorf("include list = %v, want %v", v.Selection.Include, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
lockPath := filepath.Join(h.workDir, "agent-lib.lock.json")
|
||||||
|
before := readFile(t, lockPath)
|
||||||
|
h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-01")
|
||||||
|
if after := readFile(t, lockPath); after != before {
|
||||||
|
t.Error("no-op select must leave the lockfile byte-identical")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectRemoveInAllMode(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
|
||||||
|
|
||||||
|
h.mustRun(t, "vendor", "select", "superpowers", "--remove", "skill-03,skill-17")
|
||||||
|
|
||||||
|
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||||
|
if v.Selection.Mode != "all" {
|
||||||
|
t.Errorf("mode must stay all, got %q", v.Selection.Mode)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(v.Selection.Exclude) != fmt.Sprint([]string{"skill-03", "skill-17"}) {
|
||||||
|
t.Errorf("exclude list = %v", v.Selection.Exclude)
|
||||||
|
}
|
||||||
|
got := externalSkillIDs(t, h.workDir, "superpowers")
|
||||||
|
if len(got) != 18 {
|
||||||
|
t.Errorf("external area must hold 18 skills, got %d", len(got))
|
||||||
|
}
|
||||||
|
for _, gone := range []string{"skill-03", "skill-17"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(h.workDir, "external", "superpowers", "skills", gone)); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("removed skill %s must be gone from the external area", gone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mustRun(t, "validate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectModeAllResets(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
addWithInclude(t, h, "skill-01,skill-02,skill-03")
|
||||||
|
|
||||||
|
out := h.mustRun(t, "vendor", "select", "superpowers", "--mode", "all")
|
||||||
|
for _, id := range []string{"skill-04", "skill-20"} {
|
||||||
|
if !contains(out, id+" (added)") {
|
||||||
|
t.Errorf("report must list what arrived (missing %s):\n%s", id, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||||
|
if v.Selection.Mode != "all" || len(v.Selection.Include) > 0 || len(v.Selection.Exclude) > 0 {
|
||||||
|
t.Errorf("mode all must clear include/exclude: %+v", v.Selection)
|
||||||
|
}
|
||||||
|
if got := externalSkillIDs(t, h.workDir, "superpowers"); len(got) != 20 {
|
||||||
|
t.Errorf("all 20 skills must be materialized, got %d", len(got))
|
||||||
|
}
|
||||||
|
h.mustRun(t, "validate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectAddTypoIsHardError(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
addWithInclude(t, h, "skill-01,skill-02")
|
||||||
|
|
||||||
|
lockPath := filepath.Join(h.workDir, "agent-lib.lock.json")
|
||||||
|
lockBefore := readFile(t, lockPath)
|
||||||
|
treeBefore := snapshotTree(t, filepath.Join(h.workDir, "external"))
|
||||||
|
|
||||||
|
out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-99")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("unknown id must be a hard error:\n%s", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "skill-99") || !contains(out, "skill-01") {
|
||||||
|
t.Errorf("error must name the missing id and list available ids:\n%s", out)
|
||||||
|
}
|
||||||
|
if readFile(t, lockPath) != lockBefore {
|
||||||
|
t.Error("failed select must not mutate the lockfile")
|
||||||
|
}
|
||||||
|
if snapshotTree(t, filepath.Join(h.workDir, "external")) != treeBefore {
|
||||||
|
t.Error("failed select must not mutate the external area")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectRemoveTypoInIncludeMode(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
addWithInclude(t, h, "skill-01,skill-02")
|
||||||
|
|
||||||
|
out, err := h.run(t, "vendor", "select", "superpowers", "--remove", "skill-09")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("--remove of an unselected id must be a hard error:\n%s", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "not in the current selection") {
|
||||||
|
t.Errorf("error must explain the id is not selected:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectAddInAllModeIsError(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
|
||||||
|
|
||||||
|
out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-04")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("--add in all mode must be a hard error:\n%s", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "already selected in all mode") {
|
||||||
|
t.Errorf("error must explain that everything is selected in all mode:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectCollisionGuardAndRenameHonored(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
addWithInclude(t, h, "skill-01,skill-02")
|
||||||
|
os.MkdirAll(filepath.Join(h.workDir, "skills", "skill-09"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(h.workDir, "skills", "skill-09", "SKILL.md"), []byte("---\nname: Own Nine\n---\n"), 0o644)
|
||||||
|
|
||||||
|
out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-09")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("collision must abort the select:\n%s", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "collides with own skills") {
|
||||||
|
t.Errorf("error must name both parties:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
lockPath := filepath.Join(h.workDir, "agent-lib.lock.json")
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(readFile(t, lockPath)), &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := raw["sources"].(map[string]any)["superpowers"].(map[string]any)
|
||||||
|
src["renames"] = map[string]string{"skill-09": "nine-renamed"}
|
||||||
|
data, _ := json.Marshal(raw)
|
||||||
|
os.WriteFile(lockPath, data, 0o644)
|
||||||
|
|
||||||
|
h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-09")
|
||||||
|
if _, err := os.Stat(filepath.Join(h.workDir, "external", "superpowers", "skills", "nine-renamed", "SKILL.md")); err != nil {
|
||||||
|
t.Error("renamed item must materialize under its deployed name")
|
||||||
|
}
|
||||||
|
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||||
|
if v.Renames["skill-09"] != "nine-renamed" {
|
||||||
|
t.Errorf("rename map must survive select: %v", v.Renames)
|
||||||
|
}
|
||||||
|
if out := h.mustRun(t, "validate"); contains(out, "invalid") {
|
||||||
|
t.Errorf("validate must stay green after rename select:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectModeAllRejectsCombinedFlags(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
addWithInclude(t, h, "skill-01")
|
||||||
|
|
||||||
|
out, err := h.run(t, "vendor", "select", "superpowers", "--mode", "all", "--add", "skill-02")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("--mode all with --add must be rejected:\n%s", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "cannot be combined") {
|
||||||
|
t.Errorf("error must explain the flag conflict:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectNoFlagsIsError(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
addWithInclude(t, h, "skill-01")
|
||||||
|
|
||||||
|
out, err := h.run(t, "vendor", "select", "superpowers")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("select without flags must be rejected:\n%s", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "nothing to do") {
|
||||||
|
t.Errorf("error must list the available actions:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorSelectUnknownSourceAndUpstreamAdvance(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
buildTwentySkillUpstream(t, h)
|
||||||
|
addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05")
|
||||||
|
revPinned := readLock(t, h.workDir).Sources["superpowers"].Rev
|
||||||
|
|
||||||
|
if out, err := h.run(t, "vendor", "select", "nope"); err == nil {
|
||||||
|
t.Fatalf("unknown source must fail:\n%s", out)
|
||||||
|
} else if !contains(out, "not in the lockfile") {
|
||||||
|
t.Errorf("error must name the lockfile:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.write(t, "skills/skill-01/SKILL.md", "---\nname: Skill 01\ndescription: changed upstream\n---\n# changed\n")
|
||||||
|
h.commitUpstream(t, "upstream moves on")
|
||||||
|
|
||||||
|
h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-06")
|
||||||
|
|
||||||
|
v := readLock(t, h.workDir).Sources["superpowers"]
|
||||||
|
if v.Rev != revPinned {
|
||||||
|
t.Error("select must keep the pinned rev even when upstream moved on")
|
||||||
|
}
|
||||||
|
changed, err := os.ReadFile(filepath.Join(h.workDir, "external", "superpowers", "skills", "skill-01", "SKILL.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if contains(string(changed), "changed upstream") {
|
||||||
|
t.Error("select must materialize pinned content, not the ref head")
|
||||||
|
}
|
||||||
|
if out := h.mustRun(t, "validate"); contains(out, "invalid") {
|
||||||
|
t.Errorf("validate must stay green:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,28 @@ func resolveHead(repo *git.Repository, requestedRef string) (*Repo, error) {
|
|||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin returns a copy of the repo resolved to an explicit revision, so a
|
||||||
|
// selection can be re-materialized at the pinned rev without advancing it.
|
||||||
|
func (r *Repo) Pin(rev string) (*Repo, error) {
|
||||||
|
if _, err := r.Repository.CommitObject(plumbing.NewHash(rev)); err != nil {
|
||||||
|
return nil, fmt.Errorf("pinned revision %s not found in %s: %w", rev, r.URL(), err)
|
||||||
|
}
|
||||||
|
pinned := *r
|
||||||
|
pinned.Rev = rev
|
||||||
|
return &pinned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) URL() string {
|
||||||
|
if r.Repository == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
remote, err := r.Repository.Remote(git.DefaultRemoteName)
|
||||||
|
if err != nil || len(remote.Config().URLs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return remote.Config().URLs[0]
|
||||||
|
}
|
||||||
|
|
||||||
// Tree returns the commit tree of the pinned revision as a discovery.Tree.
|
// Tree returns the commit tree of the pinned revision as a discovery.Tree.
|
||||||
func (r *Repo) Tree() (discovery.Tree, error) {
|
func (r *Repo) Tree() (discovery.Tree, error) {
|
||||||
commit, err := r.Repository.CommitObject(plumbing.NewHash(r.Rev))
|
commit, err := r.Repository.CommitObject(plumbing.NewHash(r.Rev))
|
||||||
|
|||||||
Vendored
+192
@@ -0,0 +1,192 @@
|
|||||||
|
package vendor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SelectOptions parameterize vendor select.
|
||||||
|
type SelectOptions struct {
|
||||||
|
Add []string
|
||||||
|
Remove []string
|
||||||
|
ModeAll bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select changes what a vendored source contributes — without advancing the
|
||||||
|
// pinned revision (that is vendor update's job). Ids are validated against
|
||||||
|
// the pinned upstream, collisions re-run, and the external area is rewritten
|
||||||
|
// deterministically to match the new selection.
|
||||||
|
func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*ChangeReport, error) {
|
||||||
|
lockPath := filepath.Join(workDir, lockfile.FileName)
|
||||||
|
lf, err := lockfile.Load(lockPath)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
newSel, err := planSelection(src, opts, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := gitsource.Clone(src.URL, src.Ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pinned, err := repo.Pin(src.Rev)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w — run vendor update to re-pin", err)
|
||||||
|
}
|
||||||
|
tree, err := pinned.Tree()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, err := discovery.Scan(tree, discovery.FromLockfileDiscovery(derefDiscovery(src)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSelectIDs(items, src, opts, name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := applySelection(items, newSel)
|
||||||
|
if err := checkCollisions(workDir, lf, name, selected, src.Renames); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
report := &ChangeReport{Source: name, FromRev: src.Rev, ToRev: src.Rev}
|
||||||
|
report.Added, report.Removed = membershipDiff(inventorySet(src.Inventory), idSet(selected))
|
||||||
|
|
||||||
|
cleanupStaleStaging(workDir)
|
||||||
|
staging := filepath.Join(workDir, externalDir, ".staging-"+name)
|
||||||
|
if err := os.RemoveAll(staging); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := materializeTree(tree, selected, src.Renames, staging); err != nil {
|
||||||
|
os.RemoveAll(staging)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := fillChanged(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.Selection = newSel
|
||||||
|
src.Inventory = inventoryOf(selected)
|
||||||
|
src.Warnings = dedupe(warningsOf(items))
|
||||||
|
if err := lf.Save(lockPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(stdout, "source %s: selection updated at pinned rev %s (mode %s)\n", name, short(src.Rev), newSel.Mode)
|
||||||
|
printChangeReport(stdout, report)
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// planSelection derives the new selection from the flags — pure, before any
|
||||||
|
// upstream contact: --mode all resets to everything; include mode grows and
|
||||||
|
// shrinks the include list; all mode --remove moves ids onto the exclude
|
||||||
|
// list.
|
||||||
|
func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockfile.Selection, error) {
|
||||||
|
if opts.ModeAll {
|
||||||
|
if len(opts.Add) > 0 || len(opts.Remove) > 0 {
|
||||||
|
return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add or --remove")
|
||||||
|
}
|
||||||
|
return lockfile.Selection{Mode: lockfile.ModeAll}, nil
|
||||||
|
}
|
||||||
|
if len(opts.Add) == 0 && len(opts.Remove) == 0 {
|
||||||
|
return lockfile.Selection{}, fmt.Errorf("nothing to do: pass --add <ids>, --remove <ids> or --mode all")
|
||||||
|
}
|
||||||
|
if src.Selection.Mode == lockfile.ModeAll {
|
||||||
|
if len(opts.Add) > 0 {
|
||||||
|
return lockfile.Selection{}, fmt.Errorf("source %q: everything is already selected in all mode; use --remove <ids> to exclude items", name)
|
||||||
|
}
|
||||||
|
return lockfile.Selection{
|
||||||
|
Mode: lockfile.ModeAll,
|
||||||
|
Exclude: unionIDs(src.Selection.Exclude, opts.Remove),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return lockfile.Selection{
|
||||||
|
Mode: lockfile.ModeInclude,
|
||||||
|
Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSelectIDs checks flag ids against the pinned upstream (and, for
|
||||||
|
// --remove in include mode, against the current selection) so typos fail
|
||||||
|
// before anything is mutated.
|
||||||
|
func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts SelectOptions, name string) error {
|
||||||
|
if len(opts.Add) > 0 {
|
||||||
|
if err := checkIncludeEntries(items, opts.Add, name); err != nil {
|
||||||
|
return fmt.Errorf("--add: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(opts.Remove) > 0 && src.Selection.Mode == lockfile.ModeInclude {
|
||||||
|
selected := map[string]bool{}
|
||||||
|
for _, id := range src.Selection.Include {
|
||||||
|
selected[id] = true
|
||||||
|
}
|
||||||
|
for _, id := range opts.Remove {
|
||||||
|
if !selected[id] {
|
||||||
|
return fmt.Errorf("source %q: --remove id %q is not in the current selection (selected: %s)", name, id, strings.Join(sortedKeys(selected), ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(opts.Remove) > 0 && src.Selection.Mode == lockfile.ModeAll {
|
||||||
|
if err := checkIncludeEntries(items, opts.Remove, name); err != nil {
|
||||||
|
return fmt.Errorf("--remove: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unionIDs(a, b []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, id := range a {
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
out := append([]string{}, a...)
|
||||||
|
for _, id := range b {
|
||||||
|
if !seen[id] {
|
||||||
|
out = append(out, id)
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func minusIDs(a, remove []string) []string {
|
||||||
|
drop := map[string]bool{}
|
||||||
|
for _, id := range remove {
|
||||||
|
drop[id] = true
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, id := range a {
|
||||||
|
if !drop[id] {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user