modules/homebrew: add onActivation.cleanup "check" mode

Closes #1032

Add `"check"` to the `onActivation.cleanup` enum. When set, nix-darwin runs
`brew bundle cleanup` during system checks to detect Homebrew packages that
are installed but not present in the generated Brewfile. If extra packages
are found, activation fails with a list of them and remediation steps.

Unlike `"uninstall"` and `"zap"`, the `"check"` mode never removes packages
-- it only reports. This runs during both `darwin-rebuild check` and
`darwin-rebuild switch`, matching the behavior of all other system checks.
This commit is contained in:
Malo Bourgon 2026-02-10 01:46:53 -08:00
parent ca6f8609c3
commit c68f5d1387
No known key found for this signature in database
4 changed files with 66 additions and 9 deletions

View file

@ -76,29 +76,36 @@ let
onActivationOptions = { config, ... }: { onActivationOptions = { config, ... }: {
options = { options = {
cleanup = mkOption { cleanup = mkOption {
type = types.enum [ "none" "uninstall" "zap" ]; type = types.enum [ "none" "check" "uninstall" "zap" ];
default = "none"; default = "none";
example = "uninstall"; example = "uninstall";
description = '' description = ''
This option manages what happens to formulae installed by Homebrew, that aren't present in This option manages what happens to packages installed by Homebrew that aren't present in
the Brewfile generated by this module, during {command}`nix-darwin` system the Brewfile generated by this module, during {command}`nix-darwin` system
activation. activation.
When set to `"none"` (the default), formulae not present in the generated When set to `"none"` (the default), packages not present in the generated
Brewfile are left installed. Brewfile are left installed.
When set to `"check"`, {command}`nix-darwin` verifies during system activation that no
Homebrew packages (taps, formulae, casks, etc.) are installed that aren't present in the
generated Brewfile. If extra packages are found, activation fails with a list of them.
Note that when this check fails during {command}`darwin-rebuild switch`, the entire
system activation is aborted and no other configuration changes will be applied until
the issue is resolved.
When set to `"uninstall"`, {command}`nix-darwin` invokes When set to `"uninstall"`, {command}`nix-darwin` invokes
{command}`brew bundle [install]` with the {command}`--cleanup` flag. This {command}`brew bundle [install]` with the {command}`--cleanup` flag. This
uninstalls all formulae not listed in generated Brewfile, i.e., uninstalls all packages not listed in the generated Brewfile, i.e.,
{command}`brew uninstall` is run for those formulae. {command}`brew uninstall` is run for those packages.
When set to `"zap"`, {command}`nix-darwin` invokes When set to `"zap"`, {command}`nix-darwin` invokes
{command}`brew bundle [install]` with the {command}`--cleanup --zap` {command}`brew bundle [install]` with the {command}`--cleanup --zap`
flags. This uninstalls all formulae not listed in the generated Brewfile, and if the flags. This uninstalls all packages not listed in the generated Brewfile, and if the
formula is a cask, removes all files associated with that cask. In other words, package is a cask, removes all files associated with that cask. In other words,
{command}`brew uninstall --zap` is run for all those formulae. {command}`brew uninstall --zap` is run for all those packages.
If you plan on exclusively using {command}`nix-darwin` to manage formulae If you plan on exclusively using {command}`nix-darwin` to manage packages
installed by Homebrew, you probably want to set this option to installed by Homebrew, you probably want to set this option to
`"uninstall"` or `"zap"`. `"uninstall"` or `"zap"`.
''; '';
@ -879,6 +886,33 @@ in
''; '';
}; };
system.checks.text = mkIf (cfg.enable && cfg.onActivation.cleanup == "check") ''
if [ -f "${cfg.prefix}/bin/brew" ]; then
homebrewCleanupExitCode=0
homebrewCleanupResult=$(PATH="${cfg.prefix}/bin:${lib.makeBinPath [ pkgs.mas ]}:$PATH" \
sudo \
--preserve-env=PATH \
--user=${escapeShellArg cfg.user} \
--set-home \
env HOMEBREW_NO_AUTO_UPDATE=1 \
brew bundle cleanup --file='${brewfileFile}' 2>&1) || homebrewCleanupExitCode=$?
if [ "$homebrewCleanupExitCode" -eq 1 ]; then
printf >&2 '\e[1;31merror: found Homebrew packages not listed in the Brewfile, aborting activation\e[0m\n'
printf >&2 '%s\n' "$homebrewCleanupResult"
printf >&2 '\n'
printf >&2 'To fix this, either:\n'
printf >&2 ' - Add the listed packages to your nix-darwin Homebrew configuration\n'
printf >&2 ' - Remove them by running: brew bundle cleanup --force\n'
printf >&2 ' - Set homebrew.onActivation.cleanup to "uninstall" or "zap"\n'
exit 2
elif [ "$homebrewCleanupExitCode" -ne 0 ]; then
printf >&2 '\e[1;31merror: brew bundle cleanup failed, aborting activation\e[0m\n'
printf >&2 '%s\n' "$homebrewCleanupResult"
exit 2
fi
fi
'';
system.activationScripts.homebrew.text = mkIf cfg.enable '' system.activationScripts.homebrew.text = mkIf cfg.enable ''
# Homebrew Bundle # Homebrew Bundle
echo >&2 "Homebrew bundle..." echo >&2 "Homebrew bundle..."

View file

@ -83,6 +83,7 @@ in {
tests.environment-path = makeTest ./tests/environment-path.nix; tests.environment-path = makeTest ./tests/environment-path.nix;
tests.environment-terminfo = makeTest ./tests/environment-terminfo.nix; tests.environment-terminfo = makeTest ./tests/environment-terminfo.nix;
tests.homebrew = makeTest ./tests/homebrew.nix; tests.homebrew = makeTest ./tests/homebrew.nix;
tests.homebrew-cleanup-check = makeTest ./tests/homebrew-cleanup-check.nix;
tests.homebrew-shell-integration = makeTest ./tests/homebrew-shell-integration.nix; tests.homebrew-shell-integration = makeTest ./tests/homebrew-shell-integration.nix;
tests.launchd-daemons = makeTest ./tests/launchd-daemons.nix; tests.launchd-daemons = makeTest ./tests/launchd-daemons.nix;
tests.launchd-setenv = makeTest ./tests/launchd-setenv.nix; tests.launchd-setenv = makeTest ./tests/launchd-setenv.nix;

View file

@ -0,0 +1,19 @@
{ config, ... }:
{
homebrew.enable = true;
homebrew.user = "test-homebrew-user";
homebrew.onActivation.cleanup = "check";
test = ''
echo "checking that cleanup check is present in system checks" >&2
grep 'brew bundle cleanup --file=' ${config.out}/activate
echo "checking that brew bundle command does not have --cleanup flag" >&2
if echo "${config.homebrew.onActivation.brewBundleCmd}" | grep -F -- '--cleanup' > /dev/null; then
echo "Expected no --cleanup flag in brewBundleCmd"
echo "Actual: ${config.homebrew.onActivation.brewBundleCmd}"
exit 1
fi
'';
}

View file

@ -128,5 +128,8 @@ in
echo "checking that shell integration is absent by default" >&2 echo "checking that shell integration is absent by default" >&2
(! grep 'brew shellenv' ${config.out}/etc/zshrc) (! grep 'brew shellenv' ${config.out}/etc/zshrc)
echo "checking that cleanup check is absent by default" >&2
(! grep 'brew bundle cleanup --file=' ${config.out}/activate)
''; '';
} }