commit:     5cebd32552c21d48dbe66f89abe7ca4d0bf2dda1
Author:     Sam James <sam <AT> gentoo <DOT> org>
AuthorDate: Tue Sep 26 23:12:57 2023 +0000
Commit:     Sam James <sam <AT> gentoo <DOT> org>
CommitDate: Sat Feb 14 03:46:22 2026 +0000
URL:        https://gitweb.gentoo.org/proj/portage.git/commit/?id=5cebd325

emerge: warn on installed packages in @world without ebuilds available

This is an outrageously common issue for users: they emerge something
a while ago, it gets treecleaned (possibly without the user ever seeing
the p.mask entry if they don't use the particular system often), but Portage
diligently works to keep the package installed and its dependencies satisfied.

Portage is completely in the right here, but it's very easy for users
to not be aware that this is still a constraint they've asked for that is
being honoured.

This is particularly bad if that package has a slot-operator dependency (:=)
on something which has just changed subslot, as it can't be rebuilt (because
no ebuild), and users get confusing conflicts as a result.

Anyway, for the actual changes:
* Use _replace_installed_atom which is already used for solving slot-operator
conflicts as a proxy for whether we have an ebuild (or something close enough).

* Warn loudly, as we do for other broken things in the world file, if an entry
in there has no ebuilds available.

At one point, I did also have:

* Make _replace_installed_atom's scanning for similar slots optional to speed
things up for our "is an ebuild available for thing in world" check. It remains
on for all existing consumers.

but Zac pointed out this may lead to noise where dependency resolution
would then succeed and indeed I can't convince myself it was right, so
I've dropped that.

Bug: https://bugs.gentoo.org/911180
Signed-off-by: Sam James <sam <AT> gentoo.org>

 lib/_emerge/depgraph.py                          |  16 ++
 lib/portage/tests/resolver/meson.build           |   1 +
 lib/portage/tests/resolver/test_world_warning.py | 273 +++++++++++++++++++++++
 3 files changed, 290 insertions(+)

diff --git a/lib/_emerge/depgraph.py b/lib/_emerge/depgraph.py
index b6220ba38a..4c5293cc63 100644
--- a/lib/_emerge/depgraph.py
+++ b/lib/_emerge/depgraph.py
@@ -5357,6 +5357,22 @@ class depgraph:
                         if not package_is_installed:
                             continue
 
+                    # If we're emerging @selected or @world, we want to loudly 
warn about
+                    # no ebuilds being available for packages (bug #911180).
+                    if (
+                        pkg
+                        and pkg.installed
+                        and pkg.operation == "nomerge"
+                        and isinstance(arg, SetArg)
+                        and arg.name in ("selected, world")
+                        and not self._replace_installed_atom(pkg)
+                    ):
+                        self._dynamic_config._missing_args.append((arg, atom))
+
+                    # But here, we don't warn unlike for @selected or @world 
because the
+                    # user might be emerging something else and a package 
instead gets
+                    # dragged in. We may want to warn about this at some 
point, but one
+                    # step at a time.
                     if not pkg:
                         pprovided_match = False
                         for virt_choice in virtuals.get(atom.cp, []):

diff --git a/lib/portage/tests/resolver/meson.build 
b/lib/portage/tests/resolver/meson.build
index 7ae73630be..6787ad3029 100644
--- a/lib/portage/tests/resolver/meson.build
+++ b/lib/portage/tests/resolver/meson.build
@@ -95,6 +95,7 @@ py.install_sources(
         'test_virtual_slot.py',
         'test_virtual_cycle.py',
         'test_with_test_deps.py',
+        'test_world_warning.py',
         '__init__.py',
         '__test__.py',
     ],

