Skip to content

Instantly share code, notes, and snippets.

@sachaos
Last active May 22, 2026 14:43
Show Gist options
  • Select an option

  • Save sachaos/5051526b621cde57565892df03cff85a to your computer and use it in GitHub Desktop.

Select an option

Save sachaos/5051526b621cde57565892df03cff85a to your computer and use it in GitHub Desktop.
Homebrew rename + handover: silent swap reproduction (todoist-cli case study)

Homebrew rename + handover: silent swap reproduction

Conclusion

Existing users who do NOT run brew upgrade between the rename PR merge and the new-formula PR merge will be silently swapped to the new vendor's package on their next brew upgrade — no warning, no confirmation. Reproduced on a real machine.

Conversely, existing users who DO run brew upgrade during the rename window are protected: the rename mechanism moves their Cellar directory to the new name, so the later handover doesn't touch them.

So the practical function of a "migration window" is not "time for users to notice the rename" — it is "time for users' Cellars to be physically renamed via brew upgrade, before the original name starts pointing at a different package."

Design

Reproduces the #283506 + follow-up structure at a small scale. All names use a sample- prefix to avoid colliding with real formulae.

Role Real PR name Experiment name
sachaos's Go CLI (existing) todoist-cli sample-todoist-cli v1.0.0
sachaos's renamed CLI todoist-cli-go sample-todoist-cli-go v2.0.0
Doist's official CLI (handover) todoist-cli sample-todoist-cli v9.0.0

Two operational patterns are tested independently. Each scenario uses its own self-hosted tap and is fully torn down between runs, so both can reuse the same formula name (sample-todoist-cli).

Scenario Tap Pattern
A sachaos/sample-immediately-swap Take over the formula name directly: replace sachaos's package in place with Doist's, no rename transition
B sachaos/sample-migration-period Rename sachaos's to -go first; later, ship Doist's under the original name

Each formula's install block writes an identity string to bin/sample-todoist-marker. A single dummy tarball is shared as the source.

Prerequisites

  • macOS or Linux with brew working.
  • Uses your existing Homebrew installation; does not touch homebrew-core.
  • Homebrew's Cellar (<prefix>/Cellar/<name>/) and bin/ symlinks are a flat namespace across taps, so two taps cannot install a formula of the same name concurrently. This experiment runs scenarios sequentially and tears down between them to avoid that collision.
  • Works under Nix-managed Homebrew too.

Common env + dummy tarball

Run once before any scenario. Also resets any leftover state from a previous run, so it is safe to re-run.

export HOMEBREW_NO_INSTALL_FROM_API=1
export HOMEBREW_NO_AUTO_UPDATE=1
export TAPS_ROOT="$(brew --prefix)/Library/Taps/sachaos"
export SRC=/tmp/brew-rename-experiment/src.tar.gz

# Clean up any residue from a previous run (no-op on a fresh machine)
brew uninstall --force \
  sachaos/sample-immediately-swap/sample-todoist-cli \
  sachaos/sample-migration-period/sample-todoist-cli \
  sachaos/sample-migration-period/sample-todoist-cli-go 2>/dev/null || true
rm -rf "$TAPS_ROOT/homebrew-sample-immediately-swap" \
       "$TAPS_ROOT/homebrew-sample-migration-period" \
       /tmp/brew-rename-experiment

# Build a dummy source tarball
mkdir -p /tmp/brew-rename-experiment/src
echo "dummy" > /tmp/brew-rename-experiment/src/SOURCE
tar czf "$SRC" -C /tmp/brew-rename-experiment src
export SRC_SHA=$(shasum -a 256 "$SRC" | awk '{print $1}')
echo "SRC_SHA=$SRC_SHA"

Scenario A: silent swap (sachaos/sample-immediately-swap)

No rename window. The sample-todoist-cli formula is replaced in place by Doist's version.

A-1. Create the tap

export TAP="$TAPS_ROOT/homebrew-sample-immediately-swap"
rm -rf "$TAP"
mkdir -p "$TAP/Formula"
cd "$TAP"
git init -q -b main
git config user.email "t@e.com" && git config user.name "t"

# Initial state: sachaos's version (v1)
cat > Formula/sample-todoist-cli.rb <<RUBY
class SampleTodoistCli < Formula
  desc "sachaos Go CLI (sample)"
  homepage "https://example.com/sachaos"
  url "file://$SRC"
  version "1.0.0"
  sha256 "$SRC_SHA"
  def install
    (bin/"sample-todoist-marker").write "FROM_SACHAOS v#{version}\n"
  end
end
RUBY
git add -A && git commit -q -m "v1: sachaos"
git branch v1

