feat(vendor): --exclude plus content-type selection (--types/--exclude-types)

vendor add gains --exclude a,b,c (all mode minus those ids) as the mirror
of --include, for taking the majority of a source while dropping a few
items. Mutual exclusion with --include is a hard error, unknown ids fail
naming the available ones (generalized checkIDEntries).

vendor add and vendor select gain --types (allow-list) and --exclude-types
(deny-list) over the four content types. The filter is stored as the
optional, v2-compatible Selection.exclude_types field and survives vendor
update: new upstream items of an excluded type never enter the work
repository (unlike id expansion, which would silently re-admit them).
--types/--exclude-types replace the filter on select, absent flags keep
it, and --mode all clears it along with include/exclude.

Also fixes materializeTree to create the staging directory for an empty
selection — previously select-to-zero failed at the staging rename.
This commit is contained in:
m3ta-chiron committed 2026-10-02 12:35:39 +02:00
1 parent 1ab97bff43
commit 0c779bc970
9 files changed
+512 -29

No files matched your search

+19 -3
View File
@@ -89,11 +89,23 @@ executed.
- Default: vendor **everything** (`mode: all`).
- `--include a,b,c` switches the source to **include mode**: only those ids
arrive, and new upstream items do **not** arrive automatically.
- `--exclude a,b,c` keeps **all mode** minus those ids — for taking the
majority of a source while dropping a few items.
- `--types skills,commands` (**allow-list**) and `--exclude-types agents,mcp`
(**deny-list**) filter whole content types. The filter is stored as
`exclude_types` in the lockfile and **survives updates**: new upstream items
of an excluded type never enter the work repository. `--types` covering all
four types normalizes to no filter; excluding all four fails at the
zero-selection guard.
- Hard errors, enforced by `vendor add`, `vendor update`, `vendor select`
and `validate`:
- `all` combined with an include list → error
- 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 or exclude entry that no longer exists upstream → error naming the item
- `--include` combined with `--exclude` → error
- `--types` combined with `--exclude-types` → error
- unknown type names → error listing `skills, commands, agents, mcp`
- an include entry belonging to a type the type filter excludes → error
(`vendor select` changes selections later — see
[Changing your selection later](#changing-your-selection-later) below.)
@@ -107,6 +119,7 @@ advancing the pinned revision (that is `vendor update`'s job):
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 --types skills # replace the type filter
agent-lib vendor select superpowers --mode all # reset: take everything again
```
@@ -116,8 +129,11 @@ agent-lib vendor select superpowers --mode all # reset: take everyth
- **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.
- `--types` / `--exclude-types` **replace** the content-type filter
(same semantics as on `vendor add`); when neither flag is passed, the
stored filter stays as-is.
- `--mode all` clears include/exclude **and the type filter** 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
+22 -6
View File
@@ -26,7 +26,7 @@ func newVendorCmd() *cobra.Command {
func newVendorAddCmd() *cobra.Command {
var ref string
var include []string
var include, exclude, onlyTypes, excludeTypes []string
var renames []string
cmd := &cobra.Command{
Use: "add <name> <url>",
@@ -34,7 +34,10 @@ func newVendorAddCmd() *cobra.Command {
Long: "Clones the source (in-process, via go-git), discovers skills, commands,\n" +
"agents and MCP fragments, materializes the selection under external/<name>/\n" +
"and pins URL, ref, revision, selection and renames in the lockfile.\n" +
"Default selection is everything; --include switches to an explicit list.\n" +
"Default selection is everything; --include switches to an explicit list,\n" +
"--exclude keeps everything except the listed ids. --types (allow-list) and\n" +
"--exclude-types (deny-list) filter whole content types and survive updates:\n" +
"new upstream items of an excluded type never enter the work repository.\n" +
"Collisions abort with a hard error; resolve them with --rename upstream=deployed.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -47,6 +50,9 @@ func newVendorAddCmd() *cobra.Command {
URL: args[1],
Ref: ref,
Include: splitList(include),
Exclude: splitList(exclude),
OnlyTypes: splitList(onlyTypes),
ExcludeTypes: splitList(excludeTypes),
Renames: renameMap,
}
if err := vendor.Add(".", opts, cmd.OutOrStdout()); err != nil {
@@ -57,6 +63,9 @@ func newVendorAddCmd() *cobra.Command {
}
cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to track (default: the source's default branch)")
cmd.Flags().StringSliceVar(&include, "include", nil, "comma-separated item ids to vendor (switches selection to include mode)")
cmd.Flags().StringSliceVar(&exclude, "exclude", nil, "comma-separated item ids to skip (keeps all mode, drops these ids)")
cmd.Flags().StringSliceVar(&onlyTypes, "types", nil, "comma-separated content types to take: skills, commands, agents, mcp (allow-list)")
cmd.Flags().StringSliceVar(&excludeTypes, "exclude-types", nil, "comma-separated content types to skip: skills, commands, agents, mcp (deny-list)")
cmd.Flags().StringSliceVar(&renames, "rename", nil, "deployed-name override upstream-id=deployed-name (repeatable)")
return cmd
}
@@ -75,6 +84,7 @@ func splitList(in []string) []string {
func newVendorSelectCmd() *cobra.Command {
var add, remove []string
var onlyTypes, excludeTypes []string
var mode string
cmd := &cobra.Command{
Use: "select <name>",
@@ -83,9 +93,11 @@ func newVendorSelectCmd() *cobra.Command {
"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" +
"--types/--exclude-types replace the content-type filter (skills, commands,\n" +
"agents, mcp); absent flags keep it as-is. --mode all resets to everything:\n" +
"include/exclude lists and the type filter are cleared and the full pinned\n" +
"upstream is re-materialized. Collisions re-run on every change; renames\n" +
"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 {
@@ -96,6 +108,8 @@ func newVendorSelectCmd() *cobra.Command {
Add: splitList(add),
Remove: splitList(remove),
ModeAll: mode == "all",
OnlyTypes: splitList(onlyTypes),
ExcludeTypes: splitList(excludeTypes),
}
if _, err := vendor.Select(".", args[0], opts, cmd.OutOrStdout()); err != nil {
return fmt.Errorf("vendor select: %w", err)
@@ -105,6 +119,8 @@ func newVendorSelectCmd() *cobra.Command {
}
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)")
cmd.Flags().StringSliceVar(&onlyTypes, "types", nil, "content types to take: skills, commands, agents, mcp (allow-list; replaces the type filter)")
cmd.Flags().StringSliceVar(&excludeTypes, "exclude-types", nil, "content types to skip: skills, commands, agents, mcp (deny-list; replaces the type filter)")
cmd.Flags().StringVar(&mode, "mode", "", "switch selection mode (only \"all\": clear include/exclude and the type filter, take everything)")
return cmd
}
+75
View File
@@ -0,0 +1,75 @@
package e2e
import (
"os"
"path/filepath"
"testing"
)
// TestVendorAddExcludeMode pins --exclude: all mode minus the listed ids,
// materialized and recorded in the lockfile as mode "all" with an exclude
// list.
func TestVendorAddExcludeMode(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--exclude", "broken-skill,scout")
for _, p := range []string{
"external/curated/skills/good-skill/SKILL.md",
"external/curated/commands/review.md",
"external/curated/mcp/search.yaml",
} {
if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil {
t.Errorf("non-excluded item must be materialized: %s", p)
}
}
for _, p := range []string{
"external/curated/skills/broken-skill/SKILL.md",
"external/curated/agents/scout.md",
} {
if _, err := os.Stat(filepath.Join(h.workDir, p)); err == nil {
t.Errorf("excluded item must NOT be materialized: %s", p)
}
}
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if !contains(lock, `"mode": "all"`) {
t.Errorf("exclude keeps all mode:\n%s", lock)
}
if !contains(lock, `"exclude": [`) {
t.Errorf("exclude list must be recorded:\n%s", lock)
}
}
// TestVendorAddExcludeTypoFailsClean pins the typo protection for --exclude.
func TestVendorAddExcludeTypoFailsClean(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir, "--exclude", "good-skill,nope")
if err == nil {
t.Fatalf("typo in exclude list must fail:\n%s", out)
}
if !contains(out, "exclude entry") && !contains(out, "not found upstream") {
t.Errorf("error must name the unknown exclude entry:\n%s", out)
}
if _, statErr := os.Stat(filepath.Join(h.workDir, "external/curated")); statErr == nil {
t.Error("failed add must not leave an external area behind")
}
}
// TestVendorAddIncludeAndExcludeConflict pins the mutual exclusion.
func TestVendorAddIncludeAndExcludeConflict(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir,
"--include", "good-skill", "--exclude", "scout")
if err == nil {
t.Fatalf("--include and --exclude together must fail:\n%s", out)
}
if !contains(out, "--include and --exclude cannot be combined") {
t.Errorf("error must explain the conflict:\n%s", out)
}
}
+162
View File
@@ -0,0 +1,162 @@
package e2e
import (
"os"
"path/filepath"
"testing"
)
// TestVendorAddTypesAllowList pins --types: only the listed content types
// enter the work repository, recorded as the canonical exclude_types
// complement.
func TestVendorAddTypesAllowList(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills,commands")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil {
t.Error("allowed skill must be materialized")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil {
t.Error("allowed command must be materialized")
}
for _, p := range []string{
"external/curated/agents/scout.md",
"external/curated/mcp/search.yaml",
} {
if _, err := os.Stat(filepath.Join(h.workDir, p)); err == nil {
t.Errorf("item of a non-allowed type must NOT be materialized: %s", p)
}
}
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if !contains(lock, `"exclude_types": [`) {
t.Errorf("type filter must be recorded as exclude_types:\n%s", lock)
}
if !contains(lock, `"agents"`) || !contains(lock, `"mcp"`) {
t.Errorf("exclude_types must hold the complement (agents, mcp):\n%s", lock)
}
}
// TestVendorAddExcludeTypesDenyList pins --exclude-types and its unknown-type
// validation.
func TestVendorAddExcludeTypesDenyList(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--exclude-types", "agents,mcp")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err != nil {
t.Error("skill must be materialized")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/scout.md")); err == nil {
t.Error("agent of an excluded type must NOT be materialized")
}
out, err := h.run(t, "vendor", "add", "other", h.upstreamDir, "--exclude-types", "skillz")
if err == nil {
t.Fatalf("unknown type must fail:\n%s", out)
}
if !contains(out, "unknown content type") || !contains(out, "skills, commands, agents, mcp") {
t.Errorf("error must list the valid type names:\n%s", out)
}
out, err = h.run(t, "vendor", "add", "other", h.upstreamDir, "--types", "skills", "--exclude-types", "mcp")
if err == nil {
t.Fatalf("--types and --exclude-types together must fail:\n%s", out)
}
}
// TestVendorTypesSurviveUpdate pins the whole point of exclude_types: items
// of an excluded type that appear upstream later must not slip back in on
// vendor update.
func TestVendorTypesSurviveUpdate(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills")
// upstream gains a new agent and a new skill
h.write(t, "agents/latecomer.md", "---\nname: Latecomer\n---\nbody\n")
h.write(t, "skills/new-skill/SKILL.md", "---\nname: New Skill\ndescription: fresh\n---\n# New\n")
h.commitUpstream(t, "add latecomer agent and new skill")
h.mustRun(t, "vendor", "update", "curated")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/new-skill/SKILL.md")); err != nil {
t.Error("new skill of an allowed type must be vendored by update")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/agents/latecomer.md")); err == nil {
t.Error("new agent of an excluded type must NOT re-enter on update")
}
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if !contains(lock, `"exclude_types": [`) {
t.Errorf("type filter must survive the update:\n%s", lock)
}
}
// TestVendorSelectTypeFilter pins vendor select parity: --exclude-types /
// --types replace the filter, absent flags keep it, --mode all clears it.
func TestVendorSelectTypeFilter(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--types", "skills")
// replacing the filter via --types
h.mustRun(t, "vendor", "select", "curated", "--types", "commands")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/good-skill/SKILL.md")); err == nil {
t.Error("skill must be dropped after narrowing the type filter")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err != nil {
t.Error("command must be materialized after widening the type filter")
}
// an id-only change keeps the type filter
h.mustRun(t, "vendor", "select", "curated", "--remove", "review")
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/commands/review.md")); err == nil {
t.Error("excluded command must be gone")
}
lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if !contains(lock, `"exclude_types": [`) || !contains(lock, `"skills"`) {
t.Errorf("type filter must survive an id-only select:\n%s", lock)
}
// --mode all clears everything, including the type filter
h.mustRun(t, "vendor", "select", "curated", "--mode", "all")
for _, p := range []string{
"external/curated/skills/good-skill/SKILL.md",
"external/curated/commands/review.md",
"external/curated/agents/scout.md",
"external/curated/mcp/search.yaml",
} {
if _, err := os.Stat(filepath.Join(h.workDir, p)); err != nil {
t.Errorf("--mode all must re-materialize everything: %s", p)
}
}
lock = readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json"))
if contains(lock, `"exclude_types": [`) {
t.Errorf("--mode all must clear the type filter:\n%s", lock)
}
// --mode all combined with type flags stays an error
out, err := h.run(t, "vendor", "select", "curated", "--mode", "all", "--types", "skills")
if err == nil {
t.Fatalf("--mode all with --types must fail:\n%s", out)
}
}
// TestVendorAddIncludeVsTypeFilter pins the explicit conflict error when an
// include id belongs to a type the filter excludes.
func TestVendorAddIncludeVsTypeFilter(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
out, err := h.run(t, "vendor", "add", "curated", h.upstreamDir,
"--include", "scout", "--exclude-types", "agents")
if err == nil {
t.Fatalf("include id of an excluded type must fail:\n%s", out)
}
if !contains(out, "type filter excludes") {
t.Errorf("error must explain the conflict:\n%s", out)
}
}
+6 -1
View File
@@ -68,11 +68,15 @@ const (
)
// Selection expresses what enters the work repository: everything minus
// Exclude (mode all), or exactly Include (mode include).
// Exclude (mode all), or exactly Include (mode include). ExcludeTypes
// filters whole content types (skills, commands, agents, mcp) on top of
// either mode and survives updates: new upstream items of an excluded
// type never enter the work repository.
type Selection struct {
Mode SelectionMode `json:"mode"`
Exclude []string `json:"exclude,omitempty"`
Include []string `json:"include,omitempty"`
ExcludeTypes []string `json:"exclude_types,omitempty"`
}
// Source pins one vendored git source.
@@ -140,6 +144,7 @@ func (l *Lockfile) Save(path string) error {
for _, src := range l.Sources {
sort.Strings(src.Selection.Exclude)
sort.Strings(src.Selection.Include)
sort.Strings(src.Selection.ExcludeTypes)
sort.Strings(src.Warnings)
for _, items := range src.Inventory {
sort.Strings(items)
+112 -7
View File
@@ -25,6 +25,9 @@ type AddOptions struct {
URL string
Ref string
Include []string
Exclude []string
OnlyTypes []string
ExcludeTypes []string
Renames map[string]string
}
@@ -68,12 +71,29 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error {
return err
}
sel := lockfile.Selection{Mode: lockfile.ModeAll}
if len(opts.Include) > 0 {
sel = lockfile.Selection{Mode: lockfile.ModeInclude, Include: opts.Include}
if err := checkIncludeEntries(items, opts.Include, opts.Name); err != nil {
if len(opts.Include) > 0 && len(opts.Exclude) > 0 {
return fmt.Errorf("--include and --exclude cannot be combined (a v2 selection is either include mode or all mode minus excludes)")
}
excludeTypes, err := TypeFilter(opts.OnlyTypes, opts.ExcludeTypes)
if err != nil {
return err
}
sel := lockfile.Selection{Mode: lockfile.ModeAll, ExcludeTypes: excludeTypes}
switch {
case len(opts.Include) > 0:
sel = lockfile.Selection{Mode: lockfile.ModeInclude, Include: opts.Include, ExcludeTypes: excludeTypes}
if err := checkIDEntries(items, opts.Include, opts.Name, "include"); err != nil {
return err
}
case len(opts.Exclude) > 0:
sel.Exclude = opts.Exclude
if err := checkIDEntries(items, opts.Exclude, opts.Name, "exclude"); err != nil {
return err
}
}
if err := checkIncludeVsTypes(items, sel, opts.Name); err != nil {
return err
}
selected := applySelection(items, sel)
@@ -147,21 +167,96 @@ func discoveryFromTreeRoot(root string) lockfile.Discovery {
return d
}
func checkIncludeEntries(items []discovery.Item, include []string, name string) error {
// checkIDEntries fails when any id is not available upstream (not discovered
// or skipped) — typo protection for include and exclude lists alike.
func checkIDEntries(items []discovery.Item, ids []string, name, flag string) error {
available := map[string]bool{}
for _, it := range items {
if !it.Skipped {
available[it.UpstreamID] = true
}
}
for _, want := range include {
for _, want := range ids {
if !available[want] {
return fmt.Errorf("source %q: include entry %q not found upstream (available: %s)", name, want, strings.Join(sortedKeys(available), ", "))
return fmt.Errorf("source %q: %s entry %q not found upstream (available: %s)", name, flag, want, strings.Join(sortedKeys(available), ", "))
}
}
return nil
}
// checkIncludeVsTypes fails when an include id belongs to a type excluded by
// the type filter — silently dropping it would betray the explicit include.
func checkIncludeVsTypes(items []discovery.Item, sel lockfile.Selection, name string) error {
if sel.Mode != lockfile.ModeInclude || len(sel.ExcludeTypes) == 0 {
return nil
}
excluded := map[string]bool{}
for _, t := range sel.ExcludeTypes {
excluded[t] = true
}
for _, it := range items {
if it.Skipped || !excluded[it.Type] {
continue
}
for _, id := range sel.Include {
if id == it.UpstreamID {
return fmt.Errorf("source %q: include entry %q belongs to type %s, which the type filter excludes", name, id, it.Type)
}
}
}
return nil
}
// TypeFilter translates --types (allow-list) and --exclude-types (deny-list)
// into the canonical exclude_types list: the complement of the allow-list,
// or the deny-list verbatim. Both empty means no filter (nil). A filter that
// would exclude nothing normalizes to nil; a filter excluding everything is
// returned as-is and fails later at the zero-selection guard.
func TypeFilter(only, exclude []string) ([]string, error) {
if len(only) > 0 && len(exclude) > 0 {
return nil, fmt.Errorf("--types and --exclude-types cannot be combined")
}
if len(only) == 0 && len(exclude) == 0 {
return nil, nil
}
names := only
if len(names) == 0 {
names = exclude
}
valid := map[string]bool{}
for _, t := range lockfile.Types {
valid[t] = true
}
var unknown []string
for _, t := range names {
if !valid[t] {
unknown = append(unknown, t)
}
}
if len(unknown) > 0 {
return nil, fmt.Errorf("unknown content type(s) %s (valid: %s)", strings.Join(unknown, ", "), strings.Join(lockfile.Types, ", "))
}
if len(exclude) > 0 {
out := append([]string{}, exclude...)
sort.Strings(out)
return out, nil
}
allowed := map[string]bool{}
for _, t := range only {
allowed[t] = true
}
var out []string
for _, t := range lockfile.Types {
if !allowed[t] {
out = append(out, t)
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.Item {
exclude := map[string]bool{}
for _, e := range sel.Exclude {
@@ -171,11 +266,18 @@ func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.
for _, i := range sel.Include {
include[i] = true
}
excludedTypes := map[string]bool{}
for _, t := range sel.ExcludeTypes {
excludedTypes[t] = true
}
var out []discovery.Item
for _, it := range items {
if it.Skipped {
continue
}
if excludedTypes[it.Type] {
continue
}
switch sel.Mode {
case lockfile.ModeInclude:
if include[it.UpstreamID] {
@@ -251,6 +353,9 @@ func reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selec
fmt.Fprintf(w, " rev: %s\n", src.Rev)
fmt.Fprintf(w, "discovered %d items (%s)\n", real, counts(discovered))
fmt.Fprintf(w, "selected %d items (%s)\n", len(selected), counts(selected))
if len(src.Selection.ExcludeTypes) > 0 {
fmt.Fprintf(w, "type filter: excluding %s\n", strings.Join(src.Selection.ExcludeTypes, ", "))
}
fmt.Fprintf(w, "materialized under %s/\n", filepath.Join(externalDir, name))
if len(src.Warnings) > 0 {
fmt.Fprintf(w, "warnings:\n")
+32 -11
View File
@@ -18,6 +18,8 @@ type SelectOptions struct {
Add []string
Remove []string
ModeAll bool
OnlyTypes []string
ExcludeTypes []string
}
// Select changes what a vendored source contributes — without advancing the
@@ -60,6 +62,9 @@ func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*Change
if err := validateSelectIDs(items, src, opts, name); err != nil {
return nil, err
}
if err := checkIncludeVsTypes(items, newSel, name); err != nil {
return nil, err
}
selected := applySelection(items, newSel)
if err := checkCollisions(workDir, lf, name, selected, src.Renames); err != nil {
@@ -99,6 +104,9 @@ func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*Change
}
fmt.Fprintf(stdout, "source %s: selection updated at pinned rev %s (mode %s)\n", name, short(src.Rev), newSel.Mode)
if len(newSel.ExcludeTypes) > 0 {
fmt.Fprintf(stdout, " type filter: excluding %s\n", strings.Join(newSel.ExcludeTypes, ", "))
}
printChangeReport(stdout, report)
return report, nil
}
@@ -106,30 +114,43 @@ func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*Change
// 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.
// list. --types/--exclude-types replace the type filter (absent flags keep
// it; --mode all clears it along with include/exclude).
func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockfile.Selection, error) {
typeFilter, err := TypeFilter(opts.OnlyTypes, opts.ExcludeTypes)
if err != nil {
return lockfile.Selection{}, err
}
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")
if len(opts.Add) > 0 || len(opts.Remove) > 0 || typeFilter != nil {
return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add, --remove, --types or --exclude-types")
}
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 len(opts.Add) == 0 && len(opts.Remove) == 0 && typeFilter == nil {
return lockfile.Selection{}, fmt.Errorf("nothing to do: pass --add <ids>, --remove <ids>, --types/--exclude-types or --mode all")
}
var newSel lockfile.Selection
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{
newSel = lockfile.Selection{
Mode: lockfile.ModeAll,
Exclude: unionIDs(src.Selection.Exclude, opts.Remove),
}, nil
}
return lockfile.Selection{
} else {
newSel = lockfile.Selection{
Mode: lockfile.ModeInclude,
Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add),
}, nil
}
}
if typeFilter != nil {
newSel.ExcludeTypes = typeFilter
} else {
newSel.ExcludeTypes = src.Selection.ExcludeTypes
}
return newSel, nil
}
// validateSelectIDs checks flag ids against the pinned upstream (and, for
@@ -137,7 +158,7 @@ func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockf
// 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 {
if err := checkIDEntries(items, opts.Add, name, "include"); err != nil {
return fmt.Errorf("--add: %w", err)
}
}
@@ -153,7 +174,7 @@ func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts Select
}
}
if len(opts.Remove) > 0 && src.Selection.Mode == lockfile.ModeAll {
if err := checkIncludeEntries(items, opts.Remove, name); err != nil {
if err := checkIDEntries(items, opts.Remove, name, "exclude"); err != nil {
return fmt.Errorf("--remove: %w", err)
}
}
+6 -1
View File
@@ -197,7 +197,7 @@ func computeUpdate(workDir string, lf *lockfile.Lockfile, name string, withColli
}
if src.Selection.Mode == lockfile.ModeInclude {
if err := checkIncludeEntries(items, src.Selection.Include, name); err != nil {
if err := checkIDEntries(items, src.Selection.Include, name, "include"); err != nil {
return nil, err
}
}
@@ -250,6 +250,11 @@ func cleanupStaleStaging(workDir string) {
}
func materializeTree(tree discovery.Tree, selected []discovery.Item, renames map[string]string, dst string) error {
// Create dst even for an empty selection: the caller swaps it in with a
// rename, which fails when nothing materialized the directory.
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
for _, it := range selected {
dep := deployedName(it.UpstreamID, renames)
base := filepath.Join(dst, it.Type)
+78
View File
@@ -81,6 +81,26 @@ assert_no_file "$WR3/external" "no partial external area"
assert_file "$WR3/external/sp/skills/sp-good/SKILL.md" "renamed folder carries deployed name"
assert_no_file "$WR3/external/sp/skills/good-skill" "upstream name absent after rename"
step "vendor add selection flags (--exclude, --types)"
WR4="$WORK/work-repo-4"
mkdir -p "$WR4"
(cd "$WR4" && "$BIN" vendor add cur "$UP" --exclude broken-skill) > "$WORK/exclude.log" 2>&1
assert_exit_zero $? "add --exclude succeeds"
assert_no_file "$WR4/external/cur/skills/broken-skill" "excluded skill absent"
assert_file "$WR4/external/cur/skills/good-skill/SKILL.md" "non-excluded skill present"
assert_contains "$WR4/agent-lib.lock.json" '"exclude": [' "exclude list pinned"
WR5="$WORK/work-repo-5"
mkdir -p "$WR5"
(cd "$WR5" && "$BIN" vendor add typed "$UP" --types skills,commands) > "$WORK/types.log" 2>&1
assert_exit_zero $? "add --types succeeds"
assert_no_file "$WR5/external/typed/agents" "agent type filtered out"
assert_no_file "$WR5/external/typed/mcp" "mcp type filtered out"
assert_contains "$WR5/agent-lib.lock.json" '"exclude_types": [' "type filter pinned"
set +e
(cd "$WR5" && "$BIN" vendor add bad "$UP" --include good-skill --exclude broken-skill) > "$WORK/flagconflict.log" 2>&1; RCX=$?
set -e
assert_exit_nonzero "$RCX" "--include + --exclude rejected"
step "validate / list / inspect"
(cd "$WR" && "$BIN" validate) > "$WORK/validate.log" 2>&1
assert_exit_zero $? "healthy state validates"
@@ -114,6 +134,15 @@ assert_no_file "$WR/external/superpowers" "remove leaves no residue"
(cd "$WR" && "$BIN" vendor add superpowers "$UP") >/dev/null 2>&1
git -C "$WR" add -A && git -C "$WR" commit -qm "vendor superpowers (publication = commit && push)"
step "vendor select type filter parity"
(cd "$WR5" && "$BIN" vendor select typed --exclude-types skills) > "$WORK/types-select.log" 2>&1
assert_exit_zero $? "select --exclude-types succeeds"
assert_no_file "$WR5/external/typed/skills" "skills dropped after narrowing the filter"
assert_contains "$WORK/types-select.log" "type filter" "select reports the type filter"
(cd "$WR5" && "$BIN" vendor select typed --mode all) > "$WORK/types-reset.log" 2>&1
assert_exit_zero $? "select --mode all clears the filter"
assert_file "$WR5/external/typed/skills/good-skill/SKILL.md" "skills restored after --mode all"
step "employee sync"
HOME_DIR="$WORK/employee-home"
STATE="$WORK/employee-state"
@@ -142,6 +171,31 @@ assert_exit_zero "$RC2" "second sync succeeds"
printf '%s' "$OUT2" > "$WORK/sync2.log"
assert_contains "$WORK/sync2.log" "kept:" "second run keeps everything"
step "default branch stays fresh without ref"
DB="$WORK/db-repo"
mkdir -p "$DB/skills/db-skill"
git -C "$DB" init -q -b main
printf -- '---\nname: DB\n---\n# v1\n' > "$DB/skills/db-skill/SKILL.md"
git -C "$DB" add -A && git -C "$DB" commit -qm "db v1"
DBCFG="$WORK/db-config.json"
printf '{"repo_url":"%s"}\n' "$DB" > "$DBCFG"
DBHOME="$WORK/db-home"; DBSTATE="$WORK/db-state"
mkdir -p "$DBHOME" "$DBSTATE"
set +e
HOME="$DBHOME" USERPROFILE="$DBHOME" AGENT_LIB_STATE_DIR="$DBSTATE" \
"$BIN" sync --config "$DBCFG" >/dev/null 2>&1; RCDB=$?
set -e
assert_exit_zero "$RCDB" "first default-branch sync (clone)"
printf -- '---\nname: DB\n---\n# v2\n' > "$DB/skills/db-skill/SKILL.md"
git -C "$DB" add -A && git -C "$DB" commit -qm "db v2"
set +e
HOME="$DBHOME" USERPROFILE="$DBHOME" AGENT_LIB_STATE_DIR="$DBSTATE" \
"$BIN" sync --config "$DBCFG" > "$WORK/db2.log" 2>&1; RCDB2=$?
set -e
assert_exit_zero "$RCDB2" "second default-branch sync (fetch)"
assert_contains "$WORK/db2.log" "updated: 1" "default branch advanced past the clone-time revision"
grep -q "# v2" "$DBHOME/.agents/skills/db-skill/SKILL.md" && ok "fresh content deployed" || fail "stale clone-time content served"
step "protected sync: local modification wins"
printf -- '---\nname: Own Skill\n---\n# MY EDITS\n' > "$HOME_DIR/.agents/skills/own-skill/SKILL.md"
printf -- '---\nname: Own Skill\ndescription: v2\n---\n# Upstream v2\n' > "$WR/skills/own-skill/SKILL.md"
@@ -153,6 +207,18 @@ assert_contains "$WORK/sync3.log" "skipped (local modifications kept): 1" "modif
grep -q "MY EDITS" "$HOME_DIR/.agents/skills/own-skill/SKILL.md" && ok "local version kept" || fail "local version overwritten"
assert_contains "$STATE/sync.log" "SKIP skills/own-skill" "warning logged with item and reason"
step "forced sync: repository version wins"
set +e
OUTF="$(HOME="$HOME_DIR" USERPROFILE="$HOME_DIR" AGENT_LIB_STATE_DIR="$STATE" \
"$BIN" sync --config "$CFG" --force 2>&1)"; RCF=$?
set -e
assert_exit_zero "$RCF" "sync --force succeeds"
printf '%s' "$OUTF" > "$WORK/syncf.log"
assert_contains "$WORK/syncf.log" "updated:" "forced overwrite reported as update"
grep -q "Upstream v2" "$HOME_DIR/.agents/skills/own-skill/SKILL.md" && ok "repository version restored" || fail "local modification survived --force"
# re-introduce drift so the upstream-deletion walk below has a modified copy
printf -- '---\nname: Own Skill\n---\n# MY EDITS\n' > "$HOME_DIR/.agents/skills/own-skill/SKILL.md"
step "self-created item survives"
MINE="$HOME_DIR/.agents/skills/my-private-skill"
mkdir -p "$MINE" && printf -- '---\nname: Mine\n---\n' > "$MINE/SKILL.md"
@@ -189,6 +255,18 @@ set +e; OUTSJ="$(HOME="$HOME_DIR" USERPROFILE="$HOME_DIR" AGENT_LIB_STATE_DIR="$
assert_exit_nonzero "$RCSJ" "status --json keeps drift exit code"
printf '%s' "$OUTSJ" | grep -q '"problematic_drift": true' && ok "json drift flag set" || fail "json drift flag missing"
step "forced sync after upstream deletion removes modified copies"
set +e
OUTR="$(HOME="$HOME_DIR" USERPROFILE="$HOME_DIR" AGENT_LIB_STATE_DIR="$STATE" \
"$BIN" sync --config "$CFG" --force 2>&1)"; RCR=$?
set -e
assert_exit_zero "$RCR" "sync --force after deletion succeeds"
printf '%s' "$OUTR" > "$WORK/syncr.log"
assert_contains "$WORK/syncr.log" "removed:" "forced removals reported"
assert_no_file "$HOME_DIR/.agents/skills/own-skill" "modified skill removed by force"
assert_no_file "$HOME_DIR/.config/opencode/agents/scout.md" "modified agent removed by force"
assert_file "$MINE/SKILL.md" "unmanaged item survives --force untouched"
step "unreachable repository keeps state"
BEFORE="$(cat "$HOME_DIR/.agents/skills/fresh-skill/SKILL.md")"
printf '{"repo_url":"%s","ref":"main"}\n' "$WORK/nonexistent-repo" > "$CFG"