diff --git a/omnigent/update_check.py b/omnigent/update_check.py index 1d0b803ec6..747fcda17a 100644 --- a/omnigent/update_check.py +++ b/omnigent/update_check.py @@ -95,6 +95,9 @@ # ``direct_url.json`` in place of a URL's userinfo. See # ``_unredact_ssh_userinfo`` for why we have to repair it. _REDACTED_USERINFO = "****" +# Homebrew bottles record ``brew`` in ``INSTALLER``; the tap formula is the +# only upgrade source (pip into the Cellar is clobbered on the next bump). +_BREW_TAP_FORMULA = "omnigent-ai/tap/omnigent" @dataclass @@ -1391,12 +1394,13 @@ class _UpgradeSuggestion: Always populated. :param runnable: ``True`` when ``command`` is a real shell invocation we can execute via ``subprocess.run`` — - i.e. the installer is one of uv / pip / pipx / poetry + i.e. the installer is one of uv / pip / pipx / poetry / brew (or pip-as-fallback for an unknown installer with a known VCS URL). ``False`` for the unknown-installer prose - fallbacks (``"reinstall X from ..."``) which exist to be - read, not run; the interactive "run this now?" prompt is - suppressed in that case. + fallbacks (``"reinstall X from ..."``) and for requests the + installer cannot honor (a pinned version or added extras on a + Homebrew install), which exist to be read, not run; the + interactive "run this now?" prompt is suppressed in that case. """ command: str @@ -1468,7 +1472,7 @@ def _build_upgrade_suggestion( """Build the right upgrade command for the user's install shape. Picks based on ``detected_installer`` (uv / pip / pipx / poetry / - unknown) and whether ``direct_url.json`` recorded a VCS URL. + brew / unknown) and whether ``direct_url.json`` recorded a VCS URL. :param info: Metadata from ``_read_installed_wheel_info``. :param allow_prerelease: When ``True`` (``omni upgrade --pre``), append @@ -1529,6 +1533,28 @@ def _build_upgrade_suggestion( ) # Registry install — no VCS URL recorded. + if installer == "brew": + # `brew upgrade` takes neither a version pin nor extras, so refuse + # those rather than silently ignoring them. + if target_version: + return _UpgradeSuggestion( + command=( + f"upgrade {_DIST_NAME} with `brew upgrade {_BREW_TAP_FORMULA}` — Homebrew " + f"installs whatever version the tap currently carries and cannot pin " + f"{target_version}" + ), + runnable=False, + ) + if extras: + return _UpgradeSuggestion( + command=( + f"upgrade {_DIST_NAME} with `brew upgrade {_BREW_TAP_FORMULA}` — the formula " + f"bundles its own extras and cannot add {_extras_str(extras)}" + ), + runnable=False, + ) + return _UpgradeSuggestion(command=f"brew upgrade {_BREW_TAP_FORMULA}", runnable=True) + registry_spec = _package_spec(version=target_version, extras=extras) if installer == "uv": if target_version or extras: diff --git a/tests/cli/test_update_check.py b/tests/cli/test_update_check.py index 67098d0379..45c8693088 100644 --- a/tests/cli/test_update_check.py +++ b/tests/cli/test_update_check.py @@ -880,6 +880,8 @@ def test_read_wheel_info_handles_corrupt_direct_url( # poetry path — included for completeness; poetry is rare for # CLI tool installs but the format is documented. ("poetry", None, "poetry update omnigent", True), + # Homebrew bottle — upgrades through the tap, never pip into the keg. + ("brew", None, "brew upgrade omnigent-ai/tap/omnigent", True), # Unknown installer WITH a VCS URL — we know the source but # not the tool, so the suggestion is prose ("reinstall X from # "), not a command. Must be runnable=False so the @@ -1525,6 +1527,48 @@ def test_build_upgrade_suggestion_preserves_uv_tool_extras() -> None: ) +def test_build_upgrade_suggestion_brew_is_runnable() -> None: + """A Homebrew bottle upgrades through the tap, not through pip. + + The bottle records ``brew`` in ``INSTALLER`` and ships no + ``direct_url.json``, so it lands on the registry path. Before this branch + existed it fell through to the unknown-installer prose and ``omni + upgrade`` dead-ended with "No automatic upgrade command is known". + """ + suggestion = _build_upgrade_suggestion(_make_info("brew")) + assert suggestion.command == "brew upgrade omnigent-ai/tap/omnigent" + assert suggestion.runnable is True + assert "pip" not in suggestion.command + + +def test_build_upgrade_suggestion_brew_prerelease_has_no_flag() -> None: + """``--pre`` is a no-op for brew: ``brew upgrade`` takes no such flag.""" + assert ( + _build_upgrade_suggestion(_make_info("brew"), allow_prerelease=True).command + == "brew upgrade omnigent-ai/tap/omnigent" + ) + + +def test_build_upgrade_suggestion_brew_refuses_pinned_version() -> None: + """A pinned version on brew is refused, not silently dropped. + + ``brew upgrade`` installs whatever the tap carries, so a runnable command + here would land on a different version than ``--target-version`` asked for. + """ + suggestion = _build_upgrade_suggestion(_make_info("brew"), target_version="0.2.0") + assert suggestion.runnable is False + assert "0.2.0" in suggestion.command + assert "brew upgrade omnigent-ai/tap/omnigent" in suggestion.command + + +def test_build_upgrade_suggestion_brew_refuses_extras() -> None: + """Extras on brew are refused: the formula bundles its own set.""" + suggestion = _build_upgrade_suggestion(_make_info("brew", extras=("all",))) + assert suggestion.runnable is False + assert "[all]" in suggestion.command + assert "brew upgrade omnigent-ai/tap/omnigent" in suggestion.command + + def test_build_upgrade_suggestion_uv_target_version() -> None: """A pinned target version produces ``install --reinstall`` with the spec.""" assert (