diff --git a/src/caelestia/subcommands/update.py b/src/caelestia/subcommands/update.py index cadd432..49fa761 100644 --- a/src/caelestia/subcommands/update.py +++ b/src/caelestia/subcommands/update.py @@ -192,6 +192,7 @@ class Command: self, installer: PackageInstaller, source: DotsSource, current: dict[str, list[str]], desired: list[str] ) -> dict[str, list[str]]: to_build = [p for p in desired if p not in current] + to_rebuild = self.outdated_local_packages(installer, source, current, desired) to_remove = [p for p in current if p not in desired] installed = dict(current) @@ -200,6 +201,11 @@ class Command: log(f"Building new local packages: {', '.join(to_build)}") installed.update(build_local_packages(installer, source, to_build)) + if to_rebuild: + print() + log(f"Rebuilding updated local packages: {', '.join(to_rebuild)}") + installed.update(build_local_packages(installer, source, to_rebuild)) + if to_remove: print() info(f"Local packages no longer required: {', '.join(to_remove)}") @@ -211,6 +217,29 @@ class Command: return installed + def outdated_local_packages( + self, installer: PackageInstaller, source: DotsSource, current: dict[str, list[str]], desired: list[str] + ) -> list[str]: + """Repo paths whose installed packages are older than what the repo would build (skipped when off Arch).""" + + outdated = [] + for path in desired: + if path not in current: + continue + + directory = source.working_path(path) + if not directory.is_dir(): + continue + + try: + if installer.needs_rebuild(directory, current[path]): + outdated.append(path) + except PackageError as e: + # Failed to read PKGBUILD, leave it as-is + warn(f"could not check {path} for updates, leaving as-is: {e}") + + return outdated + def summarize(self, changeset: Changeset, new_files: list[Path], revived_files: list[Path]) -> None: print() conflicts = len(new_files) + len(revived_files) diff --git a/src/caelestia/utils/dots/packages.py b/src/caelestia/utils/dots/packages.py index 45fef6c..fce9c80 100644 --- a/src/caelestia/utils/dots/packages.py +++ b/src/caelestia/utils/dots/packages.py @@ -24,6 +24,46 @@ def _try_run(cmd: list[str], error_msg: str, **kwargs) -> None: raise PackageError(error_msg) from e +def _read_srcinfo(directory: Path) -> dict[str, list[str]]: + """Run `makepkg --printsrcinfo` in `directory`, grouping each key to its list of values.""" + + try: + srcinfo = subprocess.check_output(["makepkg", "--printsrcinfo"], cwd=directory, text=True) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise PackageError(f"failed to read package metadata in {directory}") from e + + fields: dict[str, list[str]] = {} + for line in srcinfo.splitlines(): + key, sep, value = line.partition("=") + if not sep: + continue + fields.setdefault(key.strip(), []).append(value.strip()) + return fields + + +def _srcinfo_version(fields: dict[str, list[str]]) -> str | None: + """Build the `[epoch:]pkgver-pkgrel` version string from parsed .SRCINFO fields, or None if absent.""" + + pkgver = next(iter(fields.get("pkgver", [])), None) + pkgrel = next(iter(fields.get("pkgrel", [])), None) + if pkgver is None or pkgrel is None: + return None + + version = f"{pkgver}-{pkgrel}" + epoch = next(iter(fields.get("epoch", [])), None) + return f"{epoch}:{version}" if epoch else version + + +def _vercmp(a: str, b: str) -> int: + """Use pacman's `vercmp` to compare to package versions.""" + + try: + return int(subprocess.check_output(["vercmp", a, b], text=True).strip()) + except (subprocess.CalledProcessError, FileNotFoundError, ValueError) as e: + warn(f"vercmp failed, assuming equal: {e}") + return 0 # Don't rebuild when unable to check version + + def _install_aur_helper(helper: str, noconfirm: bool = False) -> None: pacman_cmd = ["sudo", "pacman", "-S", "--needed", "git", "base-devel"] if noconfirm: @@ -90,7 +130,15 @@ class PackageInstaller(ABC): """Build and install the PKGBUILD in `directory`, returning the installed package names.""" @abstractmethod - def is_installed(self, package: str) -> bool: ... + def installed_version(self, package: str) -> str | None: + """Return the installed version of `package`, or None if it is not installed.""" + + def is_installed(self, package: str) -> bool: + return self.installed_version(package) is not None + + @abstractmethod + def needs_rebuild(self, directory: Path, packages: list[str]) -> bool: + """Whether the PKGBUILD in `directory` would build a version differing from the installed `packages`.""" @abstractmethod def system_update(self) -> None: ... @@ -111,7 +159,10 @@ class NoopInstaller(PackageInstaller): info(f"Skipping local package build (not on Arch): {directory}") return [] - def is_installed(self, package: str) -> bool: + def installed_version(self, package: str) -> str | None: + return None + + def needs_rebuild(self, directory: Path, packages: list[str]) -> bool: return False def system_update(self) -> None: @@ -145,23 +196,9 @@ class ArchInstaller(PackageInstaller): _try_run([self.helper, "-Rns", *self.flags, *packages], f"failed to remove packages: {', '.join(packages)}") def build_install(self, directory: Path) -> list[str]: - try: - srcinfo = subprocess.check_output(["makepkg", "--printsrcinfo"], cwd=directory, text=True) - except (subprocess.CalledProcessError, FileNotFoundError) as e: - raise PackageError(f"failed to read package metadata in {directory}") from e - - names = [] - depends = [] - for line in srcinfo.splitlines(): - key, sep, value = line.partition("=") - if not sep: - continue - - key = key.strip() - if key == "pkgname": - names.append(value.strip()) - elif key == "depends": - depends.append(value.strip()) + fields = _read_srcinfo(directory) + names = fields.get("pkgname", []) + depends = fields.get("depends", []) self.install(depends, explicit=False) @@ -172,16 +209,37 @@ class ArchInstaller(PackageInstaller): ["makepkg", "-fsi", *self.flags], f"failed to build local package in {directory}", cwd=directory, env=env ) + # Clean build artifacts + for artifact in directory.glob("*.pkg.tar*"): + try: + artifact.unlink() + except OSError as e: + warn(f"failed to remove build artifact {artifact}: {e}") + return names - def is_installed(self, package: str) -> bool: - return ( - subprocess.run( - ["pacman", "-Q", package], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode - == 0 + def installed_version(self, package: str) -> str | None: + result = subprocess.run( + ["pacman", "-Q", package], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + if result.returncode != 0: + return None + # `pacman -Q` prints " " + parts = result.stdout.split() + return parts[1] if len(parts) >= 2 else None + + def needs_rebuild(self, directory: Path, packages: list[str]) -> bool: + built = _srcinfo_version(_read_srcinfo(directory)) + if built is None: + return False # Can't determine the source version, leave as is + + # Rebuild when installed version < repo version + # Don't rebuild packages that have been removed + return any( + (installed := self.installed_version(pkg)) is not None and _vercmp(built, installed) > 0 for pkg in packages ) def system_update(self) -> None: