Skip to content

Instantly share code, notes, and snippets.

@SteveBenner
Last active March 15, 2025 20:27
Show Gist options
  • Save SteveBenner/11254428 to your computer and use it in GitHub Desktop.
Save SteveBenner/11254428 to your computer and use it in GitHub Desktop.
Homebrew uninstall script
#!/usr/bin/env ruby
#
# CLI tool for locating and removing a Homebrew installation
# It replaces the official uninstaller, which is insufficient and often breaks
# If files were removed, the script returns 0; otherwise it returns 1
#
# http://brew.sh/
#
# Copyright (C) 2025 Stephen C. Benner
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Author: Stephen Benner
# https://github.com/SteveBenner
#
# Contributors:
# - @AaronKulick
# - @lloeki
# - @lewismc
#
# NOTE: This script has ONLY been tested and verified for the following operating systems:
# - macOS 10.9
# - macOS 15.1
#
# TODO/ROADMAP
# - v1.3: test deletion code, make sure it can ALWAYS remove
# - v1.4: find and remove daemons installed by brew
require 'optparse'
require 'fileutils'
require 'pathname'
require 'open3'
$stdout.sync = true
# Default options
options = {
:quiet => false,
:verbose => true,
:dry_run => false,
:force => false,
:find_path => false
}
$opts = options # Provide access to the options within methods
optparser = OptionParser.new do |opts|
opts.banner = <<~STR
The unofficial Homebrew uninstaller that actually works!
Example usage (run the script with one of the following flags after the filename, such as `ruby unbrew.rb -v`):
STR
opts.on('-q', '--quiet', 'Quiet mode: suppress output.') do |setting|
options[:quiet] = setting
options[:verbose] = false
end
opts.on('-v', '--verbose', 'Verbose mode: print all operations') { |setting| options[:verbose] = setting }
opts.on('-d', '--dry', 'Dry run: print results, but perform no actual operations') do |setting|
options[:dry_run] = setting
end
opts.on('-f', '--force', 'Force removal of files, bypassing all prompts (USE WITH CAUTION!)') do |setting|
options[:force] = setting
end
opts.on('-p', '--find-path', 'Output Homebrew location if found, then exit') do |setting|
options[:find_path] = setting
options[:quiet] = true
end
opts.on_tail('-h', '--help', '--usage', 'Display this message') { puts opts; exit false }
opts.on_tail('--version', 'Display script version') { puts opts.version; exit false }
end
optparser.version = '1.2'
optparser.summary_width = 16
optparser.parse!
# Files installed into the Homebrew repository
BREW_LOCAL_FILES = %w[
.git
Cellar
Library/brew.rb
Library/Homebrew
Library/Aliases
Library/Formula
Library/Contributions
Library/LinkedKegs
]
# Files that Homebrew installs into other system locations
BREW_SYSTEM_FILES = %W[
#{ENV['HOME']}/Library/Caches/Homebrew
#{ENV['HOME']}/Library/Logs/Homebrew
/Library/Caches/Homebrew
]
$files = []
# Runs the given command in a sub-shell, expecting the output to be the
# path of a Homebrew installation. If a block is provided, it passes the shell output to
# the block for processing, using the block's return value as the new path.
# Known Homebrew files are then scanned for and added to the file list. The directory
# is tested for a Homebrew installation, and the git index is added if a
# valid repository is found. The function stops running once a Homebrew installation is
# found but accumulates untracked Homebrew files with each invocation.
#
# @param [String] cmd The shell command to execute.
# @yield [path] Passes the output from the shell command to the block.
# @yieldparam [String] path The path returned by the shell command.
# @yieldreturn [String] The processed brew prefix path.
# @return [void]
#
def locate_brew_path(cmd)
return if $brew_prefix # Stop testing if we find a valid Homebrew installation
unless $opts[:quiet]
msg = "Searching for Homebrew installation using '#{cmd}'..."
msg << " This MAY take a while, and will require your password." if cmd.include? 'locate'
msg << " This WILL take a while." if cmd.include? 'find'
puts msg
end
# Update the locate database manually if necessary (if it doesn't exist or is more than 5 minutes out of date)
if cmd.include? 'locate' # Suppress errors
locate_db = '/var/db/locate.database'
if File.exist?(locate_db) && (Time.now - File.mtime(locate_db) > 60 * 5)
system 'sudo /usr/libexec/locate.updatedb 2>/dev/null'
elsif !File.exist?(locate_db)
system 'sudo /usr/libexec/locate.updatedb 2>/dev/null'
end
end
# Run given shell command ALONG with any code passed-in via a block (returns on error/empty result)
begin
path = `#{cmd}`.chomp
return if path.empty?
path = yield(path) if block_given? # Pass the command output to your own fancy code block
rescue Errno::ENOENT
return
end
# Warn user if multiple installation directories are located
puts path
if path.split("\n").count > 1
puts 'WARNING: Multiple Homebrew locations found on your system!'
puts 'It is HIGHLY recommended to rerun this script until all installations are removed.'
end
begin
Dir.chdir(path) do
# Search for known Homebrew files and folders, regardless of git presence
$files += BREW_LOCAL_FILES.select { |file| File.exist? file }.map {|file| File.expand_path file }
$files += Dir.glob('**/{man,bin}/**/brew*')
# Test for Homebrew git repository (using open3 to suppress git error output)
repo_name, status = Open3.capture2 'git remote -v'
# Presence of the Homebrew repo indicates we are in the proper directory
if repo_name =~ /homebrew.git|Homebrew/
$brew_prefix = path
else
return
end
end
rescue StandardError # On normal errors, continue the program
return
end
end
# Attempt to locate Homebrew installation using a command and optional code block
# for processing the command results. Locating a valid path halts searching.
locate_brew_path 'brew --prefix'
locate_brew_path('which brew') { |output| File.expand_path('../..', output) }
locate_brew_path('command -v brew') { |output| File.expand_path('../..', output) }
# Fallback 1: Update the local `locate` database and search files using `locate`
cmd = options[:verbose] ? 'locate bin/brew' : 'locate bin/brew 2>/dev/null'
locate_brew_path cmd do |output| # ... And here
# Limit results to the first located path
Pathname.new(output.split("\n").find { |path| File.basename(path) == 'brew' }).parent.parent.to_s
end
# Fallback 2: Use `find` to search files (very slow!)
cmd = options[:verbose] ?
'find / \( -path /System -o -path /private -o -path /dev \) -prune -o -type f -name brew -print' :
'find / \( -path /System -o -path /private -o -path /dev \) -prune -o -type f -name brew -print 2>/dev/null'
locate_brew_path cmd do |output| # Suppress errors
# Limit results to the first located path
filtered_path = output.split("\n")
.grep(%r{/bin/brew\z})
.reject { |p| p.start_with?("/System") } # Don't look here (for a good reason)
.first
Pathname.new(filtered_path).parent.parent.to_s
end
# Found Homebrew installation
if $brew_prefix
# TODO: call `brew uninstall` for all installed formulae and casks
if options[:find_path]
puts $brew_prefix
exit
end
unless options[:quiet]
puts "Homebrew found at: #{$brew_prefix}"
# Record kegs and taps for later output
brewed = `#{$brew_prefix}/bin/brew list`
tapped = `#{$brew_prefix}/bin/brew tap`
end
# Collect files indexed by git
begin
Dir.chdir($brew_prefix) do
# Update file list (use popen3 so we can suppress git error output)
Open3.popen3('git checkout master') { |stdin, stdout, stderr| stderr.close }
$files += `git ls-files`.split.map {|file| File.expand_path file }
end
rescue StandardError => e
puts e # Report any errors, but continue the script and collect any last files
end
else
puts 'No Homebrew location was found.' unless options[:quiet]
end
# Collect any files Homebrew may have installed throughout our system
puts 'Detecting vestigial system files...' unless options[:quiet]
$files += BREW_SYSTEM_FILES.select { |file| File.exist? file }
if $files.empty?
abort 'Failed to locate any Homebrew files!' if $files.empty?
else # We found files to remove!
# DESTROY! DESTROY! DESTROY!
unless options[:force]
puts "Delete #{$files.count} files? [y/n]"
abort unless gets.chomp =~ /y/i
end
rm =
if options[:dry_run]
lambda { |entry| puts "deleting #{entry}" unless options[:quiet] }
else
lambda { |entry| FileUtils.rm_rf(entry, :verbose => options[:verbose]) }
end
puts 'Deleting files...' unless options[:quiet]
$files.each &rm
# Print a list of formulae and kegs that were removed as part of the uninstallation process
unless brewed.to_s.empty?
puts
puts 'The following previously installed formulae were removed:'
puts brewed
end
unless tapped.to_s.empty?
puts
puts 'The following previously tapped kegs were removed:'
puts tapped
end
exit
end
@SteveBenner
Copy link
Author

@Roboji, @claudia1204, @cmfrtblynmb728, @techartist, @binchuri

The output for all of your cases is similar, in that I can see a couple different possible bugs causing the same kind of issue for all of you.

There are several fixes I am going to implement, they are documented at the top of the script. It will take some time to test, but I’m confident I will soon know why it broke.

It would be helpful for those experiencing hangups using unbrew to kill it with CTL-C or similar, and report any errors that show up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment