fix(install): install a repository once, by the first hook that supports it

The pipeline used to skip a second installer only when it advertised
capabilities the first had already provided, so a hook with a different
capability still ran on the very same project. file-dedupe was installed
through pip and then again through `make install`, whose first line upgrades
pip; PEP 668 refuses that, and the run went red on a repository that was
already installed correctly.

One hook now installs a repository and the pipeline stops there. The order of
INSTALLERS is the preference: `make install` leads, because it is the hook a
repository writes for itself, while the others infer an installation from a
manifest.

A hook that fails installed nothing, so the rule is one *successful*
installation rather than one attempt: _run_installer returns a bool instead of
re-raising, and the caller moves on to the next hook. To keep that fallback
from turning a broken repository into a silent pass, a repository whose hooks
all fail now exits with the list of what was tried.

The capability bookkeeping the old skip rule needed is gone from the pipeline;
discover_capabilities remains on BaseInstaller and is now unused there.

Verified: 14 targeted unit and integration tests, and a full `pkgmgr update
--all` sweep in the arch image with 0 installation failures. Delta measured at
the consumer: repositories left without a CLI entry point, 16 before and 16
after, diff identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-18 16:07:12 +02:00
1 parent e45b6ed4a5
commit e6ac35e705
4 files changed
+132 -37

No files matched your search

+2 -1
View File
@@ -38,13 +38,14 @@ from pkgmgr.core.repository.verify import verify_repository
Repository = dict[str, Any]
INSTALLERS = [
MakefileInstaller(),
ArchPkgbuildInstaller(),
DebianControlInstaller(),
RpmSpecInstaller(),
NixFlakeInstaller(),
PythonInstaller(),
MakefileInstaller(),
]
"""Preference order: the pipeline runs the first supported hook and stops."""
def _ensure_repo_dir(
+24 -18
View File
@@ -81,13 +81,15 @@ class InstallationPipeline:
else:
repo.pop("command", None)
provided_capabilities: set[str] = set()
attempted: list[str] = []
for installer in self._installers:
layer_name = getattr(installer, "layer", None)
if layer_name is None:
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
if self._run_installer(installer, ctx, identifier, repo_dir, quiet):
return
attempted.append(installer.__class__.__name__)
continue
try:
@@ -120,15 +122,6 @@ class InstallationPipeline:
if not installer.supports(ctx):
continue
caps = installer.discover_capabilities(ctx)
if caps and caps.issubset(provided_capabilities):
if not quiet:
print(
f"Skipping installer {installer.__class__.__name__} "
f"for {identifier} – capabilities {caps} already provided."
)
continue
if not quiet:
if (
ctx.force_update
@@ -142,13 +135,17 @@ class InstallationPipeline:
else:
print(
f"[pkgmgr] Running installer {installer.__class__.__name__} "
f"for {identifier} in '{repo_dir}' "
f"(new capabilities: {caps or set()})..."
f"for {identifier} in '{repo_dir}'..."
)
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
provided_capabilities.update(caps)
if not self._run_installer(installer, ctx, identifier, repo_dir, quiet):
attempted.append(installer.__class__.__name__)
if not quiet:
print(
f"[pkgmgr] {installer.__class__.__name__} installed nothing "
f"for {identifier}; falling back to the next hook."
)
continue
new_state = resolver.resolve()
if new_state.command:
@@ -165,6 +162,13 @@ class InstallationPipeline:
repo.pop("command", None)
state = new_state
return
if attempted:
raise SystemExit(
f"every installation hook failed for {identifier}: "
f"{', '.join(attempted)}"
)
@staticmethod
def _run_installer(
@@ -173,9 +177,11 @@ class InstallationPipeline:
identifier: str,
repo_dir: str,
quiet: bool,
) -> None:
) -> bool:
"""Run one hook. Returns whether it installed anything."""
try:
installer.run(ctx)
return True
except SystemExit as exc:
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
print(
@@ -193,4 +199,4 @@ class InstallationPipeline:
f" pkgmgr install {identifier} "
"--clone-mode shallow --no-verification"
)
raise
return False