# Later state: same formula name, replaced with Doist's version (no rename)
cat > Formula/sample-todoist-cli.rb <<RUBY
class SampleTodoistCli < Formula
  desc "Doist official CLI (sample)"
  homepage "https://example.com/doist"
  url "file://$SRC"
  version "9.0.0"
  sha256 "$SRC_SHA"
  def install
    (bin/"sample-todoist-marker").write "FROM_DOIST v#{version}\n"
  end
end
RUBY
git add -A && git commit -q -m "main: doist (silent swap)"

A-2. Run the scenario

Note: bare brew upgrade upgrades every outdated formula on your system. That's intentional here — it mirrors what a real user actually runs. If you'd rather not touch unrelated packages, scope it to brew upgrade sachaos/sample-immediately-swap/sample-todoist-cli; the outcome is the same.

# 1. Tap at v1, install sachaos's version
cd "$TAP" && git checkout -q v1
brew install --formula sachaos/sample-immediately-swap/sample-todoist-cli
cat "$(brew --prefix)/bin/sample-todoist-marker"
# => FROM_SACHAOS v1.0.0

# 2. Time passes; tap is replaced with Doist's version
cd "$TAP" && git checkout -q main

# 3. User runs brew upgrade (the normal, no-args invocation)
brew upgrade
# Output includes:
#   ==> Upgrading N outdated packages:
#   sachaos/sample-immediately-swap/sample-todoist-cli 1.0.0 -> 9.0.0
# No warning. No confirmation.

cat "$(brew --prefix)/bin/sample-todoist-marker"
# => FROM_DOIST v9.0.0   <- silent swap complete

A-3. Teardown

brew uninstall --force sachaos/sample-immediately-swap/sample-todoist-cli 2>/dev/null || true
rm -rf "$TAP"
unset TAP

Scenario B: migration period (sachaos/sample-migration-period)

Rename sample-todoist-cli to sample-todoist-cli-go first, then later perform the handover where Doist's sample-todoist-cli re-appears.

B-1. Create the tap

export TAP="$TAPS_ROOT/homebrew-sample-migration-period"
rm -rf "$TAP"
mkdir -p "$TAP/Formula"
cd "$TAP"
git init -q -b main
git config user.email "t@e.com" && git config user.name "t"

# v1: sachaos's version
cat > Formula/sample-todoist-cli.rb <<RUBY
class SampleTodoistCli < Formula
  desc "sachaos Go CLI (sample)"
  homepage "https://example.com/sachaos"
  url "file://$SRC"
  version "1.0.0"
  sha256 "$SRC_SHA"
  def install
    (bin/"sample-todoist-marker").write "FROM_SACHAOS v#{version}\n"
  end
end
RUBY
git add -A && git commit -q -m "v1: sachaos"
git branch v1

# v2: rename sample-todoist-cli -> sample-todoist-cli-go + formula_renames entry
# (git rm would also delete the now-empty Formula/ dir, so use plain rm)
rm -f Formula/sample-todoist-cli.rb
cat > Formula/sample-todoist-cli-go.rb <<RUBY
class SampleTodoistCliGo < Formula
  desc "sachaos Go CLI renamed (sample)"
  homepage "https://example.com/sachaos"
  url "file://$SRC"
  version "2.0.0"
  sha256 "$SRC_SHA"
  def install
    (bin/"sample-todoist-marker").write "FROM_SACHAOS_RENAMED v#{version}\n"
  end
end
RUBY
echo '{"sample-todoist-cli":"sample-todoist-cli-go"}' > formula_renames.json
git add -A && git commit -q -m "v2: rename sample-todoist-cli to sample-todoist-cli-go"
git branch v2

# main: handover (drop rename entry + reintroduce sample-todoist-cli as Doist's version)
cat > Formula/sample-todoist-cli.rb <<RUBY
class SampleTodoistCli < Formula
  desc "Doist official CLI (sample)"
  homepage "https://example.com/doist"
  url "file://$SRC"
  version "9.0.0"
  sha256 "$SRC_SHA"
  def install
    (bin/"sample-todoist-marker").write "FROM_DOIST v#{version}\n"
  end
end
RUBY
echo '{}' > formula_renames.json
git add -A && git commit -q -m "main: handover"

B-2. Run the scenario

Note: bare brew upgrade upgrades every outdated formula on your system. That's intentional here — it mirrors what a real user actually runs. If you'd rather not touch unrelated packages, scope it to brew upgrade sachaos/sample-migration-period/sample-todoist-cli using the old name (rename resolution makes the result the same).

# 1. Tap at v1, install sachaos's version
cd "$TAP" && git checkout -q v1
brew install --formula sachaos/sample-migration-period/sample-todoist-cli
cat "$(brew --prefix)/bin/sample-todoist-marker"
# => FROM_SACHAOS v1.0.0

# 2. Time passes; rename is applied (v2)
cd "$TAP" && git checkout -q v2

# 3. User runs brew upgrade during the rename window
brew upgrade
# Output includes:
#   ==> Migrating formula sample-todoist-cli to sample-todoist-cli-go
#   ==> Moving sample-todoist-cli versions to <prefix>/Cellar/sample-todoist-cli-go

cat "$(brew --prefix)/bin/sample-todoist-marker"
# => FROM_SACHAOS_RENAMED v2.0.0

# 4. More time passes; handover happens (main)
cd "$TAP" && git checkout -q main

# 5. User runs brew upgrade again
brew upgrade
cat "$(brew --prefix)/bin/sample-todoist-marker"
# => FROM_SACHAOS_RENAMED v2.0.0   <- unchanged, protected

brew list --versions sachaos/sample-migration-period/sample-todoist-cli      # => (nothing)
brew list --versions sachaos/sample-migration-period/sample-todoist-cli-go   # => sample-todoist-cli-go 2.0.0

B-3. Teardown

brew uninstall --force \
  sachaos/sample-migration-period/sample-todoist-cli \
  sachaos/sample-migration-period/sample-todoist-cli-go 2>/dev/null || true
rm -rf "$TAP"
unset TAP

Bonus: what if the user skips the rename window?

To demonstrate that a user who exists during the rename window but never runs brew upgrade is NOT protected by Scenario B's tap structure:

# Re-create tap B as in B-1, then:
cd "$TAP" && git checkout -q v1
brew install --formula sachaos/sample-migration-period/sample-todoist-cli
# marker => FROM_SACHAOS v1.0.0

# Skip v2 entirely; fast-forward to handover
cd "$TAP" && git checkout -q main

brew upgrade
# Output:
#   ==> Upgrading 1 outdated package:
#   sachaos/sample-migration-period/sample-todoist-cli 1.0.0 -> 9.0.0

cat "$(brew --prefix)/bin/sample-todoist-marker"
# => FROM_DOIST v9.0.0   <- silent swap, exactly as Scenario A

So the migration window only protects users who happen to run brew upgrade during it. Users who don't get silent-swapped on their first post-handover upgrade.

Final cleanup

brew uninstall --force \
  sachaos/sample-immediately-swap/sample-todoist-cli \
  sachaos/sample-migration-period/sample-todoist-cli \
  sachaos/sample-migration-period/sample-todoist-cli-go 2>/dev/null || true
rm -rf "$TAPS_ROOT/homebrew-sample-immediately-swap" \
       "$TAPS_ROOT/homebrew-sample-migration-period" \
       /tmp/brew-rename-experiment
unset HOMEBREW_NO_INSTALL_FROM_API HOMEBREW_NO_AUTO_UPDATE TAPS_ROOT SRC SRC_SHA TAP

Outcome matrix

User behavior Final marker Outcome
Scenario A (no rename window) → brew upgrade after Doist lands FROM_DOIST v9.0.0 Silent swap to Doist's package
Scenario B → brew upgrade during rename window → brew upgrade after handover FROM_SACHAOS_RENAMED v2.0.0 Protected: stays on sachaos's renamed version
Scenario B → skip rename window → brew upgrade after handover FROM_DOIST v9.0.0 Silent swap (window did not protect this user)
Never run brew upgrade FROM_SACHAOS v1.0.0 Stays on the original sachaos version locally; no swap, but no updates either

Facts established by the experiment

  1. brew upgrade triggers automatic migration via the rename mechanism — Scenario B output ==> Migrating formula sample-todoist-cli to sample-todoist-cli-go proves this.
  2. Users who upgrade during the rename window are insulated from the later handover — Scenario B's final marker remains FROM_SACHAOS_RENAMED even after handover.
  3. Users who skip the rename window are silently swapped to the new vendor's package on their next upgrade — Scenarios A and the Bonus section demonstrate this.
  4. Homebrew does not detect "the source URL or vendor of this formula changed"; outdated detection is by formula name and version only.

This is by design, not a Homebrew bug. The migration window matters precisely because it widens the time during which existing users can be auto-migrated to the new name via brew upgrade, before the original name starts pointing at a different package.

Related Homebrew internals

  • Library/Homebrew/upgrade.rb:73 calls Migrator.migrate_if_needed for every formula being upgraded
  • Library/Homebrew/migrator.rb:129 (migrate_if_needed) checks whether the old-name Cellar directory exists and, if so, moves it to the new name automatically
  • formula_renames.json (at the homebrew-core root, or at the root of any tap) is the source of truth for old-name → new-name mappings
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment