commit:     e13a6647093a34c008b75fee02094edc7df5da0c
Author:     Jethro Donaldson <devel <AT> jro <DOT> nz>
AuthorDate: Wed Dec  3 11:40:22 2025 +0000
Commit:     Sam James <sam <AT> gentoo <DOT> org>
CommitDate: Mon Feb 16 06:54:15 2026 +0000
URL:        https://gitweb.gentoo.org/proj/portage.git/commit/?id=e13a6647

emerge: add --getbinpkg-include --getbinpkg-exclude command line opts

Allow specification of which binary packages can or cannot be satisfied
using remote binary packages, with a interface consistent with the
--usepkg-include and --usepkg-exclude options. These additional options
influence fetching of remote binaries only and have no effect unless -g
is also supplied or implied.

Signed-off-by: Jethro Donaldson <devel <AT> jro.nz>
Suggested-by: Sam James <sam <AT> gentoo.org>
Part-of: https://github.com/gentoo/portage/pull/1527
Signed-off-by: Sam James <sam <AT> gentoo.org>

 lib/_emerge/actions.py                             |  53 ++-
 lib/_emerge/depgraph.py                            |  27 +-
 lib/_emerge/main.py                                |  12 +
 lib/portage/_sets/base.py                          |  16 +
 lib/portage/dbapi/bintree.py                       |  46 +-
 lib/portage/tests/dbapi/test_bintree.py            |  12 +-
 lib/portage/tests/resolver/ResolverPlayground.py   |  85 +++-
 .../tests/resolver/test_binpackage_selection.py    | 528 +++++++++++++++++++++
 .../tests/sets/base/test_wildcard_package_set.py   |  30 ++
 man/emerge.1                                       |  16 +
 10 files changed, 784 insertions(+), 41 deletions(-)

diff --git a/lib/_emerge/actions.py b/lib/_emerge/actions.py
index 1136336916..472feb135f 100644
--- a/lib/_emerge/actions.py
+++ b/lib/_emerge/actions.py
@@ -55,7 +55,7 @@ warn = create_color_func("WARN")
 from portage.package.ebuild._ipc.QueryCommand import QueryCommand
 from portage.package.ebuild.fetch import _hide_url_passwd
 from portage._sets import load_default_config, SETPREFIX
