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:
1 parent
1ab97bff43
commit
0c779bc970
9 files changed
+533
-50
No files matched your search
@@ -89,11 +89,23 @@ 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.
|
||||||
|
- `--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`
|
- Hard errors, enforced by `vendor add`, `vendor update`, `vendor select`
|
||||||
and `validate`:
|
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 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
|
(`vendor select` changes selections later — see
|
||||||
[Changing your selection later](#changing-your-selection-later) below.)
|
[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 --add tdd,brainstorm # include mode: grow
|
||||||
agent-lib vendor select superpowers --remove review,debug # include mode: shrink
|
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 --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
|
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
|
- **all mode**: `--remove` moves ids onto the exclude list (lockfile v2
|
||||||
supports exclude in all mode) and deletes their folders; `--add` is a hard
|
supports exclude in all mode) and deletes their folders; `--add` is a hard
|
||||||
error — everything is already selected.
|
error — everything is already selected.
|
||||||
- `--mode all` clears include/exclude and re-materializes the full pinned
|
- `--types` / `--exclude-types` **replace** the content-type filter
|
||||||
upstream, reporting what appeared.
|
(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
|
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
|
— you can hand-add a rename for a not-yet-selected item, then `--add` it
|
||||||
|
|||||||
+30
-14
@@ -26,7 +26,7 @@ func newVendorCmd() *cobra.Command {
|
|||||||
|
|
||||||
func newVendorAddCmd() *cobra.Command {
|
func newVendorAddCmd() *cobra.Command {
|
||||||
var ref string
|
var ref string
|
||||||
var include []string
|
var include, exclude, onlyTypes, excludeTypes []string
|
||||||
var renames []string
|
var renames []string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <name> <url>",
|
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" +
|
Long: "Clones the source (in-process, via go-git), discovers skills, commands,\n" +
|
||||||
"agents and MCP fragments, materializes the selection under external/<name>/\n" +
|
"agents and MCP fragments, materializes the selection under external/<name>/\n" +
|
||||||
"and pins URL, ref, revision, selection and renames in the lockfile.\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.",
|
"Collisions abort with a hard error; resolve them with --rename upstream=deployed.",
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -43,11 +46,14 @@ func newVendorAddCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
opts := vendor.AddOptions{
|
opts := vendor.AddOptions{
|
||||||
Name: args[0],
|
Name: args[0],
|
||||||
URL: args[1],
|
URL: args[1],
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
Include: splitList(include),
|
Include: splitList(include),
|
||||||
Renames: renameMap,
|
Exclude: splitList(exclude),
|
||||||
|
OnlyTypes: splitList(onlyTypes),
|
||||||
|
ExcludeTypes: splitList(excludeTypes),
|
||||||
|
Renames: renameMap,
|
||||||
}
|
}
|
||||||
if err := vendor.Add(".", opts, cmd.OutOrStdout()); err != nil {
|
if err := vendor.Add(".", opts, cmd.OutOrStdout()); err != nil {
|
||||||
return fmt.Errorf("vendor add: %w", err)
|
return fmt.Errorf("vendor add: %w", err)
|
||||||
@@ -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().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(&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)")
|
cmd.Flags().StringSliceVar(&renames, "rename", nil, "deployed-name override upstream-id=deployed-name (repeatable)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -75,6 +84,7 @@ func splitList(in []string) []string {
|
|||||||
|
|
||||||
func newVendorSelectCmd() *cobra.Command {
|
func newVendorSelectCmd() *cobra.Command {
|
||||||
var add, remove []string
|
var add, remove []string
|
||||||
|
var onlyTypes, excludeTypes []string
|
||||||
var mode string
|
var mode string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "select <name>",
|
Use: "select <name>",
|
||||||
@@ -83,9 +93,11 @@ func newVendorSelectCmd() *cobra.Command {
|
|||||||
"Include mode: --add appends ids (validated against the pinned upstream),\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" +
|
"--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" +
|
"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" +
|
"--types/--exclude-types replace the content-type filter (skills, commands,\n" +
|
||||||
"full pinned upstream is re-materialized. Collisions re-run on every change;\n" +
|
"agents, mcp); absent flags keep it as-is. --mode all resets to everything:\n" +
|
||||||
"renames from the lockfile are honored. The revision never moves — that is\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.",
|
"vendor update's job.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -93,9 +105,11 @@ func newVendorSelectCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("--mode accepts only \"all\" (got %q)", mode)
|
return fmt.Errorf("--mode accepts only \"all\" (got %q)", mode)
|
||||||
}
|
}
|
||||||
opts := vendor.SelectOptions{
|
opts := vendor.SelectOptions{
|
||||||
Add: splitList(add),
|
Add: splitList(add),
|
||||||
Remove: splitList(remove),
|
Remove: splitList(remove),
|
||||||
ModeAll: mode == "all",
|
ModeAll: mode == "all",
|
||||||
|
OnlyTypes: splitList(onlyTypes),
|
||||||
|
ExcludeTypes: splitList(excludeTypes),
|
||||||
}
|
}
|
||||||
if _, err := vendor.Select(".", args[0], opts, cmd.OutOrStdout()); err != nil {
|
if _, err := vendor.Select(".", args[0], opts, cmd.OutOrStdout()); err != nil {
|
||||||
return fmt.Errorf("vendor select: %w", err)
|
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(&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().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
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,11 +68,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Selection expresses what enters the work repository: everything minus
|
// 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 {
|
type Selection struct {
|
||||||
Mode SelectionMode `json:"mode"`
|
Mode SelectionMode `json:"mode"`
|
||||||
Exclude []string `json:"exclude,omitempty"`
|
Exclude []string `json:"exclude,omitempty"`
|
||||||
Include []string `json:"include,omitempty"`
|
Include []string `json:"include,omitempty"`
|
||||||
|
ExcludeTypes []string `json:"exclude_types,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source pins one vendored git source.
|
// Source pins one vendored git source.
|
||||||
@@ -140,6 +144,7 @@ func (l *Lockfile) Save(path string) error {
|
|||||||
for _, src := range l.Sources {
|
for _, src := range l.Sources {
|
||||||
sort.Strings(src.Selection.Exclude)
|
sort.Strings(src.Selection.Exclude)
|
||||||
sort.Strings(src.Selection.Include)
|
sort.Strings(src.Selection.Include)
|
||||||
|
sort.Strings(src.Selection.ExcludeTypes)
|
||||||
sort.Strings(src.Warnings)
|
sort.Strings(src.Warnings)
|
||||||
for _, items := range src.Inventory {
|
for _, items := range src.Inventory {
|
||||||
sort.Strings(items)
|
sort.Strings(items)
|
||||||
|
|||||||
Vendored
+117
-12
@@ -21,11 +21,14 @@ const externalDir = "external"
|
|||||||
|
|
||||||
// AddOptions parameterize vendor add.
|
// AddOptions parameterize vendor add.
|
||||||
type AddOptions struct {
|
type AddOptions struct {
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
Ref string
|
Ref string
|
||||||
Include []string
|
Include []string
|
||||||
Renames map[string]string
|
Exclude []string
|
||||||
|
OnlyTypes []string
|
||||||
|
ExcludeTypes []string
|
||||||
|
Renames map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add vendors a new source into the work repository at workDir: it clones,
|
// Add vendors a new source into the work repository at workDir: it clones,
|
||||||
@@ -68,12 +71,29 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sel := lockfile.Selection{Mode: lockfile.ModeAll}
|
if len(opts.Include) > 0 && len(opts.Exclude) > 0 {
|
||||||
if len(opts.Include) > 0 {
|
return fmt.Errorf("--include and --exclude cannot be combined (a v2 selection is either include mode or all mode minus excludes)")
|
||||||
sel = lockfile.Selection{Mode: lockfile.ModeInclude, Include: opts.Include}
|
}
|
||||||
if err := checkIncludeEntries(items, opts.Include, opts.Name); err != nil {
|
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
|
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)
|
selected := applySelection(items, sel)
|
||||||
@@ -147,21 +167,96 @@ func discoveryFromTreeRoot(root string) lockfile.Discovery {
|
|||||||
return d
|
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{}
|
available := map[string]bool{}
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if !it.Skipped {
|
if !it.Skipped {
|
||||||
available[it.UpstreamID] = true
|
available[it.UpstreamID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, want := range include {
|
for _, want := range ids {
|
||||||
if !available[want] {
|
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
|
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 {
|
func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.Item {
|
||||||
exclude := map[string]bool{}
|
exclude := map[string]bool{}
|
||||||
for _, e := range sel.Exclude {
|
for _, e := range sel.Exclude {
|
||||||
@@ -171,11 +266,18 @@ func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery.
|
|||||||
for _, i := range sel.Include {
|
for _, i := range sel.Include {
|
||||||
include[i] = true
|
include[i] = true
|
||||||
}
|
}
|
||||||
|
excludedTypes := map[string]bool{}
|
||||||
|
for _, t := range sel.ExcludeTypes {
|
||||||
|
excludedTypes[t] = true
|
||||||
|
}
|
||||||
var out []discovery.Item
|
var out []discovery.Item
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
if it.Skipped {
|
if it.Skipped {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if excludedTypes[it.Type] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch sel.Mode {
|
switch sel.Mode {
|
||||||
case lockfile.ModeInclude:
|
case lockfile.ModeInclude:
|
||||||
if include[it.UpstreamID] {
|
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, " rev: %s\n", src.Rev)
|
||||||
fmt.Fprintf(w, "discovered %d items (%s)\n", real, counts(discovered))
|
fmt.Fprintf(w, "discovered %d items (%s)\n", real, counts(discovered))
|
||||||
fmt.Fprintf(w, "selected %d items (%s)\n", len(selected), counts(selected))
|
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))
|
fmt.Fprintf(w, "materialized under %s/\n", filepath.Join(externalDir, name))
|
||||||
if len(src.Warnings) > 0 {
|
if len(src.Warnings) > 0 {
|
||||||
fmt.Fprintf(w, "warnings:\n")
|
fmt.Fprintf(w, "warnings:\n")
|
||||||
|
|||||||
Vendored
+37
-16
@@ -15,9 +15,11 @@ import (
|
|||||||
|
|
||||||
// SelectOptions parameterize vendor select.
|
// SelectOptions parameterize vendor select.
|
||||||
type SelectOptions struct {
|
type SelectOptions struct {
|
||||||
Add []string
|
Add []string
|
||||||
Remove []string
|
Remove []string
|
||||||
ModeAll bool
|
ModeAll bool
|
||||||
|
OnlyTypes []string
|
||||||
|
ExcludeTypes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select changes what a vendored source contributes — without advancing the
|
// 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 {
|
if err := validateSelectIDs(items, src, opts, name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := checkIncludeVsTypes(items, newSel, name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
selected := applySelection(items, newSel)
|
selected := applySelection(items, newSel)
|
||||||
if err := checkCollisions(workDir, lf, name, selected, src.Renames); err != nil {
|
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)
|
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)
|
printChangeReport(stdout, report)
|
||||||
return report, nil
|
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
|
// planSelection derives the new selection from the flags — pure, before any
|
||||||
// upstream contact: --mode all resets to everything; include mode grows and
|
// upstream contact: --mode all resets to everything; include mode grows and
|
||||||
// shrinks the include list; all mode --remove moves ids onto the exclude
|
// 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) {
|
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 opts.ModeAll {
|
||||||
if len(opts.Add) > 0 || len(opts.Remove) > 0 {
|
if len(opts.Add) > 0 || len(opts.Remove) > 0 || typeFilter != nil {
|
||||||
return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add or --remove")
|
return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add, --remove, --types or --exclude-types")
|
||||||
}
|
}
|
||||||
return lockfile.Selection{Mode: lockfile.ModeAll}, nil
|
return lockfile.Selection{Mode: lockfile.ModeAll}, nil
|
||||||
}
|
}
|
||||||
if len(opts.Add) == 0 && len(opts.Remove) == 0 {
|
if len(opts.Add) == 0 && len(opts.Remove) == 0 && typeFilter == nil {
|
||||||
return lockfile.Selection{}, fmt.Errorf("nothing to do: pass --add <ids>, --remove <ids> or --mode all")
|
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 src.Selection.Mode == lockfile.ModeAll {
|
||||||
if len(opts.Add) > 0 {
|
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{}, 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,
|
Mode: lockfile.ModeAll,
|
||||||
Exclude: unionIDs(src.Selection.Exclude, opts.Remove),
|
Exclude: unionIDs(src.Selection.Exclude, opts.Remove),
|
||||||
}, nil
|
}
|
||||||
|
} else {
|
||||||
|
newSel = lockfile.Selection{
|
||||||
|
Mode: lockfile.ModeInclude,
|
||||||
|
Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return lockfile.Selection{
|
if typeFilter != nil {
|
||||||
Mode: lockfile.ModeInclude,
|
newSel.ExcludeTypes = typeFilter
|
||||||
Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add),
|
} else {
|
||||||
}, nil
|
newSel.ExcludeTypes = src.Selection.ExcludeTypes
|
||||||
|
}
|
||||||
|
return newSel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateSelectIDs checks flag ids against the pinned upstream (and, for
|
// 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.
|
// before anything is mutated.
|
||||||
func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts SelectOptions, name string) error {
|
func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts SelectOptions, name string) error {
|
||||||
if len(opts.Add) > 0 {
|
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)
|
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 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)
|
return fmt.Errorf("--remove: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+6
-1
@@ -197,7 +197,7 @@ func computeUpdate(workDir string, lf *lockfile.Lockfile, name string, withColli
|
|||||||
}
|
}
|
||||||
|
|
||||||
if src.Selection.Mode == lockfile.ModeInclude {
|
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
|
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 {
|
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 {
|
for _, it := range selected {
|
||||||
dep := deployedName(it.UpstreamID, renames)
|
dep := deployedName(it.UpstreamID, renames)
|
||||||
base := filepath.Join(dst, it.Type)
|
base := filepath.Join(dst, it.Type)
|
||||||
|
|||||||
@@ -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_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"
|
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"
|
step "validate / list / inspect"
|
||||||
(cd "$WR" && "$BIN" validate) > "$WORK/validate.log" 2>&1
|
(cd "$WR" && "$BIN" validate) > "$WORK/validate.log" 2>&1
|
||||||
assert_exit_zero $? "healthy state validates"
|
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
|
(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)"
|
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"
|
step "employee sync"
|
||||||
HOME_DIR="$WORK/employee-home"
|
HOME_DIR="$WORK/employee-home"
|
||||||
STATE="$WORK/employee-state"
|
STATE="$WORK/employee-state"
|
||||||
@@ -142,6 +171,31 @@ assert_exit_zero "$RC2" "second sync succeeds"
|
|||||||
printf '%s' "$OUT2" > "$WORK/sync2.log"
|
printf '%s' "$OUT2" > "$WORK/sync2.log"
|
||||||
assert_contains "$WORK/sync2.log" "kept:" "second run keeps everything"
|
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"
|
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\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"
|
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"
|
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"
|
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"
|
step "self-created item survives"
|
||||||
MINE="$HOME_DIR/.agents/skills/my-private-skill"
|
MINE="$HOME_DIR/.agents/skills/my-private-skill"
|
||||||
mkdir -p "$MINE" && printf -- '---\nname: Mine\n---\n' > "$MINE/SKILL.md"
|
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"
|
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"
|
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"
|
step "unreachable repository keeps state"
|
||||||
BEFORE="$(cat "$HOME_DIR/.agents/skills/fresh-skill/SKILL.md")"
|
BEFORE="$(cat "$HOME_DIR/.agents/skills/fresh-skill/SKILL.md")"
|
||||||
printf '{"repo_url":"%s","ref":"main"}\n' "$WORK/nonexistent-repo" > "$CFG"
|
printf '{"repo_url":"%s","ref":"main"}\n' "$WORK/nonexistent-repo" > "$CFG"
|
||||||
|
|||||||
Reference in new issue
Block a user