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."
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.
- macOS or Linux with
brewworking. - Uses your existing Homebrew installation; does not touch
homebrew-core. - Homebrew's Cellar (
<prefix>/Cellar/<name>/) andbin/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.
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"No rename window. The sample-todoist-cli formula is replaced in place by Doist's version.
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)"Note: bare
brew upgradeupgrades 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 tobrew 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 completebrew uninstall --force sachaos/sample-immediately-swap/sample-todoist-cli 2>/dev/null || true
rm -rf "$TAP"
unset TAPRename sample-todoist-cli to sample-todoist-cli-go first, then later perform the handover where Doist's sample-todoist-cli re-appears.
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"Note: bare
brew upgradeupgrades 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 tobrew upgrade sachaos/sample-migration-period/sample-todoist-cliusing 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.0brew 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 TAPTo 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 ASo 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.
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| 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 |
brew upgradetriggers automatic migration via the rename mechanism — Scenario B output==> Migrating formula sample-todoist-cli to sample-todoist-cli-goproves this.- Users who upgrade during the rename window are insulated from the later handover — Scenario B's final marker remains
FROM_SACHAOS_RENAMEDeven after handover. - 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.
- 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.
Library/Homebrew/upgrade.rb:73callsMigrator.migrate_if_neededfor every formula being upgradedLibrary/Homebrew/migrator.rb:129(migrate_if_needed) checks whether the old-name Cellar directory exists and, if so, moves it to the new name automaticallyformula_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