Update for bundles

This commit is contained in:
David Tomaschik
2026-04-07 16:02:49 -07:00
parent 41f8a49381
commit 37c765ae29
5 changed files with 202 additions and 82 deletions

View File

@@ -9,8 +9,9 @@ fi
UPDATE_SCRIPT="bin/macos/update_brewfile" UPDATE_SCRIPT="bin/macos/update_brewfile"
if [[ -x "$UPDATE_SCRIPT" ]]; then if [[ -x "$UPDATE_SCRIPT" ]]; then
# Run in dry-run mode and see if there's output echo "🔍 Checking Brewfile synchronization..."
DIFF_OUTPUT=$("$UPDATE_SCRIPT" --dry-run 2>/dev/null) # Run in dry-run mode with --add-only and see if there's output
DIFF_OUTPUT=$("$UPDATE_SCRIPT" --dry-run --add-only 2>/dev/null)
if [[ "$DIFF_OUTPUT" == *"Changes detected"* ]]; then if [[ "$DIFF_OUTPUT" == *"Changes detected"* ]]; then
echo "⚠️ Brewfile is out of sync with your installed packages." echo "⚠️ Brewfile is out of sync with your installed packages."
echo " Run '$UPDATE_SCRIPT' to synchronize it." echo " Run '$UPDATE_SCRIPT' to synchronize it."

View File

@@ -7,16 +7,69 @@ import sys
import argparse import argparse
import difflib import difflib
import tempfile
# Regex to match brew/cask/tap/mas lines # Regex to match brew/cask/tap/mas lines
PKG_RE = re.compile(r'^\s*(brew|cask|tap|mas)\s+["\']([^"\']+)["\'](.*)$') PKG_RE = re.compile(r'^\s*(brew|cask|tap|mas)\s+["\']([^"\']+)["\'](.*)$')
def colorize_diff(lines):
for line in lines:
if line.startswith('+') and not line.startswith('+++'):
yield f"\033[32m{line}\033[0m"
elif line.startswith('-') and not line.startswith('---'):
yield f"\033[31m{line}\033[0m"
elif line.startswith('^'):
yield f"\033[36m{line}\033[0m"
else:
yield line
class Entry:
def sort_key(self): raise NotImplementedError()
def to_lines(self): raise NotImplementedError()
class PackageEntry(Entry):
def __init__(self, pkg_type, name, options, comments=None):
self.pkg_type = pkg_type
self.name = name
self.options = options.strip()
self.comments = comments or []
def sort_key(self):
order = {'tap': 0, 'brew': 1, 'cask': 2, 'mas': 3}
return (order.get(self.pkg_type, 4), self.name)
def to_lines(self):
res = list(self.comments)
pkg_line = f'{self.pkg_type} "{self.name}"'
if self.options:
if not self.options.startswith(','):
pkg_line += ' '
pkg_line += self.options
res.append(pkg_line)
return res
class TextEntry(Entry):
def __init__(self, lines, is_header=True):
self.lines = lines
self.is_header = is_header
def sort_key(self):
# Header is -1, Trailing is 5 (after all package types 0-4)
return (-1 if self.is_header else 5, "")
def to_lines(self):
return self.lines
def get_repo_root(): def get_repo_root():
script_dir = os.path.dirname(os.path.realpath(__file__))
try: try:
root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'],
cwd=script_dir,
stderr=subprocess.STDOUT).decode().strip() stderr=subprocess.STDOUT).decode().strip()
return root return root
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return os.getcwd() # If not in a git repo, go up 2 levels from script_dir (bin/macos/)
return os.path.abspath(os.path.join(script_dir, '..', '..'))
def get_ignore_list(repo_root): def get_ignore_list(repo_root):
ignore = set() ignore = set()
@@ -34,11 +87,19 @@ def get_ignore_list(repo_root):
ignore.add(line) ignore.add(line)
return ignore return ignore
def get_current_packages(): def get_current_packages(args):
"""Runs brew bundle dump and returns lines.""" """Runs brew bundle dump and returns lines."""
env = os.environ.copy()
env["HOMEBREW_NO_AUTO_UPDATE"] = "1"
env["HOMEBREW_NO_INSTALL_CLEANUP"] = "1"
env["HOMEBREW_NO_ENV_HINTS"] = "1"
cmd = ['brew', 'bundle', 'dump', '--file=-']
if args.no_mas: cmd.append('--no-mas')
if args.no_vscode: cmd.append('--no-vscode')
try: try:
output = subprocess.check_output(['brew', 'bundle', 'dump', '--file=-'], output = subprocess.check_output(cmd, env=env, stderr=subprocess.DEVNULL).decode()
stderr=subprocess.DEVNULL).decode()
return output.splitlines() return output.splitlines()
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
print("Error: 'brew bundle dump' failed. Is homebrew installed?", file=sys.stderr) print("Error: 'brew bundle dump' failed. Is homebrew installed?", file=sys.stderr)
@@ -48,9 +109,9 @@ def parse_brewfile(content):
""" """
Parses Brewfile content. Parses Brewfile content.
Returns: Returns:
- unconditional_lines: list of strings - entries: list of Entry objects
- conditional_pkgs: set of (type, name) - conditional_pkgs: set of (type, name)
- preserved_footer: string (everything from first conditional onwards) - footer: string (everything from first conditional onwards)
""" """
lines = content.splitlines() lines = content.splitlines()
conditional_pkgs = set() conditional_pkgs = set()
@@ -78,14 +139,35 @@ def parse_brewfile(content):
if stripped == 'end' or stripped.endswith('; end'): if stripped == 'end' or stripped.endswith('; end'):
in_conditional -= 1 in_conditional -= 1
if first_conditional_idx == -1: unconditional_lines = lines[:first_conditional_idx] if first_conditional_idx != -1 else lines
return lines, set(), "" footer = "\n".join(lines[first_conditional_idx:]) if first_conditional_idx != -1 else ""
unconditional_lines = lines[:first_conditional_idx] entries = []
footer = "\n".join(lines[first_conditional_idx:]) comment_buffer = []
return unconditional_lines, conditional_pkgs, footer first_pkg_seen = False
for line in unconditional_lines:
match = PKG_RE.match(line)
if match:
if not first_pkg_seen:
if comment_buffer:
entries.append(TextEntry(comment_buffer, is_header=True))
comment_buffer = []
first_pkg_seen = True
entries.append(PackageEntry(match.group(1), match.group(2), match.group(3), comment_buffer))
comment_buffer = []
else:
comment_buffer.append(line)
if comment_buffer:
entries.append(TextEntry(comment_buffer, is_header=False))
return entries, conditional_pkgs, footer
def main(args): def main(args):
if sys.platform != "darwin":
print(f"Warning: Running on {sys.platform}. Brewfile is primarily for macOS.", file=sys.stderr)
repo_root = get_repo_root() repo_root = get_repo_root()
brewfile_path = os.path.join(repo_root, 'Brewfile') brewfile_path = os.path.join(repo_root, 'Brewfile')
@@ -96,66 +178,84 @@ def main(args):
with open(brewfile_path) as f: with open(brewfile_path) as f:
old_content = f.read() old_content = f.read()
unconditional_lines, conditional_pkgs, footer = parse_brewfile(old_content) old_entries, conditional_pkgs, footer = parse_brewfile(old_content)
ignore_list = get_ignore_list(repo_root) ignore_list = get_ignore_list(repo_root)
dumped_lines = get_current_packages() old_pkg_map = {}
for e in old_entries:
if isinstance(e, PackageEntry):
key = (e.pkg_type, e.name)
if key not in old_pkg_map:
old_pkg_map[key] = e
new_unconditional_lines = [] dumped_lines = get_current_packages(args)
seen_pkgs = set() dumped_pkgs = []
for line in dumped_lines:
match = PKG_RE.match(line)
if match:
pkg_type, pkg_name, pkg_options = match.group(1), match.group(2), match.group(3)
if pkg_name in ignore_list or (pkg_type, pkg_name) in conditional_pkgs:
continue
dumped_pkgs.append(PackageEntry(pkg_type, pkg_name, pkg_options))
new_entries = []
for e in old_entries:
if isinstance(e, TextEntry) and e.is_header:
new_entries.append(e)
seen_in_new = set()
added_count = 0
removed_count = 0
merged_count = 0
if args.add_only: if args.add_only:
# First, keep existing unconditional packages for e in old_entries:
for line in unconditional_lines: if isinstance(e, PackageEntry):
match = PKG_RE.match(line) new_entries.append(e)
if match: seen_in_new.add((e.pkg_type, e.name))
pkg_type, pkg_name = match.group(1), match.group(2) for d in dumped_pkgs:
if pkg_name in ignore_list or (pkg_type, pkg_name) in conditional_pkgs: if (d.pkg_type, d.name) not in seen_in_new:
continue new_entries.append(d)
new_unconditional_lines.append(line) seen_in_new.add((d.pkg_type, d.name))
seen_pkgs.add((pkg_type, pkg_name)) added_count += 1
elif line.strip() and not line.strip().startswith('#'):
# Keep other non-comment lines
new_unconditional_lines.append(line)
# Then, add new packages from dump
for line in dumped_lines:
match = PKG_RE.match(line)
if match:
pkg_type, pkg_name = match.group(1), match.group(2)
if (pkg_type, pkg_name) not in seen_pkgs and pkg_name not in ignore_list and (pkg_type, pkg_name) not in conditional_pkgs:
new_unconditional_lines.append(line)
seen_pkgs.add((pkg_type, pkg_name))
else: else:
for line in dumped_lines: for d in dumped_pkgs:
match = PKG_RE.match(line) key = (d.pkg_type, d.name)
if match: if key in seen_in_new: continue
pkg_type, pkg_name = match.group(1), match.group(2) if key in old_pkg_map:
if pkg_name in ignore_list: merged = old_pkg_map[key]
continue if not merged.options:
if (pkg_type, pkg_name) in conditional_pkgs: merged.options = d.options
continue new_entries.append(merged)
if (pkg_type, pkg_name) in seen_pkgs: merged_count += 1
continue else:
seen_pkgs.add((pkg_type, pkg_name)) new_entries.append(d)
added_count += 1
seen_in_new.add(key)
# If it's not a package line (e.g. comment from dump), we can skip or keep # Check for removals
if line.strip(): for key in old_pkg_map:
new_unconditional_lines.append(line) if key not in seen_in_new:
removed_count += 1
# Sort lines by type (tap, brew, cask, mas) then name for e in old_entries:
def sort_key(line): if isinstance(e, TextEntry) and not e.is_header:
match = PKG_RE.match(line) new_entries.append(e)
if not match: return (4, line)
order = {'tap': 0, 'brew': 1, 'cask': 2, 'mas': 3}
return (order.get(match.group(1), 4), match.group(2))
new_unconditional_lines.sort(key=sort_key) new_entries.sort(key=lambda x: x.sort_key())
# Build new content output_lines = []
new_content = "\n".join(new_unconditional_lines) last_type = None
for e in new_entries:
if isinstance(e, PackageEntry):
if last_type and e.pkg_type != last_type:
output_lines.append("")
last_type = e.pkg_type
output_lines.extend(e.to_lines())
new_content = "\n".join(output_lines)
if footer: if footer:
if new_unconditional_lines: if output_lines and output_lines[-1].strip():
new_content += "\n\n" new_content += "\n\n"
new_content += footer.strip() + "\n" new_content += footer.strip() + "\n"
else: else:
@@ -166,21 +266,33 @@ def main(args):
else: else:
if args.dry_run: if args.dry_run:
print("Changes detected (dry run):") print("Changes detected (dry run):")
diff = difflib.unified_diff( diff = list(difflib.unified_diff(
old_content.splitlines(keepends=True), old_content.splitlines(keepends=True),
new_content.splitlines(keepends=True), new_content.splitlines(keepends=True),
fromfile='Brewfile (original)', fromfile='Brewfile (original)',
tofile='Brewfile (new)' tofile='Brewfile (new)'
) ))
sys.stdout.writelines(diff) if sys.stdout.isatty():
sys.stdout.writelines(colorize_diff(diff))
else:
sys.stdout.writelines(diff)
else: else:
with open(brewfile_path, 'w') as f: dir_name = os.path.dirname(brewfile_path)
f.write(new_content) with tempfile.NamedTemporaryFile('w', dir=dir_name, delete=False) as tf:
tf.write(new_content)
tempname = tf.name
os.replace(tempname, brewfile_path)
print("Brewfile updated.") print("Brewfile updated.")
if args.verbose:
print(f"Summary: {added_count} added, {removed_count} removed, {merged_count} kept/merged.")
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Update Brewfile while preserving conditionals.") parser = argparse.ArgumentParser(description="Update Brewfile while preserving conditionals.")
parser.add_argument("--dry-run", action="store_true", help="Show changes without applying them.") parser.add_argument("--dry-run", action="store_true", help="Show changes without applying them.")
parser.add_argument("--add-only", action="store_true", help="Only add missing entries, do not remove existing ones.") parser.add_argument("--add-only", action="store_true", help="Only add missing entries, do not remove existing ones.")
parser.add_argument("--verbose", "-v", action="store_true", help="Print summary of changes.")
parser.add_argument("--no-mas", action="store_true", help="Do not include Mac App Store apps.")
parser.add_argument("--no-vscode", action="store_true", help="Do not include VSCode extensions.")
args = parser.parse_args() args = parser.parse_args()
main(args) main(args)

