commit:     aae28fd06a45ad1ba0c3cb62bcbf294a46d7f876
Author:     Thomas Bracht Laumann Jespersen <t <AT> laumann <DOT> xyz>
AuthorDate: Wed Jan 21 05:11:58 2026 +0000
Commit:     Sam James <sam <AT> gentoo <DOT> org>
CommitDate: Wed Jan 21 06:03:11 2026 +0000
URL:        https://gitweb.gentoo.org/proj/gentoolkit.git/commit/?id=aae28fd0

eclean-pkg: clean out debuginfo tarballs

For each binpkg identified for removal, construct its corresponding
debuginfo tarball name, check if it exists and add it to the removal
list as well. The reported size for each package that is removed
includes the debuginfo tarball sizes.

The structure returned by findPackages() is changed from a list of
files to a tuple of (binpkg, Optional[debugpack]) and the cleaning and
prompting is adapted to handle this new structure. Notably, now
pretend_clean() has to deal with the files for each entry being either
a list (for files) or a tuple (for binpkgs).

Closes: https://bugs.gentoo.org/967112
Signed-off-by: Thomas Bracht Laumann Jespersen <t <AT> laumann.xyz>
Part-of: https://codeberg.org/gentoo/gentoolkit/pulls/3
Signed-off-by: Sam James <sam <AT> gentoo.org>

 pym/gentoolkit/eclean/clean.py  | 90 +++++++++++++++++++++++++++++++++++++----
 pym/gentoolkit/eclean/output.py |  5 ++-
 pym/gentoolkit/eclean/search.py | 31 ++++++++++++--
 3 files changed, 114 insertions(+), 12 deletions(-)

diff --git a/pym/gentoolkit/eclean/clean.py b/pym/gentoolkit/eclean/clean.py
index 776c6a4..c77d9e2 100644
--- a/pym/gentoolkit/eclean/clean.py
+++ b/pym/gentoolkit/eclean/clean.py
@@ -40,7 +40,7 @@ class CleanUp:
         clean_size = 0
         # clean all entries one by one; sorting helps reading
         for key in sorted(clean_dict):
-            clean_size += self._clean_files(clean_dict[key], key, file_type)
+            clean_size += self._clean_files(clean_dict[key], key)
         # return total size of deleted or to delete files
         clean_size += self._clean_vcs_src(vcs)
         return clean_size
@@ -61,7 +61,7 @@ class CleanUp:
         clean_size = 0
         # clean all entries one by one; sorting helps reading
         for key in sorted(clean_dict):
-            clean_size += self._clean_files(clean_dict[key], key, file_type)
+            clean_size += self._clean_binary_package(clean_dict[key], key)
 
         #  run 'emaint --fix' here
         if clean_size:
@@ -88,10 +88,19 @@ class CleanUp:
         # tally all entries one by one; sorting helps reading
         if vcs:
             clean_size += self._clean_vcs_src(vcs, pretend=True)
-        for key in sorted(clean_dict):
-            key_size = self._get_size(clean_dict[key])
-            self.controller(key_size, key, clean_dict[key], file_type)
-            clean_size += key_size
+        if file_type == "file":
+            for key in sorted(clean_dict):
+                key_size = self._get_size(clean_dict[key])
+                self.controller(key_size, key, clean_dict[key], file_type)
+                clean_size += key_size
+        else:
+            # binary package
+            for key in sorted(clean_dict):
+                (binpkg, debugpack) = clean_dict[key]
+                key_size = self._get_size([binpkg, debugpack] if debugpack 
else [binpkg])
+                self.controller(key_size, key, clean_dict[key], file_type)
+                clean_size += key_size
+
         return clean_size
 
     def _get_size(self, key):
@@ -111,7 +120,72 @@ class CleanUp:
                 print(pp.error("Error: %s" % str(er)), file=sys.stderr)
         return key_size
 
-    def _clean_files(self, files, key, file_type):
+    def _get_size_valid_symlink(self, file_):
+        """
+        Get a file size, attempting to remove broken symlinks when
+        possible (attempting to remove any broken symlinks results in
+        this function returning 0).
+        """
+        try:
+            statinfo = os.stat(file_)
+            if statinfo.st_nlink == 1:
+                return statinfo.st_size
+        except OSError as er:
+            if os.path.exists(os.readlink(file_)):
+                print(pp.error("Could not get stat info for:" + file_), 
file=sys.stderr)
+                print(pp.error("Error: %s" % str(er)), file=sys.stderr)
+            else:
+                try:
+                    os.remove(file_)
+                    print(
+                        pp.error("Removed broken symbolic link " + file_),
+                        file=sys.stderr,
+                    )
+                except OSError as er:
+                    print(
+                        pp.error("Error deleting broken symbolic link " + 
file_),
+                        file=sys.stderr,
+                    )
+                    print(pp.error("Error: %s" % str(er)), file=sys.stderr)
+        return 0
+
+    def _clean_binary_package(self, files: tuple[str, str | None], key: str):
+        # collect files
+        rm_files = []
+        rm_size = 0
+
+        if (sz := self._get_size_valid_symlink(files[0])):
+            rm_files.append(files[0])
+            rm_size += sz
+
+        if files[1] and (sz := self._get_size_valid_symlink(files[1])):
+            rm_files.append(files[1])
+            rm_size += sz
+
+        if not rm_size:
+            return 0
+
+        # optionally prompt for removal
+        clean_size = 0
+        if self.controller(rm_size, key, files, "binary package"):
+            for file_ in rm_files:
+                # ... try to delete it.
+                try:
+                    statinfo = os.stat(file_)
+                    os.unlink(file_)
+                    # only count size if successfully deleted and not a link
+                    if statinfo.st_nlink == 1:
+                        clean_size += statinfo.st_size
+                        try:
+                            os.rmdir(os.path.dirname(file_))
+                        except OSError:
+                            pass
+                except OSError as er:
+                    print(pp.error("Could not delete " + file_), 
file=sys.stderr)
+                    print(pp.error("Error: %s" % str(er)), file=sys.stderr)
+        return clean_size
+
+    def _clean_files(self, files, key):
         """File removal function."""
         clean_size = 0
         for file_ in files:
