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
+30
-14
@@ -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 {
|
||||
@@ -43,11 +46,14 @@ func newVendorAddCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
opts := vendor.AddOptions{
|
||||
Name: args[0],
|
||||
URL: args[1],
|
||||
Ref: ref,
|
||||
Include: splitList(include),
|
||||
Renames: renameMap,
|
||||
Name: args[0],
|
||||
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 {
|
||||
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().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 {
|
||||
@@ -93,9 +105,11 @@ func newVendorSelectCmd() *cobra.Command {
|
||||
return fmt.Errorf("--mode accepts only \"all\" (got %q)", mode)
|
||||
}
|
||||
opts := vendor.SelectOptions{
|
||||
Add: splitList(add),
|
||||
Remove: splitList(remove),
|
||||
ModeAll: mode == "all",
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
// 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"`
|
||||
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)
|
||||
|
||||
Vendored
+117
-12
@@ -21,11 +21,14 @@ const externalDir = "external"
|
||||
|
||||
// AddOptions parameterize vendor add.
|
||||
type AddOptions struct {
|
||||
Name string
|
||||
URL string
|
||||
Ref string
|
||||
Include []string
|
||||
Renames map[string]string
|
||||
Name string
|
||||
URL string
|
||||
Ref string
|
||||
Include []string
|
||||
Exclude []string
|
||||
OnlyTypes []string
|
||||
ExcludeTypes []string
|
||||
Renames map[string]string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
Vendored
+37
-16
@@ -15,9 +15,11 @@ import (
|
||||
|
||||
// SelectOptions parameterize vendor select.
|
||||
type SelectOptions struct {
|
||||
Add []string
|
||||
Remove []string
|
||||
ModeAll bool
|
||||
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
|
||||
}
|
||||
} else {
|
||||
newSel = lockfile.Selection{
|
||||
Mode: lockfile.ModeInclude,
|
||||
Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add),
|
||||
}
|
||||
}
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+6
-1
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user