View File

@@ -30,5 +30,8 @@ if status --is-interactive
install_fisher install_fisher
end end
# Want this at the bottom to put this path first # Want these at the bottom to put them first in PATH
fish_add_path --move --path {$HOME}/bin fish_add_path --move --path {$HOME}/bin
if test (uname) = "Darwin"
fish_add_path --move --path {$HOME}/bin/macos
end

View File

@@ -189,6 +189,7 @@ if [ "$(uname)" = "Darwin" ] ; then
# Using id -u for better POSIX compatibility than $UID # Using id -u for better POSIX compatibility than $UID
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$TMPDIR/runtime-$(id -u)}" export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$TMPDIR/runtime-$(id -u)}"
export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
export PATH="${HOME}/bin/macos:${PATH}"
fi fi
if test -e "$HOME/.localenv"; then if test -e "$HOME/.localenv"; then

View File

@@ -271,8 +271,11 @@ if [ -x /usr/bin/ack-grep ] ; then
alias ack='/usr/bin/ack-grep' alias ack='/usr/bin/ack-grep'
fi fi
# I want this first always # I want these first always
PATH="${HOME}/bin:${PATH}" PATH="${HOME}/bin:${PATH}"
if [[ "$(uname)" == "Darwin" ]]; then
PATH="${HOME}/bin/macos:${PATH}"
fi
# Load any local settings # Load any local settings
source_if_existing $HOME/.zshrc.local source_if_existing $HOME/.zshrc.local