diff --git a/lib/portage/tests/resolver/test_world_warning.py 
b/lib/portage/tests/resolver/test_world_warning.py
new file mode 100644
index 0000000000..ea728cd15e
--- /dev/null
+++ b/lib/portage/tests/resolver/test_world_warning.py
@@ -0,0 +1,273 @@
+# Copyright 2023-2026 Gentoo Authors
+# Distributed under the terms of the GNU General Public License v2
+
+from portage.tests import TestCase
+from portage.tests.resolver.ResolverPlayground import (
+    ResolverPlayground,
+    ResolverPlaygroundTestCase,
+)
+
+
+class WorldWarningTestCase(TestCase):
+    def testWorldWarningEmerge(self):
+        """
+        Test that we warn about a package in @world with no ebuild.
+        """
+        installed = {
+            "app-misc/i-do-not-exist-1": {},
+        }
+        ebuilds = {}
+
+        playground = ResolverPlayground(
+            world=["app-misc/i-do-not-exist"],
+            ebuilds=ebuilds,
+            installed=installed,
+        )
+
+        test_case = ResolverPlaygroundTestCase(
+            ["@world"],
+            mergelist=[],
+            options={
+                "--update": True,
+                "--deep": True,
+            },
+            success=True,
+        )
+
+        try:
+            # Just to make sure we don't freak out on the general case
+            # without worrying about the specific output first.
+            playground.run_TestCase(test_case)
+            self.assertEqual(test_case.test_success, True, test_case.fail_msg)
+
+            # We need access to the depgraph object to check for missing_args
+            # so we run again manually.
+            depgraph = playground.run(
+                ["@world"], test_case.options, test_case.action
+            ).depgraph
+
+            self.assertIsNotNone(depgraph._dynamic_config._missing_args)
+            self.assertTrue(
+                len(depgraph._dynamic_config._missing_args) > 0,
+                "Ebuild-less packages did not raise an error",
+            )
+        finally:
+            playground.cleanup()
+
+    def testWorldWarningVsSubslotRebuildEmerge(self):
+        """
+        Test that we warn about a package in @world with no ebuild
+        where it is already installed and is blocking a subslot
+        rebuild.
+        """
+        installed = {
+            "app-misc/i-do-not-exist-1": {
+                "EAPI": "8",
+                "RDEPEND": "dev-libs/libfoo:0/1=",
+                "DEPEND": "dev-libs/libfoo:0/1=",
+            },
+            "app-misc/bar-1": {
+                "EAPI": "8",
+                "RDEPEND": "dev-libs/libfoo:0/1=",
+                "DEPEND": "dev-libs/libfoo:0/1=",
+            },
+            "dev-libs/libfoo-1": {
+                "EAPI": "8",
+                "SLOT": "0/1",
+            },
+        }
+        ebuilds = {
+            "app-misc/bar-1": {
+                "EAPI": "8",
+                "RDEPEND": "dev-libs/libfoo:=",
+                "DEPEND": "dev-libs/libfoo:=",
+            },
+            "dev-libs/libfoo-2": {
+                "EAPI": "8",
+                "SLOT": "0/2",
+            },
+        }
+
+        playground = ResolverPlayground(
+            world=["app-misc/i-do-not-exist", "app-misc/bar"],
+            ebuilds=ebuilds,
+            installed=installed,
+        )
+
+        test_case = ResolverPlaygroundTestCase(
+            ["@world"],
+            mergelist=[],
+            options={
+                "--update": True,
+                "--deep": True,
+            },
+            success=True,
+        )
+
+        try:
+            # Just to make sure we don't freak out on the general case
+            # without worrying about the specific output first.
+            playground.run_TestCase(test_case)
+            self.assertEqual(test_case.test_success, True, test_case.fail_msg)
+
+            # We need access to the depgraph object to check for missing_args
+            # so we run again manually.
+            depgraph = playground.run(
+                ["@world"], test_case.options, test_case.action
+            ).depgraph
+
+            self.assertIsNotNone(depgraph._dynamic_config._missing_args)
+            self.assertTrue(
+                len(depgraph._dynamic_config._missing_args) > 0,
+                "Ebuild-less packages did not raise an error",
+            )
+        finally:
+            playground.cleanup()
+
+    def testAbsentWorldWarningEmerge(self):
+        """
+        Test that we do not warn about a package in @world with an ebuild
+        available.
+        """
+
+        installed = {
+            "app-misc/i-do-exist-1": {},
+        }
+        ebuilds = {
+            # Package has a newer ebuild available but not
+            # for the installed version.
+            "app-misc/i-do-exist-2": {}
+        }
+
+        playground = ResolverPlayground(
+            world=["app-misc/i-do-exist"],
+            ebuilds=ebuilds,
+            installed=installed,
+        )
+
+        test_case = ResolverPlaygroundTestCase(
+            ["@world"],
+            mergelist=["app-misc/i-do-exist-2"],
+            options={
+                "--update": True,
+                "--deep": True,
+            },
+            success=True,
+        )
+
+        try:
+            # Just to make sure we don't freak out on the general case
+            # without worrying about the specific output first.
+            playground.run_TestCase(test_case)
+            self.assertEqual(test_case.test_success, True, test_case.fail_msg)
+
+            # We need access to the depgraph object to check for missing_args
+            # so we run again manually.
+            depgraph = playground.run(
+                ["@world"], test_case.options, test_case.action
+            ).depgraph
+
+            self.assertIsNotNone(depgraph._dynamic_config._missing_args)
+            self.assertTrue(
+                len(depgraph._dynamic_config._missing_args) == 0,
+                "Package with an ebuild was incorrectly flagged",
+            )
+        finally:
+            playground.cleanup()
+
+    def testAbsentNotInWorldWarningEmerge(self):
+        """
+        Test that we do not warn about an installed package with no
+        ebuild available if the package is not in @world.
+        """
+
+        installed = {
+            "app-misc/i-do-not-exist-1": {},
+        }
+        ebuilds = {}
+
+        playground = ResolverPlayground(
+            world=[],
+            ebuilds=ebuilds,
+            installed=installed,
+        )
+
+        test_case = ResolverPlaygroundTestCase(
+            ["@world"],
+            mergelist=[],
+            options={
+                "--update": True,
+                "--deep": True,
+            },
+            success=True,
+        )
+
+        try:
+            # Just to make sure we don't freak out on the general case
+            # without worrying about the specific output first.
+            playground.run_TestCase(test_case)
+            self.assertEqual(test_case.test_success, True, test_case.fail_msg)
+
+            # We need access to the depgraph object to check for missing_args
+            # so we run again manually.
+            depgraph = playground.run(
+                ["@world"], test_case.options, test_case.action
+            ).depgraph
+
+            self.assertIsNotNone(depgraph._dynamic_config._missing_args)
+            self.assertTrue(
+                len(depgraph._dynamic_config._missing_args) == 0,
+                "Package without an ebuild but not in world was incorrectly 
flagged",
+            )
+        finally:
+            playground.cleanup()
+
+    def testAbsentNodeNotInWorldWarningEmerge(self):
+        """
+        Test that we do not warn about an installed package with an ebuild 
available
+        if the package is not in @world but is depended on by something in 
@world.
+        """
+
+        installed = {
+            "app-misc/foo-1": {"RDEPEND": "dev-libs/bar"},
+            "dev-libs/bar-1": {},
+        }
+        ebuilds = {
+            "app-misc/foo-1": {"RDEPEND": "dev-libs/bar"},
+        }
+
+        playground = ResolverPlayground(
+            world=["app-misc/foo"],
+            ebuilds=ebuilds,
+            installed=installed,
+        )
+
+        test_case = ResolverPlaygroundTestCase(
+            ["@world"],
+            mergelist=[],
+            options={
+                "--update": True,
+                "--deep": True,
+            },
+            success=True,
+        )
+
+        try:
+            # Just to make sure we don't freak out on the general case
+            # without worrying about the specific output first.
+            playground.run_TestCase(test_case)
+            self.assertEqual(test_case.test_success, True, test_case.fail_msg)
+
+            # We need access to the depgraph object to check for missing_args
+            # so we run again manually.
+            depgraph = playground.run(
+                ["@world"], test_case.options, test_case.action
+            ).depgraph
+
+            self.assertIsNotNone(depgraph._dynamic_config._missing_args)
+            self.assertTrue(
+                len(depgraph._dynamic_config._missing_args) == 0,
+                "Package with an ebuild that was reachable from world was 
incorrectly flagged",
+            )
+        finally:
+            playground.cleanup()

Reply via email to