Skip to content

[BUG] bm update reports "up to date" when the Homebrew check fails (false negative pins users to an old version) #1297

Description

@groksrc

Bug Description

_check_homebrew_update_available() in src/basic_memory/cli/auto_update.py decides "outdated or not" purely from brew outdated's stdout and discards result.returncode entirely. Every failure mode of brew outdated produces the same shape — non-zero exit, empty stdout, message on stderr — which the function reads as "not outdated". The caller then reports AutoUpdateStatus.UP_TO_DATE and prints:

Basic Memory is up to date (0.21.5).

A hard failure to determine the latest version is reported to the user as success. For an update checker this is the worst failure mode available: it silently pins Homebrew users to whatever version they happen to have, and the tool actively tells them there is nothing to do.

The code

src/basic_memory/cli/auto_update.py:

def _check_homebrew_update_available(silent: bool) -> tuple[bool, str | None]:
    """Check whether Homebrew reports an outdated basic-memory formula."""
    result = _run_subprocess(
        ["brew", "outdated", "--quiet", PACKAGE_NAME],
        timeout_seconds=BREW_OUTDATED_TIMEOUT_SECONDS,
        silent=silent,
        capture_output=True,
    )
    # Trigger: brew outdated exits 1 when the formula IS outdated (with name on stdout).
    # Why: non-zero exit here means "outdated", not "error".
    # Outcome: check stdout for the package name to determine outdated status.
    stdout = (result.stdout or "").strip()
    is_outdated = PACKAGE_NAME in stdout
    return is_outdated, None

The comment is correct that exit 1 can mean "outdated" — but it is used to justify ignoring the return code altogether, so the error case and the up-to-date case become indistinguishable. There are three real outcomes (up to date / outdated / could-not-determine) collapsed into two.

run_auto_update() then does:

if not update_available:
    return AutoUpdateResult(status=AutoUpdateStatus.UP_TO_DATE, ...)

so "brew blew up" lands in the up-to-date branch.

Steps To Reproduce

Observed on macOS with a Homebrew install from the project's own tap (basicmachines-co/homebrew-basic-memory).

  1. Install via Homebrew. Installed version: 0.21.5 (Jun 1). The tap formula has pointed at v0.22.1 since tap commit dated 2026-06-13.
  2. Put brew outdated into any failing state (see below).
  3. Run bm update.
  4. Output: Basic Memory is up to date (0.21.5). — for ~2.5 months, while an upgrade was available the whole time.

The specific trigger seen in the wild was newer Homebrew's third-party tap trust requirement:

$ brew outdated --quiet basic-memory
Error: Refusing to load formula basicmachines-co/basic-memory/basic-memory from untrusted tap basicmachines-co/basic-memory.
$ echo $?
1

exit 1, stdout empty, message on stderr. PACKAGE_NAME in stdout is False → "up to date".

The untrusted tap is only one trigger. Any brew outdated failure has the identical shape. Reproduced live on the same machine with a formula brew cannot resolve:

$ out=$(brew outdated --quiet basic-memory-nope 2>/tmp/e.txt); echo "exit=$?"; echo "stdout=[$out]"; echo "stderr=[$(cat /tmp/e.txt)]"
exit=1
stdout=[]
stderr=[Error: No available formula with the name "basic-memory-nope". ...]

The same shape is produced by: an untapped/removed tap, a stale or broken tap checkout, a network failure during brew update, brew not on PATH (FileNotFoundError), or the 60s timeout expiring. All of them currently print "up to date".

For contrast, the working case on that machine after the tap was trusted:

$ brew outdated --quiet basic-memory
basicmachines-co/basic-memory/basic-memory
$ echo $?
1

exit 1 with the name on stdout — the case the existing tests cover.

Expected Behavior

When the update check cannot be completed, say so. Never claim the user is current based on a failed check. Surface brew's stderr, and/or fall back to a check that can actually answer the question.

Actual Behavior

Basic Memory is up to date (<installed version>).

Secondary defect on the same path

_check_homebrew_update_available() returns latest_version=None unconditionally. So even when it correctly detects an outdated formula, the message is:

Update available (latest: unknown). Run `brew upgrade basic-memory`.

Scope

  • Homebrew installs only. The uv-tool path is unaffected: it goes through _check_pypi_update_available(), which fetches the PyPI version and does a real Version comparison, raising on failure so the error reaches the FAILED branch. Confirmed empirically — a second machine on a uv-tool install tracked 0.22.1 correctly while the Homebrew machine sat on 0.21.5 reporting itself up to date.
  • Affects both bm update and the periodic check in maybe_run_periodic_auto_update().

Environment

  • OS: macOS 15 (darwin 25.5.0), Apple Silicon
  • Basic Memory version: 0.21.5 (Homebrew), diagnosis verified against current main
  • Installation method: Homebrew, basicmachines-co/basic-memory tap

Possible Solution

Distinguish three outcomes instead of two:

  • returncode == 0 and stdout empty → up to date
  • PACKAGE_NAME in stdout → outdated
  • anything else (non-zero exit with empty stdout, stderr carrying an error, timeout, FileNotFoundError for brew) → unknown, not "up to date"

For the unknown case, falling back to _check_pypi_update_available() gives a real answer and a real latest_version, fixing the "latest: unknown" nit — as long as the remediation hint still says brew upgrade basic-memory for Homebrew installs. If the fallback is also unavailable, report FAILED with brew's stderr rather than a false all-clear.

Happy to send a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions