Last active
March 15, 2025 20:27
-
-
Save SteveBenner/11254428 to your computer and use it in GitHub Desktop.
Homebrew uninstall script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@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 withCTL-C
or similar, and report any errors that show up.