From 876ff1c6b97342b3282f88a53dd4d51e8303b0ac Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sun, 23 Aug 2026 15:13:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(vendor):=20select=20command=20=E2=80=94=20?= =?UTF-8?q?manage=20include/exclude=20selection=20after=20initial=20add?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vendor select --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 --- README.md | 31 +++- internal/cli/vendor.go | 37 ++++ internal/e2e/select_test.go | 343 ++++++++++++++++++++++++++++++++++++ internal/gitsource/git.go | 22 +++ internal/vendor/select.go | 192 ++++++++++++++++++++ 5 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 internal/e2e/select_test.go create mode 100644 internal/vendor/select.go diff --git a/README.md b/README.md index 79f5a34..50d0e8e 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,40 @@ never executed. - Default: vendor **everything** (`mode: all`). - `--include a,b,c` switches the source to **include mode**: only those ids arrive, and new upstream items do **not** arrive automatically. -- Hard errors, enforced by `vendor add`, `vendor update` and `validate`: +- Hard errors, enforced by `vendor add`, `vendor update`, `vendor select` + and `validate`: - `all` combined with an include list → error - an exclude list without `all` → error (not supported in v2 selections) - an include entry that no longer exists upstream → error naming the item +(`vendor select` changes selections later — see +[Changing your selection later](#changing-your-selection-later) below.) + +### Changing your selection later + +`vendor select` manages the selection after the initial add — without ever +advancing the pinned revision (that is `vendor update`'s job): + +```sh +agent-lib vendor select superpowers --add tdd,brainstorm # include mode: grow +agent-lib vendor select superpowers --remove review,debug # include mode: shrink +agent-lib vendor select superpowers --remove review # all mode: move to exclude +agent-lib vendor select superpowers --mode all # reset: take everything again +``` + +- **include mode**: `--add` appends ids (validated against the pinned + upstream — typos abort with the list of available ids), `--remove` drops + ids and deletes their folders from `external/`. +- **all mode**: `--remove` moves ids onto the exclude list (lockfile v2 + supports exclude in all mode) and deletes their folders; `--add` is a hard + error — everything is already selected. +- `--mode all` clears include/exclude and re-materializes the full pinned + upstream, reporting what appeared. + +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 +under its deployed name. Failed selects mutate nothing. + ### Collisions and renames Every item deploys into a flat per-type namespace. If a vendored id collides diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 06ec04e..e266eb2 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -15,6 +15,7 @@ func newVendorCmd() *cobra.Command { Short: "Curate external sources into this work repository", } cmd.AddCommand(newVendorAddCmd()) + cmd.AddCommand(newVendorSelectCmd()) cmd.AddCommand(newVendorListCmd()) cmd.AddCommand(newVendorInspectCmd()) cmd.AddCommand(newVendorUpdateCmd()) @@ -71,3 +72,39 @@ func splitList(in []string) []string { } return out } + +func newVendorSelectCmd() *cobra.Command { + var add, remove []string + var mode string + cmd := &cobra.Command{ + Use: "select ", + Short: "Change a source's selection without advancing the pinned revision", + Long: "Manages include/exclude selection after the initial vendor add.\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" + + "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" + + "vendor update's job.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if mode != "" && mode != "all" { + return fmt.Errorf("--mode accepts only \"all\" (got %q)", mode) + } + opts := vendor.SelectOptions{ + Add: splitList(add), + Remove: splitList(remove), + ModeAll: mode == "all", + } + if _, err := vendor.Select(".", args[0], opts, cmd.OutOrStdout()); err != nil { + return fmt.Errorf("vendor select: %w", err) + } + return nil + }, + } + 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)") + return cmd +} diff --git a/internal/e2e/select_test.go b/internal/e2e/select_test.go new file mode 100644 index 0000000..41cfb88 --- /dev/null +++ b/internal/e2e/select_test.go @@ -0,0 +1,343 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "testing" +) + +func buildTwentySkillUpstream(t *testing.T, h *harness) { + t.Helper() + for i := 1; i <= 20; i++ { + h.write(t, fmt.Sprintf("skills/skill-%02d/SKILL.md", i), fmt.Sprintf("---\nname: Skill %02d\ndescription: s%d\n---\n# %02d\n", i, i, i)) + } + h.commitUpstream(t, "twenty skills") +} + +type lockView struct { + Version int `json:"version"` + Sources map[string]struct { + Rev string `json:"rev"` + Renames map[string]string `json:"renames"` + Selection struct { + Mode string `json:"mode"` + Exclude []string `json:"exclude"` + Include []string `json:"include"` + } `json:"selection"` + Inventory map[string][]string `json:"inventory"` + } `json:"sources"` +} + +func readLock(t *testing.T, workDir string) lockView { + t.Helper() + data := readFile(t, filepath.Join(workDir, "agent-lib.lock.json")) + var v lockView + if err := json.Unmarshal([]byte(data), &v); err != nil { + t.Fatalf("lockfile not parseable: %v", err) + } + return v +} + +func externalSkillIDs(t *testing.T, workDir, name string) []string { + t.Helper() + dir := filepath.Join(workDir, "external", name, "skills") + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + t.Fatal(err) + } + var out []string + for _, e := range entries { + if e.IsDir() { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} + +func snapshotTree(t *testing.T, root string) string { + t.Helper() + var paths []string + filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err == nil { + paths = append(paths, p) + } + return nil + }) + sort.Strings(paths) + out := "" + for _, p := range paths { + data, err := os.ReadFile(p) + if err == nil { + out += p + ":" + fmt.Sprint(len(data)) + "\n" + } else { + out += p + ":dir\n" + } + } + return out +} + +func addWithInclude(t *testing.T, h *harness, firstFive string) { + t.Helper() + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir, "--include", firstFive) +} + +func TestVendorSelectAddGrowsSelection(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05") + revBefore := readLock(t, h.workDir).Sources["superpowers"].Rev + + out := h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-06,skill-07,skill-08") + if !contains(out, "skill-06 (added)") || !contains(out, "skill-08 (added)") { + t.Errorf("report must list added items:\n%s", out) + } + + got := externalSkillIDs(t, h.workDir, "superpowers") + if len(got) != 8 { + t.Errorf("external area must hold exactly 8 skills, got %d: %v", len(got), got) + } + v := readLock(t, h.workDir).Sources["superpowers"] + if len(v.Selection.Include) != 8 { + t.Errorf("include list must hold 8 ids, got %v", v.Selection.Include) + } + if v.Rev != revBefore { + t.Error("select must never advance the pinned rev") + } + if out := h.mustRun(t, "validate"); contains(out, "invalid") { + t.Errorf("validate must stay green:\n%s", out) + } +} + +func TestVendorSelectRemoveInIncludeMode(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05") + + out := h.mustRun(t, "vendor", "select", "superpowers", "--remove", "skill-02,skill-04") + if !contains(out, "skill-02 (removed)") || !contains(out, "skill-04 (removed)") { + t.Errorf("report must list removed items:\n%s", out) + } + + got := externalSkillIDs(t, h.workDir, "superpowers") + want := []string{"skill-01", "skill-03", "skill-05"} + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("external skills = %v, want %v", got, want) + } + v := readLock(t, h.workDir).Sources["superpowers"] + if fmt.Sprint(v.Selection.Include) != fmt.Sprint(want) { + t.Errorf("include list = %v, want %v", v.Selection.Include, want) + } + + lockPath := filepath.Join(h.workDir, "agent-lib.lock.json") + before := readFile(t, lockPath) + h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-01") + if after := readFile(t, lockPath); after != before { + t.Error("no-op select must leave the lockfile byte-identical") + } +} + +func TestVendorSelectRemoveInAllMode(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + + h.mustRun(t, "vendor", "select", "superpowers", "--remove", "skill-03,skill-17") + + v := readLock(t, h.workDir).Sources["superpowers"] + if v.Selection.Mode != "all" { + t.Errorf("mode must stay all, got %q", v.Selection.Mode) + } + if fmt.Sprint(v.Selection.Exclude) != fmt.Sprint([]string{"skill-03", "skill-17"}) { + t.Errorf("exclude list = %v", v.Selection.Exclude) + } + got := externalSkillIDs(t, h.workDir, "superpowers") + if len(got) != 18 { + t.Errorf("external area must hold 18 skills, got %d", len(got)) + } + for _, gone := range []string{"skill-03", "skill-17"} { + if _, err := os.Stat(filepath.Join(h.workDir, "external", "superpowers", "skills", gone)); !os.IsNotExist(err) { + t.Errorf("removed skill %s must be gone from the external area", gone) + } + } + h.mustRun(t, "validate") +} + +func TestVendorSelectModeAllResets(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + addWithInclude(t, h, "skill-01,skill-02,skill-03") + + out := h.mustRun(t, "vendor", "select", "superpowers", "--mode", "all") + for _, id := range []string{"skill-04", "skill-20"} { + if !contains(out, id+" (added)") { + t.Errorf("report must list what arrived (missing %s):\n%s", id, out) + } + } + + v := readLock(t, h.workDir).Sources["superpowers"] + if v.Selection.Mode != "all" || len(v.Selection.Include) > 0 || len(v.Selection.Exclude) > 0 { + t.Errorf("mode all must clear include/exclude: %+v", v.Selection) + } + if got := externalSkillIDs(t, h.workDir, "superpowers"); len(got) != 20 { + t.Errorf("all 20 skills must be materialized, got %d", len(got)) + } + h.mustRun(t, "validate") +} + +func TestVendorSelectAddTypoIsHardError(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + addWithInclude(t, h, "skill-01,skill-02") + + lockPath := filepath.Join(h.workDir, "agent-lib.lock.json") + lockBefore := readFile(t, lockPath) + treeBefore := snapshotTree(t, filepath.Join(h.workDir, "external")) + + out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-99") + if err == nil { + t.Fatalf("unknown id must be a hard error:\n%s", out) + } + if !contains(out, "skill-99") || !contains(out, "skill-01") { + t.Errorf("error must name the missing id and list available ids:\n%s", out) + } + if readFile(t, lockPath) != lockBefore { + t.Error("failed select must not mutate the lockfile") + } + if snapshotTree(t, filepath.Join(h.workDir, "external")) != treeBefore { + t.Error("failed select must not mutate the external area") + } +} + +func TestVendorSelectRemoveTypoInIncludeMode(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + addWithInclude(t, h, "skill-01,skill-02") + + out, err := h.run(t, "vendor", "select", "superpowers", "--remove", "skill-09") + if err == nil { + t.Fatalf("--remove of an unselected id must be a hard error:\n%s", out) + } + if !contains(out, "not in the current selection") { + t.Errorf("error must explain the id is not selected:\n%s", out) + } +} + +func TestVendorSelectAddInAllModeIsError(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + + out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-04") + if err == nil { + t.Fatalf("--add in all mode must be a hard error:\n%s", out) + } + if !contains(out, "already selected in all mode") { + t.Errorf("error must explain that everything is selected in all mode:\n%s", out) + } +} + +func TestVendorSelectCollisionGuardAndRenameHonored(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + addWithInclude(t, h, "skill-01,skill-02") + os.MkdirAll(filepath.Join(h.workDir, "skills", "skill-09"), 0o755) + os.WriteFile(filepath.Join(h.workDir, "skills", "skill-09", "SKILL.md"), []byte("---\nname: Own Nine\n---\n"), 0o644) + + out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-09") + if err == nil { + t.Fatalf("collision must abort the select:\n%s", out) + } + if !contains(out, "collides with own skills") { + t.Errorf("error must name both parties:\n%s", out) + } + + lockPath := filepath.Join(h.workDir, "agent-lib.lock.json") + var raw map[string]any + if err := json.Unmarshal([]byte(readFile(t, lockPath)), &raw); err != nil { + t.Fatal(err) + } + src := raw["sources"].(map[string]any)["superpowers"].(map[string]any) + src["renames"] = map[string]string{"skill-09": "nine-renamed"} + data, _ := json.Marshal(raw) + os.WriteFile(lockPath, data, 0o644) + + h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-09") + if _, err := os.Stat(filepath.Join(h.workDir, "external", "superpowers", "skills", "nine-renamed", "SKILL.md")); err != nil { + t.Error("renamed item must materialize under its deployed name") + } + v := readLock(t, h.workDir).Sources["superpowers"] + if v.Renames["skill-09"] != "nine-renamed" { + t.Errorf("rename map must survive select: %v", v.Renames) + } + if out := h.mustRun(t, "validate"); contains(out, "invalid") { + t.Errorf("validate must stay green after rename select:\n%s", out) + } +} + +func TestVendorSelectModeAllRejectsCombinedFlags(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + addWithInclude(t, h, "skill-01") + + out, err := h.run(t, "vendor", "select", "superpowers", "--mode", "all", "--add", "skill-02") + if err == nil { + t.Fatalf("--mode all with --add must be rejected:\n%s", out) + } + if !contains(out, "cannot be combined") { + t.Errorf("error must explain the flag conflict:\n%s", out) + } +} + +func TestVendorSelectNoFlagsIsError(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + addWithInclude(t, h, "skill-01") + + out, err := h.run(t, "vendor", "select", "superpowers") + if err == nil { + t.Fatalf("select without flags must be rejected:\n%s", out) + } + if !contains(out, "nothing to do") { + t.Errorf("error must list the available actions:\n%s", out) + } +} + +func TestVendorSelectUnknownSourceAndUpstreamAdvance(t *testing.T) { + h := newHarness(t) + buildTwentySkillUpstream(t, h) + addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05") + revPinned := readLock(t, h.workDir).Sources["superpowers"].Rev + + if out, err := h.run(t, "vendor", "select", "nope"); err == nil { + t.Fatalf("unknown source must fail:\n%s", out) + } else if !contains(out, "not in the lockfile") { + t.Errorf("error must name the lockfile:\n%s", out) + } + + h.write(t, "skills/skill-01/SKILL.md", "---\nname: Skill 01\ndescription: changed upstream\n---\n# changed\n") + h.commitUpstream(t, "upstream moves on") + + h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-06") + + v := readLock(t, h.workDir).Sources["superpowers"] + if v.Rev != revPinned { + t.Error("select must keep the pinned rev even when upstream moved on") + } + changed, err := os.ReadFile(filepath.Join(h.workDir, "external", "superpowers", "skills", "skill-01", "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if contains(string(changed), "changed upstream") { + t.Error("select must materialize pinned content, not the ref head") + } + if out := h.mustRun(t, "validate"); contains(out, "invalid") { + t.Errorf("validate must stay green:\n%s", out) + } +} diff --git a/internal/gitsource/git.go b/internal/gitsource/git.go index f99ebc9..43a0217 100644 --- a/internal/gitsource/git.go +++ b/internal/gitsource/git.go @@ -93,6 +93,28 @@ func resolveHead(repo *git.Repository, requestedRef string) (*Repo, error) { return r, nil } +// Pin returns a copy of the repo resolved to an explicit revision, so a +// selection can be re-materialized at the pinned rev without advancing it. +func (r *Repo) Pin(rev string) (*Repo, error) { + if _, err := r.Repository.CommitObject(plumbing.NewHash(rev)); err != nil { + return nil, fmt.Errorf("pinned revision %s not found in %s: %w", rev, r.URL(), err) + } + pinned := *r + pinned.Rev = rev + return &pinned, nil +} + +func (r *Repo) URL() string { + if r.Repository == nil { + return "" + } + remote, err := r.Repository.Remote(git.DefaultRemoteName) + if err != nil || len(remote.Config().URLs) == 0 { + return "" + } + return remote.Config().URLs[0] +} + // Tree returns the commit tree of the pinned revision as a discovery.Tree. func (r *Repo) Tree() (discovery.Tree, error) { commit, err := r.Repository.CommitObject(plumbing.NewHash(r.Rev)) diff --git a/internal/vendor/select.go b/internal/vendor/select.go new file mode 100644 index 0000000..780c34b --- /dev/null +++ b/internal/vendor/select.go @@ -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 , --remove 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 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 +}