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
+533 -50

No files matched your search

+117 -12
View File
@@ -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")
+37 -16
View File
@@ -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)
}
}
+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)