feat(vendor): select command — manage include/exclude selection after initial add

vendor select <name> --add/--remove/--mode all changes what a source
contributes without advancing the pinned rev: ids are validated against
the pinned upstream (typos abort listing available ids), collisions
re-run, renames from the lockfile are honored, and the external area is
rewritten via the same staging-swap as update. include mode grows and
shrinks the include list; all mode --remove moves ids onto exclude;
--mode all resets to everything. Failed selects mutate nothing.

Closes beads: agent-lib-cd3
This commit is contained in:
2026-08-23 15:13:50 +02:00
parent 7070d8cc25
commit 876ff1c6b9
5 changed files with 624 additions and 1 deletions
+192
View File
@@ -0,0 +1,192 @@
package vendor
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/m3tam3re/agent-lib/internal/discovery"
"github.com/m3tam3re/agent-lib/internal/gitsource"
"github.com/m3tam3re/agent-lib/internal/lockfile"
)
// SelectOptions parameterize vendor select.
type SelectOptions struct {
Add []string
Remove []string
ModeAll bool
}
// Select changes what a vendored source contributes — without advancing the
// pinned revision (that is vendor update's job). Ids are validated against
// the pinned upstream, collisions re-run, and the external area is rewritten
// deterministically to match the new selection.
func Select(workDir, name string, opts SelectOptions, stdout io.Writer) (*ChangeReport, error) {
lockPath := filepath.Join(workDir, lockfile.FileName)
lf, err := lockfile.Load(lockPath)
if err != nil {
return nil, err
}
src, ok := lf.Sources[name]
if !ok {
return nil, fmt.Errorf("source %q is not in the lockfile", name)
}
newSel, err := planSelection(src, opts, name)
if err != nil {
return nil, err
}
repo, err := gitsource.Clone(src.URL, src.Ref)
if err != nil {
return nil, err
}
pinned, err := repo.Pin(src.Rev)
if err != nil {
return nil, fmt.Errorf("%w — run vendor update to re-pin", err)
}
tree, err := pinned.Tree()
if err != nil {
return nil, err
}
items, err := discovery.Scan(tree, discovery.FromLockfileDiscovery(derefDiscovery(src)))
if err != nil {
return nil, err
}
if err := validateSelectIDs(items, src, opts, name); err != nil {
return nil, err
}
selected := applySelection(items, newSel)
if err := checkCollisions(workDir, lf, name, selected, src.Renames); err != nil {
return nil, err
}
report := &ChangeReport{Source: name, FromRev: src.Rev, ToRev: src.Rev}
report.Added, report.Removed = membershipDiff(inventorySet(src.Inventory), idSet(selected))
cleanupStaleStaging(workDir)
staging := filepath.Join(workDir, externalDir, ".staging-"+name)
if err := os.RemoveAll(staging); err != nil {
return nil, err
}
if err := materializeTree(tree, selected, src.Renames, staging); err != nil {
os.RemoveAll(staging)
return nil, err
}
if err := fillChanged(report, name, lf, staging); err != nil {
os.RemoveAll(staging)
return nil, err
}
oldDir := filepath.Join(workDir, externalDir, name)
if err := os.RemoveAll(oldDir); err != nil {
return nil, fmt.Errorf("replacing %s: %w", oldDir, err)
}
if err := os.Rename(staging, oldDir); err != nil {
return nil, fmt.Errorf("swapping in new external area: %w", err)
}
src.Selection = newSel
src.Inventory = inventoryOf(selected)
src.Warnings = dedupe(warningsOf(items))
if err := lf.Save(lockPath); err != nil {
return nil, err
}
fmt.Fprintf(stdout, "source %s: selection updated at pinned rev %s (mode %s)\n", name, short(src.Rev), newSel.Mode)
printChangeReport(stdout, report)
return report, nil
}
// planSelection derives the new selection from the flags — pure, before any
// upstream contact: --mode all resets to everything; include mode grows and
// shrinks the include list; all mode --remove moves ids onto the exclude
// list.
func planSelection(src *lockfile.Source, opts SelectOptions, name string) (lockfile.Selection, error) {
if opts.ModeAll {
if len(opts.Add) > 0 || len(opts.Remove) > 0 {
return lockfile.Selection{}, fmt.Errorf("--mode all cannot be combined with --add or --remove")
}
return lockfile.Selection{Mode: lockfile.ModeAll}, nil
}
if len(opts.Add) == 0 && len(opts.Remove) == 0 {
return lockfile.Selection{}, fmt.Errorf("nothing to do: pass --add <ids>, --remove <ids> or --mode all")
}
if src.Selection.Mode == lockfile.ModeAll {
if len(opts.Add) > 0 {
return lockfile.Selection{}, fmt.Errorf("source %q: everything is already selected in all mode; use --remove <ids> to exclude items", name)
}
return lockfile.Selection{
Mode: lockfile.ModeAll,
Exclude: unionIDs(src.Selection.Exclude, opts.Remove),
}, nil
}
return lockfile.Selection{
Mode: lockfile.ModeInclude,
Include: unionIDs(minusIDs(src.Selection.Include, opts.Remove), opts.Add),
}, nil
}
// validateSelectIDs checks flag ids against the pinned upstream (and, for
// --remove in include mode, against the current selection) so typos fail
// before anything is mutated.
func validateSelectIDs(items []discovery.Item, src *lockfile.Source, opts SelectOptions, name string) error {
if len(opts.Add) > 0 {
if err := checkIncludeEntries(items, opts.Add, name); err != nil {
return fmt.Errorf("--add: %w", err)
}
}
if len(opts.Remove) > 0 && src.Selection.Mode == lockfile.ModeInclude {
selected := map[string]bool{}
for _, id := range src.Selection.Include {
selected[id] = true
}
for _, id := range opts.Remove {
if !selected[id] {
return fmt.Errorf("source %q: --remove id %q is not in the current selection (selected: %s)", name, id, strings.Join(sortedKeys(selected), ", "))
}
}
}
if len(opts.Remove) > 0 && src.Selection.Mode == lockfile.ModeAll {
if err := checkIncludeEntries(items, opts.Remove, name); err != nil {
return fmt.Errorf("--remove: %w", err)
}
}
return nil
}
func unionIDs(a, b []string) []string {
seen := map[string]bool{}
for _, id := range a {
seen[id] = true
}
out := append([]string{}, a...)
for _, id := range b {
if !seen[id] {
out = append(out, id)
seen[id] = true
}
}
sort.Strings(out)
return out
}
func minusIDs(a, remove []string) []string {
drop := map[string]bool{}
for _, id := range remove {
drop[id] = true
}
var out []string
for _, id := range a {
if !drop[id] {
out = append(out, id)
}
}
sort.Strings(out)
return out
}