@@ -141,7 +215,7 @@ class CleanUp:
                         file=sys.stderr,
                     )
                     print(pp.error("Error: %s" % str(er)), file=sys.stderr)
-            if self.controller(statinfo.st_size, key, file_, file_type):
+            if self.controller(statinfo.st_size, key, file_, "file"):
                 # ... try to delete it.
                 try:
                     os.unlink(file_)

diff --git a/pym/gentoolkit/eclean/output.py b/pym/gentoolkit/eclean/output.py
index 04fb493..e02ccce 100644
--- a/pym/gentoolkit/eclean/output.py
+++ b/pym/gentoolkit/eclean/output.py
@@ -135,7 +135,10 @@ class OutputControl:
         """
         if not self.options["quiet"]:
             # pretty print mode
-            print(self.prettySize(size, True), self.pkg_color(key))
+            if file_type == "binary package" and clean_list[1] is not None:
+                print(self.prettySize(size, True), self.pkg_color(key), "(+ 
debug tarball)")
+            else:
+                print(self.prettySize(size, True), self.pkg_color(key))
         elif self.options["pretend"] or self.options["interactive"]:
             # file list mode
             if file_type == "checkout":

diff --git a/pym/gentoolkit/eclean/search.py b/pym/gentoolkit/eclean/search.py
index 0a51ce9..97b5f8d 100644
--- a/pym/gentoolkit/eclean/search.py
+++ b/pym/gentoolkit/eclean/search.py
@@ -38,6 +38,11 @@ DEPRECATED = pp.warn(deprecated_message)
 
 debug_modules = []
 
+# Location for debuginfo tarballs
+PACKDEBUG_PATH = "/usr/lib/debug/.tarball"
+# suffix (including extension) of debug tarballs
+PACKDEBUG_TARBALL_SUFFIX = "debug.tar.xz"
+
 
 def dprint(module, message):
     if module in debug_modules:
@@ -580,6 +585,24 @@ def _deps_equal(deps_a, eapi_a, deps_b, eapi_b, libc_deps, 
uselist=None, cpv=Non
     return deps_a == deps_b
 
 
+def _find_debuginfo_tarball(cpv: portage.versions._pkg_str, cp: str):
+    """
+    From a CPV, identify and check for a matching debuginfo tarball.
+
+    Returns None if no such file exists
+    """
+    pf = portage.catsplit(cpv)[1]
+    if cpv.build_id is None:
+        debuginfo_tarball = "-".join([pf, PACKDEBUG_TARBALL_SUFFIX])
+    else:
+        debuginfo_tarball = "-".join([pf, str(cpv.build_id), 
PACKDEBUG_TARBALL_SUFFIX])
+
+    debuginfo_path = os.path.join(PACKDEBUG_PATH, cp, debuginfo_tarball)
+    if not os.path.exists(debuginfo_path):
+        return None
+    return debuginfo_path
+
+
 def findPackages(
     options: dict[str, bool],
     exclude: Optional[dict] = None,
@@ -638,7 +661,7 @@ def findPackages(
 
     # Dictionary of binary packages to clean. Organized as cpv->[pkgs] in order
     # to support FEATURES=binpkg-multi-instance.
-    dead_binpkgs: dict[str, list[str]] = {}
+    dead_binpkgs: dict[str, tuple[str, str | None]] = {}
     keep_binpkgs = {}
 
     def mk_binpkg_key(cpv):
@@ -683,7 +706,8 @@ def findPackages(
 
                 binpkg_key = mk_binpkg_key(drop_cpv)
                 binpkg_path = bin_dbapi.bintree.getname(drop_cpv)
-                dead_binpkgs.setdefault(binpkg_key, []).append(binpkg_path)
+                debuginfo_path = _find_debuginfo_tarball(drop_cpv, cp)
+                dead_binpkgs[binpkg_key] = (binpkg_path, debuginfo_path)
 
                 if new_time < old_time:
                     continue
@@ -727,7 +751,8 @@ def findPackages(
             del keep_binpkgs[cpv_key]
 
         binpkg_path = bin_dbapi.bintree.getname(cpv)
-        dead_binpkgs.setdefault(binpkg_key, []).append(binpkg_path)
+        debuginfo_path = _find_debuginfo_tarball(cpv, cp)
+        dead_binpkgs[binpkg_key] = (binpkg_path, debuginfo_path)
     try:
         invalid_paths = bin_dbapi.bintree.invalid_paths
     except AttributeError:

Reply via email to