-from portage._sets.base import InternalPackageSet
+from portage._sets.base import InternalPackageSet, WildcardPackageSet
 from portage.util import (
     cmp_sort_key,
     normalize_path,
@@ -81,12 +81,7 @@ from portage.binpkg import get_binpkg_format
 from _emerge.clear_caches import clear_caches
 from _emerge.create_depgraph_params import create_depgraph_params
 from _emerge.Dependency import Dependency
-from _emerge.depgraph import (
-    backtrack_depgraph,
-    depgraph,
-    resume_depgraph,
-    _wildcard_set,
-)
+from _emerge.depgraph import backtrack_depgraph, depgraph, resume_depgraph
 from _emerge.emergelog import emergelog
 from _emerge.is_valid_package_atom import is_valid_package_atom
 from _emerge.main import profile_check
@@ -171,6 +166,10 @@ def action_build(
         kwargs["add_repos"] = (quickpkg_vardb,)
         try:
             kwargs["pretend"] = "--pretend" in emerge_config.opts
+            if "--getbinpkg-exclude" in emerge_config.opts:
+                kwargs["getbinpkg_exclude"] = 
emerge_config.opts["--getbinpkg-exclude"]
+            if "--getbinpkg-include" in emerge_config.opts:
+                kwargs["getbinpkg_include"] = 
emerge_config.opts["--getbinpkg-include"]
             emerge_config.target_config.trees["bintree"].populate(
                 getbinpkgs="--getbinpkg" in emerge_config.opts, **kwargs
             )
@@ -434,6 +433,14 @@ def action_build(
                         kwargs["add_repos"] = (
                             
emerge_config.running_config.trees["vartree"].dbapi,
                         )
+                    if "--getbinpkg-exclude" in emerge_config.opts:
+                        kwargs["getbinpkg_exclude"] = emerge_config.opts[
+                            "--getbinpkg-exclude"
+                        ]
+                    if "--getbinpkg-include" in emerge_config.opts:
+                        kwargs["getbinpkg_include"] = emerge_config.opts[
+                            "--getbinpkg-include"
+                        ]
 
                     try:
                         root_trees["bintree"].populate(
@@ -2807,12 +2814,16 @@ def adjust_config(myopts, settings):
 
 
 def binpkg_selection_config(opts, settings):
+    atoms = " ".join(opts.pop("--getbinpkg-exclude", [])).split()
+    getbinpkg_exclude = WildcardPackageSet(atoms)
+    atoms = " ".join(opts.pop("--getbinpkg-include", [])).split()
+    getbinpkg_include = WildcardPackageSet(atoms)
     atoms = " ".join(opts.pop("--usepkg-exclude", [])).split()
-    usepkg_exclude = _wildcard_set(atoms)
+    usepkg_exclude = WildcardPackageSet(atoms)
     atoms = " ".join(opts.pop("--usepkg-include", [])).split()
-    usepkg_include = _wildcard_set(atoms)
+    usepkg_include = WildcardPackageSet(atoms)
 
-    # warn if include/exclude lists overlap on command line
+    # --usepkg-include and --usepkg-exclude may not overlap
     conflicted_atoms = 
usepkg_exclude.getAtoms().intersection(usepkg_include.getAtoms())
     if conflicted_atoms:
         writemsg(
@@ -2841,6 +2852,24 @@ def binpkg_selection_config(opts, settings):
             )
             usepkg_include.clear()
 
+    # --getbinpkg-include and --getbinpkg-exclude may not overlap
+    conflicted_atoms = getbinpkg_exclude.getAtoms().intersection(
+        getbinpkg_include.getAtoms()
+    )
+    if conflicted_atoms:
+        writemsg(
+            "\n!!! The following atoms appear in both the --getbinpkg-exclude "
+            "and --getbinpkg-include command line arguments:\n"
+            "\n    %s\n" % ("\n    ".join(conflicted_atoms))
+        )
+        for a in conflicted_atoms:
+            getbinpkg_exclude.remove(a)
+            getbinpkg_include.remove(a)
+
+    if not getbinpkg_exclude.isEmpty():
+        opts["--getbinpkg-exclude"] = list(getbinpkg_exclude)
+    if not getbinpkg_include.isEmpty():
+        opts["--getbinpkg-include"] = list(getbinpkg_include)
     if not usepkg_exclude.isEmpty():
         opts["--usepkg-exclude"] = list(usepkg_exclude)
     if not usepkg_include.isEmpty():
@@ -3634,6 +3663,10 @@ def run_action(emerge_config):
                 )
 
             kwargs["pretend"] = "--pretend" in emerge_config.opts
+            if "--getbinpkg-exclude" in emerge_config.opts:
+                kwargs["getbinpkg_exclude"] = 
emerge_config.opts["--getbinpkg-exclude"]
+            if "--getbinpkg-include" in emerge_config.opts:
+                kwargs["getbinpkg_include"] = 
emerge_config.opts["--getbinpkg-include"]
 
             try:
                 mytrees["bintree"].populate(

diff --git a/lib/_emerge/depgraph.py b/lib/_emerge/depgraph.py
index 96586e9c69..17c5e54900 100644
--- a/lib/_emerge/depgraph.py
+++ b/lib/_emerge/depgraph.py
@@ -52,7 +52,7 @@ from portage.output import colorize, create_color_func, 
darkgreen, green
 bad = create_color_func("BAD")
 from portage.package.ebuild.getmaskingstatus import _getmaskingstatus, 
_MaskReason
 from portage._sets import SETPREFIX
-from portage._sets.base import InternalPackageSet
+from portage._sets.base import InternalPackageSet, WildcardPackageSet
 from portage.dep._slot_operator import evaluate_slot_operator_equal_deps
 from portage.util import ConfigProtect, new_protect_filename
 from portage.util import cmp_sort_key, writemsg, writemsg_stdout
@@ -131,17 +131,6 @@ class _scheduler_graph_config:
         self.mergelist = mergelist
 
 
-def _wildcard_set(atoms):
-    pkgs = InternalPackageSet(allow_wildcard=True)
-    for x in atoms:
-        try:
-            x = Atom(x, allow_wildcard=True, allow_repo=False)
-        except portage.exception.InvalidAtom:
-            x = Atom("*/" + x, allow_wildcard=True, allow_repo=False)
-        pkgs.add(x)
-    return pkgs
-
-
 class _frozen_depgraph_config:
     def __init__(self, settings, trees, myopts, params, spinner):
         self.settings = settings
@@ -204,19 +193,19 @@ class _frozen_depgraph_config:
             self._required_set_names = {"world"}
 
         atoms = " ".join(myopts.get("--exclude", [])).split()
-        self.excluded_pkgs = _wildcard_set(atoms)
+        self.excluded_pkgs = WildcardPackageSet(atoms)
         atoms = " ".join(myopts.get("--reinstall-atoms", [])).split()
-        self.reinstall_atoms = _wildcard_set(atoms)
+        self.reinstall_atoms = WildcardPackageSet(atoms)
         atoms = " ".join(myopts.get("--usepkg-exclude", [])).split()
-        self.usepkg_exclude = _wildcard_set(atoms)
+        self.usepkg_exclude = WildcardPackageSet(atoms)
         atoms = " ".join(myopts.get("--usepkg-include", [])).split()
-        self.usepkg_include = _wildcard_set(atoms)
+        self.usepkg_include = WildcardPackageSet(atoms)
         atoms = " ".join(myopts.get("--useoldpkg-atoms", [])).split()
-        self.useoldpkg_atoms = _wildcard_set(atoms)
+        self.useoldpkg_atoms = WildcardPackageSet(atoms)
         atoms = " ".join(myopts.get("--rebuild-exclude", [])).split()
-        self.rebuild_exclude = _wildcard_set(atoms)
+        self.rebuild_exclude = WildcardPackageSet(atoms)
         atoms = " ".join(myopts.get("--rebuild-ignore", [])).split()
-        self.rebuild_ignore = _wildcard_set(atoms)
+        self.rebuild_ignore = WildcardPackageSet(atoms)
 
         self.rebuild_if_new_rev = "--rebuild-if-new-rev" in myopts
         self.rebuild_if_new_ver = "--rebuild-if-new-ver" in myopts

diff --git a/lib/_emerge/main.py b/lib/_emerge/main.py
index 7a3371807d..5eef25193e 100644
--- a/lib/_emerge/main.py
+++ b/lib/_emerge/main.py
@@ -571,6 +571,16 @@ def parse_opts(tmpcmdline, silent=False):
             "help": "fetch binary packages only",
             "choices": true_y_or_n,
         },
+        "--getbinpkg-exclude": {
+            "help": "A space separated list of package names or slot atoms. "
+            + "Emerge will not fetch matching remote binary packages. ",
+            "action": "append",
+        },
+        "--getbinpkg-include": {
+            "help": "A space separated list of package names or slot atoms. "
+            + "Emerge will not fetch non-matching remote binary packages. ",
+            "action": "append",
+        },
         "--usepkg-exclude": {
             "help": "A space separated list of package names or slot atoms. "
             + "Emerge will ignore matching binary packages. ",
@@ -890,6 +900,8 @@ def parse_opts(tmpcmdline, silent=False):
 
     candidate_bad_options = (
         (myoptions.exclude, "exclude"),
+        (myoptions.getbinpkg_exclude, "getbinpkg-exclude"),
+        (myoptions.getbinpkg_include, "getbinpkg-include"),
         (myoptions.reinstall_atoms, "reinstall-atoms"),
         (myoptions.rebuild_exclude, "rebuild-exclude"),
         (myoptions.rebuild_ignore, "rebuild-ignore"),

diff --git a/lib/portage/_sets/base.py b/lib/portage/_sets/base.py
index 4c9f4914ab..2f51137863 100644
--- a/lib/portage/_sets/base.py
+++ b/lib/portage/_sets/base.py
@@ -252,3 +252,19 @@ class DummyPackageSet(PackageSet):
         return DummyPackageSet(atoms=atoms)
 
     singleBuilder = classmethod(singleBuilder)
+
+
+class WildcardPackageSet(InternalPackageSet):
+    def __init__(self, initial_atoms, allow_repo=False):
+        super().__init__(initial_atoms, allow_wildcard=True, 
allow_repo=allow_repo)
+
+    def _implicitWildcarding(self, atom):
+        if isinstance(atom, Atom):
+            return atom
+        try:
+            return Atom(atom, allow_wildcard=True, allow_repo=self._allow_repo)
+        except InvalidAtom:
+            return Atom("*/" + atom, allow_wildcard=True, 
allow_repo=self._allow_repo)
+
+    def update(self, atoms):
+        super().update(self._implicitWildcarding(a) for a in atoms)

diff --git a/lib/portage/dbapi/bintree.py b/lib/portage/dbapi/bintree.py
index 781d9fd4f3..b6056896e7 100644
--- a/lib/portage/dbapi/bintree.py
+++ b/lib/portage/dbapi/bintree.py
@@ -27,6 +27,7 @@ from portage.exception import (
     PortagePackageException,
     SignatureException,
 )
+from portage._sets.base import WildcardPackageSet
 from portage.localization import _
 from portage.output import colorize
 from portage.package.ebuild.profile_iuse import iter_iuse_vars
@@ -904,6 +905,8 @@ class binarytree:
     def populate(
         self,
         getbinpkgs=False,
+        getbinpkg_exclude=None,
+        getbinpkg_include=None,
         getbinpkg_refresh=False,
         verbose=False,
         add_repos=(),
@@ -916,6 +919,8 @@ class binarytree:
 
         @param getbinpkgs: include remote packages
         @type getbinpkgs: bool
+        @param getbinpkg_exclude: list of remote atoms to exclude
+        @param getbinpkg_include: list of remote atoms to include
         @param getbinpkg_refresh: attempt to refresh the cache
                 of remote package metadata if getbinpkgs is also True
         @type getbinpkg_refresh: bool
@@ -1000,6 +1005,8 @@ class binarytree:
                         getbinpkg_refresh=getbinpkg_refresh,
                         pretend=pretend,
                         verbose=verbose,
+                        getbinpkg_exclude=getbinpkg_exclude,
+                        getbinpkg_include=getbinpkg_include,
                     )
 
         finally:
@@ -1397,7 +1404,16 @@ class binarytree:
             return
         ret.check_returncode()
 
-    def _populate_remote(self, getbinpkg_refresh=True, pretend=False, 
verbose=False):
+    def _populate_remote(
+        self,
+        getbinpkg_refresh=True,
+        pretend=False,
+        verbose=False,
+        getbinpkg_exclude=None,
+        getbinpkg_include=None,
+    ):
+        from portage.util import writemsg
+
         self._remote_has_index = False
         self._remotepkgs = {}
 
@@ -1411,10 +1427,21 @@ class binarytree:
         else:
             gpkg_only = False
 
+        atoms = " ".join(getbinpkg_exclude or []).split()
+        getbinpkg_exclude = WildcardPackageSet(atoms)
+        atoms = " ".join(getbinpkg_include or []).split()
+        getbinpkg_include = WildcardPackageSet(atoms)
+
         # Order by descending priority.
         for repo in reversed(list(self._binrepos_conf.values())):
             self._populate_remote_repo(
-                repo, getbinpkg_refresh, pretend, verbose, gpkg_only
+                repo,
+                getbinpkg_refresh,
+                pretend,
+                verbose,
+                gpkg_only,
+                getbinpkg_exclude,
+                getbinpkg_include,
             )
 
     def _populate_remote_repo(
@@ -1424,6 +1451,8 @@ class binarytree:
         pretend: bool,
         verbose: bool,
         gpkg_only: bool,
+        getbinpkg_exclude: WildcardPackageSet,
+        getbinpkg_include: WildcardPackageSet,
     ):
         from portage.package.ebuild.fetch import _hide_url_passwd
         from portage.util import atomic_ofstream, writemsg
@@ -1778,6 +1807,8 @@ class binarytree:
                 # The current user doesn't have permission to cache the
                 # file, but that's alright.
         if pkgindex:
+            have_getbinpkg_exclude = not getbinpkg_exclude.isEmpty()
+            have_getbinpkg_include = not getbinpkg_include.isEmpty()
             remote_base_uri = pkgindex.header.get("URI", base_url)
             for d in pkgindex.packages:
                 cpv = _pkg_str(
@@ -1787,6 +1818,17 @@ class binarytree:
                     db=self.dbapi,
                     repoconfig=repo,
                 )
+
+                # Respect remote binary exclude and include lists if defined
+                in_getbinpkg_exclude = (
+                    have_getbinpkg_exclude and 
getbinpkg_exclude.containsCPV(cpv)
+                )
+                in_getbinpkg_include = (
+                    not have_getbinpkg_include or 
getbinpkg_include.containsCPV(cpv)
+                )
+                if in_getbinpkg_exclude or not in_getbinpkg_include:
+                    continue
+
                 # Local package instances override remote instances
                 # with the same instance_key.
                 if self.dbapi.cpv_exists(cpv):

diff --git a/lib/portage/tests/dbapi/test_bintree.py 
b/lib/portage/tests/dbapi/test_bintree.py
index 537556aff5..a1d58e0867 100644
--- a/lib/portage/tests/dbapi/test_bintree.py
+++ b/lib/portage/tests/dbapi/test_bintree.py
@@ -143,7 +143,11 @@ class BinarytreeTestCase(TestCase):
         bt = binarytree(pkgdir=os.getenv("TMPDIR", "/tmp"), settings=settings)
         bt.populate(getbinpkgs=True, getbinpkg_refresh=refresh)
         ppopulate_remote.assert_called_once_with(
-            getbinpkg_refresh=refresh, pretend=False, verbose=False
+            getbinpkg_refresh=refresh,
+            pretend=False,
+            verbose=False,
+            getbinpkg_exclude=None,
+            getbinpkg_include=None,
         )
 
     @patch("portage.dbapi.bintree.BinRepoConfigLoader")
@@ -189,7 +193,11 @@ class BinarytreeTestCase(TestCase):
         bt = binarytree(pkgdir=os.getenv("TMPDIR", "/tmp"), settings=settings)
         bt.populate(getbinpkgs=True)
         ppopulate_remote.assert_called_once_with(
-            getbinpkg_refresh=False, pretend=False, verbose=False
+            getbinpkg_refresh=False,
+            pretend=False,
+            verbose=False,
+            getbinpkg_exclude=None,
+            getbinpkg_include=None,
         )
 
     @patch("portage.data.secpass", 2)

diff --git a/lib/portage/tests/resolver/ResolverPlayground.py 
b/lib/portage/tests/resolver/ResolverPlayground.py
index aa7fbea70a..a66ed54944 100644
--- a/lib/portage/tests/resolver/ResolverPlayground.py
+++ b/lib/portage/tests/resolver/ResolverPlayground.py
@@ -44,6 +44,27 @@ from _emerge.Package import Package
 from _emerge.RootConfig import RootConfig
 
 
+def _combine_repo_config(conf, lines):
+    merged = lines.copy()
+    for section in reversed(conf):
+        header = f"[{section}]"
+        if header in merged:
+            index = merged.index(header) + 1
+            conflicts = [any(l.startswith(k) for l in lines) for k in 
conf[section]]
+            if any(conflicts):
+                reserved = ",".join(conf[section].keys())
+                raise AssertionError(
+                    f"cannot override config attributes [{reserved}] set by 
ResolverPlayground"
+                )
+        else:
+            merged.insert(0, header)
+            index = 1
+        for entry in conf[section].items():
+            merged.insert(index, ("%s = %s" % entry))
+            index += 1
+    return merged
+
+
 class ResolverPlayground:
     """
     This class helps to create the necessary files on disk and
@@ -77,7 +98,6 @@ class ResolverPlayground:
             "use.force",
             "use.mask",
             "use.stable",
-            "layout.conf",
         )
     )
 
@@ -120,6 +140,7 @@ class ResolverPlayground:
         self,
         ebuilds={},
         binpkgs={},
+        binrepos={},
         installed={},
         profile={},
         repo_configs={},
@@ -234,6 +255,10 @@ class ResolverPlayground:
         # Make sure the main repo is always created
         self._get_repo_dir("test_repo")
 
+        self._binrepos = {}
+        for binrepo in binrepos:
+            self._get_binrepo_dir(binrepo)
+
         self._create_distfiles(distfiles)
         self._create_ebuilds(ebuilds)
         self._create_installed(installed)
@@ -245,9 +270,13 @@ class ResolverPlayground:
         self.settings, self.trees = self._load_config()
 
         self.gpg = None
-        self._create_binpkgs(binpkgs)
+        self._create_binpkgs(self.pkgdir, binpkgs)
         self._create_ebuild_manifests(ebuilds)
 
+        for binrepo, binpkgs in binrepos.items():
+            binrepo_dir = self._get_binrepo_dir(binrepo)
+            self._create_binpkgs(binrepo_dir, binpkgs)
+
         portage.util.noiselimit = 0
 
     def reload_config(self):
@@ -283,6 +312,23 @@ class ResolverPlayground:
 
         return self._repositories[repo]["location"]
 
+    def _get_binrepo_dir(self, binrepo):
+        """
+        Create the binrepo directory if needed.
+        """
+        if binrepo not in self._binrepos:
+            self._binrepos["DEFAULT"] = {"frozen": "yes"}
+
+            repo_path = os.path.join(self.eroot, "var", "binrepos", binrepo)
+            self._binrepos[binrepo] = {"sync-uri": repo_path}
+
+            try:
+                os.makedirs(repo_path)
+            except OSError:
+                pass
+
+        return self._binrepos[binrepo]["sync-uri"]
+
     def _create_distfiles(self, distfiles):
         os.makedirs(self.distdir)
         for k, v in distfiles.items():
@@ -352,7 +398,7 @@ class ResolverPlayground:
                     f"command failed with returncode {result.returncode}: 
{egencache_cmd}"
                 )
 
-    def _create_binpkgs(self, binpkgs):
+    def _create_binpkgs(self, repo_dir, binpkgs):
         # When using BUILD_ID, there can be multiple instances for the
         # same cpv. Therefore, binpkgs may be an iterable instead of
         # a dict.
@@ -382,7 +428,6 @@ class ResolverPlayground:
             metadata["PF"] = pf
             metadata["BINPKG_FORMAT"] = binpkg_format
 
-            repo_dir = self.pkgdir
             category_dir = os.path.join(repo_dir, cat)
             if "BUILD_ID" in metadata:
                 if binpkg_format == "xpak":
@@ -415,8 +460,8 @@ class ResolverPlayground:
             else:
                 raise InvalidBinaryPackageFormat(binpkg_format)
 
-            bintree = binarytree(pkgdir=self.pkgdir, settings=self.settings)
-            bintree.populate(force_reindex=True)
+        bintree = binarytree(pkgdir=repo_dir, settings=self.settings)
+        bintree.populate(force_reindex=True)
 
     def _create_installed(self, installed):
         for cpv in installed:
@@ -633,6 +678,12 @@ class ResolverPlayground:
         configs = user_config.copy()
         configs["make.conf"] = make_conf_lines
 
+        if self._binrepos:
+            binrepos_conf_lines = list(user_config.get("binrepos.conf", ()))
+            configs["binrepos.conf"] = _combine_repo_config(
+                self._binrepos, binrepos_conf_lines
+            )
+
         for config_file, lines in configs.items():
             if config_file not in self.config_files:
                 raise ValueError(f"Unknown config file: '{config_file}'")
@@ -738,6 +789,13 @@ class ResolverPlayground:
             elif options.get("--prune"):
                 action = "prune"
 
+        if "--getbinpkgonly" in options:
+            options["--getbinpkg"] = True
+            options["--usepkgonly"] = True
+
+        if "--getbinpkg" in options:
+            options["--usepkg"] = True
+
         if "--usepkgonly" in options:
             options["--usepkg"] = True
 
@@ -750,6 +808,14 @@ class ResolverPlayground:
                 portage.util.noiselimit = -2
             _emerge.emergelog._disable = True
 
+            if self._binrepos:
+                self.trees[self.eroot]["bintree"].populate(
+                    getbinpkgs=options.get("--getbinpkg", False),
+                    getbinpkg_exclude=options.get("--getbinpkg-exclude", None),
+                    getbinpkg_include=options.get("--getbinpkg-include", None),
+                    pretend=options.get("--pretend", False),
+                )
+
             # NOTE: frozen_config could be cached and reused if options and 
params were constant.
             params_action = (
                 "remove" if action in ("dep_check", "depclean", "prune") else 
action
@@ -1020,9 +1086,12 @@ def _mergelist_str(x, depgraph):
         mergelist_str = x.cpv + build_id_str + repo_str
         if x.built:
             if x.operation == "merge":
-                desc = x.type_name
+                desc = [x.type_name]
             else:
-                desc = x.operation
+                desc = [x.operation]
+            if x.remote:
+                desc.append("remote")
+            desc = ",".join(desc)
             mergelist_str = f"[{desc}]{mergelist_str}"
         if x.root != depgraph._frozen_config._running_root.root:
             mergelist_str += "{targetroot}"

diff --git a/lib/portage/tests/resolver/test_binpackage_selection.py 
b/lib/portage/tests/resolver/test_binpackage_selection.py
index 2d6df54ee1..ac2f10ec63 100644
--- a/lib/portage/tests/resolver/test_binpackage_selection.py
+++ b/lib/portage/tests/resolver/test_binpackage_selection.py
@@ -16,6 +16,12 @@ class BinPkgSelectionTestCase(TestCase):
         "app-misc/baz-1.0": {},
     }
 
+    pkgs_no_deps_newer = {
+        "app-misc/foo-1.1": {},
+        "app-misc/bar-1.1": {},
+        "app-misc/baz-1.1": {},
+    }
+
     pkgs_with_deps_newer = {
         "app-misc/foo-1.1": {"RDEPEND": "app-misc/bar"},
         "app-misc/bar-1.1": {"RDEPEND": "app-misc/baz"},
@@ -52,6 +58,528 @@ class BinPkgSelectionTestCase(TestCase):
             playground.cleanup()
 
 
+# test --getbinpkg-exclude option
+class GetBinPkgExcludeTestCase(BinPkgSelectionTestCase):
+
+    def testGetBinPkgExcludeOpt(self):
+        binpkgs = self.pkgs_no_deps
+        ebuilds = self.pkgs_no_deps
+
+        binrepos = {"test_binrepo": self.pkgs_no_deps}
+
+        test_cases = (
+            # --getbinpkg-exclude to have no effect without --getbinpkg
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg-exclude": ["foo"]},
+                mergelist=[
+                    "app-misc/foo-1.0",
+                    "app-misc/bar-1.0",
+                    "app-misc/baz-1.0",
+                ],
+            ),
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--usepkgonly": True, "--getbinpkg-exclude": ["foo"]},
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+            # --getbinpkg-exclude with unmatched atom excludes no remote 
binaries
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-exclude": 
["dev-libs/foo"]},
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.0",
+                    "[binary,remote]app-misc/bar-1.0",
+                    "[binary,remote]app-misc/baz-1.0",
+                ],
+            ),
+            # --getbinpkg-exclude in conflict with --getbinpkg-include to have 
no effect
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--getbinpkg": True,
+                    "--getbinpkg-exclude": ["foo"],
+                    "--getbinpkg-include": ["foo"],
+                },
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.0",
+                    "[binary,remote]app-misc/bar-1.0",
+                    "[binary,remote]app-misc/baz-1.0",
+                ],
+            ),
+            # --getbinpkg-exclude in conflict with --getbinpkg-include to not
+            # interfere with non-overlapping --getbinpkg-exclude
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--getbinpkg": True,
+                    "--getbinpkg-exclude": ["foo", "bar"],
+                    "--getbinpkg-include": ["foo"],
+                },
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.0",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary,remote]app-misc/baz-1.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-exclude with single atom
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-exclude": ["foo"]},
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "[binary,remote]app-misc/bar-1.0",
+                    "[binary,remote]app-misc/baz-1.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-exclude with multiple atoms
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-exclude": ["foo", 
"bar"]},
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary,remote]app-misc/baz-1.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-exclude with wildcard
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-exclude": 
["app-misc/b*"]},
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.0",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+            # combined use of --getbinpkg-exclude and --usepkg-exclude can have
+            # a complimentary effect (leaving some remote binaries selected)...
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--getbinpkg": True,
+                    "--getbinpkg-exclude": ["app-misc/b*"],
+                    "--usepkg-exclude": ["baz"],
+                },
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.0",
+                    "[binary]app-misc/bar-1.0",
+                    "app-misc/baz-1.0",
+                ],
+            ),
+            # ...or an overriding effect with no remote binaries selected. 
depends
+            # on the overlap in the specified atoms
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--getbinpkg": True,
+                    "--getbinpkg-exclude": ["app-misc/b*"],
+                    "--usepkg-exclude": ["foo"],
+                },
+                mergelist=[
+                    "app-misc/foo-1.0",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+        )
+
+        self.runBinPkgSelectionTest(
+            test_cases, binpkgs=binpkgs, binrepos=binrepos, ebuilds=ebuilds
+        )
+
+    def testGetBinPkgExcludeFallbacks(self):
+        binpkgs = self.pkgs_no_deps
+        ebuilds = self.pkgs_no_deps | self.pkgs_no_deps_newer
+
+        binrepos = {"test_binrepo": self.pkgs_no_deps_newer}
+
+        test_cases = (
+            # prefer newer ebuild over old local binary where 
--getbinpkg-exclude
+            # prevents fetching newer remote binary and --usepkgonly is not 
used
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-exclude": ["foo"]},
+                mergelist=[
+                    "app-misc/foo-1.1",
+                    "[binary,remote]app-misc/bar-1.1",
+                    "[binary,remote]app-misc/baz-1.1",
+                ],
+            ),
+            # --usepkgonly excludes newer ebuilds and so forces fallback on 
older
+            # local binary where --getbinpkg-exclude is used
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--usepkgonly": True,
+                    "--getbinpkg": True,
+                    "--getbinpkg-exclude": ["foo"],
+                },
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "[binary,remote]app-misc/bar-1.1",
+                    "[binary,remote]app-misc/baz-1.1",
+                ],
+            ),
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    # currently --getbinpkgonly is equivalent to previous test
+                    "--getbinpkgonly": True,
+                    "--getbinpkg-exclude": ["foo"],
+                },
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "[binary,remote]app-misc/bar-1.1",
+                    "[binary,remote]app-misc/baz-1.1",
+                ],
+            ),
+        )
+
+        self.runBinPkgSelectionTest(
+            test_cases, binpkgs=binpkgs, binrepos=binrepos, ebuilds=ebuilds
+        )
+
+    def testGetBinPkgExcludeSlot(self):
+        ebuilds = self.pkgs_with_slots
+        binpkgs = self.pkgs_with_slots
+
+        binrepos = {"test_binrepo": self.pkgs_with_slots}
+
+        test_cases = (
+            # request all packages and --getbinpkg-exclude with single slot 
atom
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-exclude": 
["foo:2"]},
+                mergelist=[
+                    "[binary]app-misc/foo-2.0",
+                    "[binary,remote]app-misc/bar-2.0",
+                    "[binary,remote]app-misc/baz-2.0",
+                ],
+            ),
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-exclude": 
["foo:2"]},
+                mergelist=[
+                    "[binary]app-misc/foo-2.0",
+                    "[binary,remote]app-misc/bar-2.0",
+                    "[binary,remote]app-misc/baz-2.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-exclude with wildcard slot 
atom
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-exclude": 
["app-misc/b*:2"]},
+                mergelist=[
+                    "[binary,remote]app-misc/foo-2.0",
+                    "[binary]app-misc/bar-2.0",
+                    "[binary]app-misc/baz-2.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-exclude with unmatched slot 
atom
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--getbinpkg": True,
+                    "--getbinpkg-exclude": ["app-misc/foo:1"],
+                },
+                mergelist=[
+                    "[binary,remote]app-misc/foo-2.0",
+                    "[binary,remote]app-misc/bar-2.0",
+                    "[binary,remote]app-misc/baz-2.0",
+                ],
+            ),
+        )
+
+        self.runBinPkgSelectionTest(
+            test_cases, binpkgs=binpkgs, binrepos=binrepos, ebuilds=ebuilds
+        )
+
+
+# test --getbinpkg-include option
+class GetBinPkgIncludeTestCase(BinPkgSelectionTestCase):
+
+    def testGetBinPkgIncludeOpt(self):
+        binpkgs = self.pkgs_no_deps
+        ebuilds = self.pkgs_no_deps
+
+        binrepos = {"test_binrepo": self.pkgs_no_deps}
+
+        test_cases = (
+            # --getbinpkg-include to have no effect without --getbinpkg
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg-include": ["foo"]},
+                mergelist=[
+                    "app-misc/foo-1.0",
+                    "app-misc/bar-1.0",
+                    "app-misc/baz-1.0",
+                ],
+            ),
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--usepkgonly": True, "--getbinpkg-include": ["foo"]},
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+            # --getbinpkg-include with unmatched atom excludes all remote 
binaries
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-include": 
["dev-libs/foo"]},
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-include with single atom
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-include": ["foo"]},
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.0",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-include with multiple atoms
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-include": ["foo", 
"bar"]},
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.0",
+                    "[binary,remote]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-include with wildcard
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-include": 
["app-misc/b*"]},
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "[binary,remote]app-misc/bar-1.0",
+                    "[binary,remote]app-misc/baz-1.0",
+                ],
+            ),
+            # --getbinpkg-include in conflict with --getbinpkg-exclude to not
+            # interfere with non-overlapping --getbinpkg-include
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--getbinpkg": True,
+                    "--getbinpkg-exclude": ["foo"],
+                    "--getbinpkg-include": ["foo", "bar"],
+                },
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "[binary,remote]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+            # combined use of --getbinpkg-include and --usepkg-include can have
+            # a complimentary effect (leaving some remote binaries selected)...
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--getbinpkg": True,
+                    "--getbinpkg-include": ["app-misc/b*"],
+                    "--usepkg-include": ["baz"],
+                },
+                mergelist=[
+                    "app-misc/foo-1.0",
+                    "app-misc/bar-1.0",
+                    "[binary,remote]app-misc/baz-1.0",
+                ],
+            ),
+            # ...or an overriding effect with no remote binaries selected. 
depends
+            # on the overlap in the specified atoms
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--getbinpkg": True,
+                    "--getbinpkg-include": ["app-misc/b*"],
+                    "--usepkg-include": ["foo"],
+                },
+                mergelist=[
+                    "[binary]app-misc/foo-1.0",
+                    "app-misc/bar-1.0",
+                    "app-misc/baz-1.0",
+                ],
+            ),
+        )
+
+        self.runBinPkgSelectionTest(
+            test_cases, binpkgs=binpkgs, binrepos=binrepos, ebuilds=ebuilds
+        )
+
+    def testGetBinPkgIncludeFallbacks(self):
+        binpkgs = self.pkgs_no_deps
+        ebuilds = self.pkgs_no_deps | self.pkgs_no_deps_newer
+
+        binrepos = {"test_binrepo": self.pkgs_no_deps_newer}
+
+        test_cases = (
+            # prefer newer ebuild over old local binary where 
--getbinpkg-include
+            # prevents fetching newer remote binary and --usepkgonly is not 
used
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-include": ["foo"]},
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.1",
+                    "app-misc/bar-1.1",
+                    "app-misc/baz-1.1",
+                ],
+            ),
+            # --usepkgonly excludes newer ebuilds and so forces fallback on 
older
+            # local binary where --getbinpkg-include is used
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    "--usepkgonly": True,
+                    "--getbinpkg": True,
+                    "--getbinpkg-include": ["foo"],
+                },
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.1",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={
+                    # currently --getbinpkgonly is equivalent to previous test
+                    "--getbinpkgonly": True,
+                    "--getbinpkg-include": ["foo"],
+                },
+                mergelist=[
+                    "[binary,remote]app-misc/foo-1.1",
+                    "[binary]app-misc/bar-1.0",
+                    "[binary]app-misc/baz-1.0",
+                ],
+            ),
+        )
+
+        self.runBinPkgSelectionTest(
+            test_cases, binpkgs=binpkgs, binrepos=binrepos, ebuilds=ebuilds
+        )
+
+    def testGetBinPkgIncludeSlot(self):
+        ebuilds = self.pkgs_with_slots
+        binpkgs = self.pkgs_with_slots
+
+        binrepos = {"test_binrepo": self.pkgs_with_slots}
+
+        test_cases = (
+            # request all packages and --getbinpkg-include with single slot 
atom
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-include": 
["foo:2"]},
+                mergelist=[
+                    "[binary,remote]app-misc/foo-2.0",
+                    "[binary]app-misc/bar-2.0",
+                    "[binary]app-misc/baz-2.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-include with wildcard slot 
atom
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--getbinpkg": True, "--getbinpkg-include": 
["app-misc/b*:2"]},
+                mergelist=[
+                    "[binary]app-misc/foo-2.0",
+                    "[binary,remote]app-misc/bar-2.0",
+                    "[binary,remote]app-misc/baz-2.0",
+                ],
+            ),
+            # request all packages and --getbinpkg-include with unmatched slot 
atom
+            ResolverPlaygroundTestCase(
+                self.pkg_atoms,
+                success=True,
+                ignore_mergelist_order=True,
+                options={"--usepkg": True, "--getbinpkg-include": 
["app-misc/foo:1"]},
+                mergelist=[
+                    "[binary]app-misc/foo-2.0",
+                    "[binary]app-misc/bar-2.0",
+                    "[binary]app-misc/baz-2.0",
+                ],
+            ),
+        )
+
+        self.runBinPkgSelectionTest(
+            test_cases, binpkgs=binpkgs, binrepos=binrepos, ebuilds=ebuilds
+        )
+
+
 # test --usepkg-exclude option
 class UsePkgExcludeTestCase(BinPkgSelectionTestCase):
 

diff --git a/lib/portage/tests/sets/base/test_wildcard_package_set.py 
b/lib/portage/tests/sets/base/test_wildcard_package_set.py
new file mode 100644
index 0000000000..b53c121177
--- /dev/null
+++ b/lib/portage/tests/sets/base/test_wildcard_package_set.py
@@ -0,0 +1,30 @@
+# Copyright 2026 Gentoo Authors
+# Distributed under the terms of the GNU General Public License v2
+
+from portage.dep import Atom
+from portage.exception import InvalidAtom
+from portage.tests import TestCase
+from portage._sets.base import WildcardPackageSet
+
+
+class WildcardPackageSetTestCase(TestCase):
+    """Test case for WildcardPackageSet"""
+
+    def testWildcardPackageSet(self):
+        ambig_atoms = {"A", "B", "C"}
+        norm_atoms = {"dev-libs/A", ">=dev-libs/A-1"}
+        wild_atoms = {"dev-libs/*", "*/B"}
+        sets = {"@world", "@installed", "@system"}
+
+        w1 = WildcardPackageSet(initial_atoms=norm_atoms)
+        w2 = WildcardPackageSet(initial_atoms=wild_atoms)
+        self.assertRaises(InvalidAtom, WildcardPackageSet, initial_atoms=sets)
+
+        self.assertEqual(w1.getAtoms(), norm_atoms)
+        self.assertEqual(w2.getAtoms(), wild_atoms)
+
+        w3 = WildcardPackageSet(initial_atoms=ambig_atoms)
+        self.assertEqual(w3.getAtoms(), {"*/" + a for a in ambig_atoms})
+
+        w4 = WildcardPackageSet(initial_atoms=(Atom(a) for a in norm_atoms))
+        self.assertEqual(w4.getAtoms(), norm_atoms)

diff --git a/man/emerge.1 b/man/emerge.1
index 4ed28e67b1..a7b5fa0cf1 100644
--- a/man/emerge.1
+++ b/man/emerge.1
@@ -632,6 +632,22 @@ merging.)
 This option is identical to \fB\-g\fR, as above, except binaries from the
 remote server are preferred over local packages if they are not identical.
 .TP
+.BR "\-\-getbinpkg\-exclude " ATOMS
+A space separated list of package names or slot atoms. Emerge will not fetch
+matching remote binary packages. This option only influences fetching of
+remote binary packages, local binary packages are still considered even if
+listed here. To define an explicit binary package blacklist use
+\fB\-\-usepkg\-exclude\fR instead, which also affects remote binaries when
+\fB-g\fR is used.
+.TP
+.BR "\-\-getbinpkg\-include " ATOMS
+A space separated list of package names or slot atoms. Emerge will not fetch
+non-matching remote binary packages. This option only influences fetching of
+remote binary packages, local binary packages are still considered even if
+not listed here. To define an explicit binary package whitelist use
+\fB\-\-usepkg\-include\fR instead, which also affects remote binaries when
+\fB-g\fR is used.
+.TP
 .BR \-\-ignore-default-opts
 Causes \fIEMERGE_DEFAULT_OPTS\fR (see \fBmake.conf\fR(5)) to be ignored.
 .TP


Reply via email to