diff --git a/.githooks/pre-commit.d/05-check-brewfile b/.githooks/pre-commit.d/05-check-brewfile index 2e51b81..9dbcf97 100755 --- a/.githooks/pre-commit.d/05-check-brewfile +++ b/.githooks/pre-commit.d/05-check-brewfile @@ -9,8 +9,9 @@ fi UPDATE_SCRIPT="bin/macos/update_brewfile" if [[ -x "$UPDATE_SCRIPT" ]]; then - # Run in dry-run mode and see if there's output - DIFF_OUTPUT=$("$UPDATE_SCRIPT" --dry-run 2>/dev/null) + echo "🔍 Checking Brewfile synchronization..." + # 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 echo "⚠️ Brewfile is out of sync with your installed packages." echo " Run '$UPDATE_SCRIPT' to synchronize it." diff --git a/bin/macos/update_brewfile b/bin/macos/update_brewfile index 30d8f98..a099df5 100755 --- a/bin/macos/update_brewfile +++ b/bin/macos/update_brewfile @@ -7,16 +7,69 @@ import sys import argparse import difflib +import tempfile + # Regex to match brew/cask/tap/mas lines 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(): + script_dir = os.path.dirname(os.path.realpath(__file__)) 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() return root 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): ignore = set() @@ -34,11 +87,19 @@ def get_ignore_list(repo_root): ignore.add(line) return ignore -def get_current_packages(): +def get_current_packages(args): """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: - output = subprocess.check_output(['brew', 'bundle', 'dump', '--file=-'], - stderr=subprocess.DEVNULL).decode() + output = subprocess.check_output(cmd, env=env, stderr=subprocess.DEVNULL).decode() return output.splitlines() except subprocess.CalledProcessError: print("Error: 'brew bundle dump' failed. Is homebrew installed?", file=sys.stderr) @@ -48,17 +109,17 @@ def parse_brewfile(content): """ Parses Brewfile content. Returns: - - unconditional_lines: list of strings + - entries: list of Entry objects - conditional_pkgs: set of (type, name) - - preserved_footer: string (everything from first conditional onwards) + - footer: string (everything from first conditional onwards) """ lines = content.splitlines() conditional_pkgs = set() - + # Find the start of the first conditional block first_conditional_idx = -1 in_conditional = 0 - + for i, line in enumerate(lines): stripped = line.strip() if stripped.startswith(('if ', 'unless ', 'case ', 'def ', 'begin ')) and not stripped.endswith('; end'): @@ -69,93 +130,132 @@ def parse_brewfile(content): j -= 1 first_conditional_idx = j + 1 in_conditional += 1 - + if in_conditional > 0: match = PKG_RE.match(line) if match: conditional_pkgs.add((match.group(1), match.group(2))) - + if stripped == 'end' or stripped.endswith('; end'): in_conditional -= 1 - if first_conditional_idx == -1: - return lines, set(), "" - - unconditional_lines = lines[:first_conditional_idx] - footer = "\n".join(lines[first_conditional_idx:]) - return unconditional_lines, conditional_pkgs, footer + unconditional_lines = lines[:first_conditional_idx] if first_conditional_idx != -1 else lines + footer = "\n".join(lines[first_conditional_idx:]) if first_conditional_idx != -1 else "" + + entries = [] + comment_buffer = [] + 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): + if sys.platform != "darwin": + print(f"Warning: Running on {sys.platform}. Brewfile is primarily for macOS.", file=sys.stderr) + repo_root = get_repo_root() brewfile_path = os.path.join(repo_root, 'Brewfile') - + if not os.path.exists(brewfile_path): print(f"Error: Brewfile not found at {brewfile_path}", file=sys.stderr) sys.exit(1) - + with open(brewfile_path) as f: 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) - - dumped_lines = get_current_packages() - - new_unconditional_lines = [] - seen_pkgs = set() + + 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 + + dumped_lines = get_current_packages(args) + 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: - # First, keep existing unconditional packages - for line in unconditional_lines: - match = PKG_RE.match(line) - if match: - pkg_type, pkg_name = match.group(1), match.group(2) - if pkg_name in ignore_list or (pkg_type, pkg_name) in conditional_pkgs: - continue - new_unconditional_lines.append(line) - seen_pkgs.add((pkg_type, pkg_name)) - 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)) + for e in old_entries: + if isinstance(e, PackageEntry): + new_entries.append(e) + seen_in_new.add((e.pkg_type, e.name)) + for d in dumped_pkgs: + if (d.pkg_type, d.name) not in seen_in_new: + new_entries.append(d) + seen_in_new.add((d.pkg_type, d.name)) + added_count += 1 else: - for line in dumped_lines: - match = PKG_RE.match(line) - if match: - pkg_type, pkg_name = match.group(1), match.group(2) - if pkg_name in ignore_list: - continue - if (pkg_type, pkg_name) in conditional_pkgs: - continue - if (pkg_type, pkg_name) in seen_pkgs: - continue - seen_pkgs.add((pkg_type, pkg_name)) - - # If it's not a package line (e.g. comment from dump), we can skip or keep - if line.strip(): - new_unconditional_lines.append(line) + for d in dumped_pkgs: + key = (d.pkg_type, d.name) + if key in seen_in_new: continue + if key in old_pkg_map: + merged = old_pkg_map[key] + if not merged.options: + merged.options = d.options + new_entries.append(merged) + merged_count += 1 + else: + new_entries.append(d) + added_count += 1 + seen_in_new.add(key) - # Sort lines by type (tap, brew, cask, mas) then name - def sort_key(line): - match = PKG_RE.match(line) - 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)) + # Check for removals + for key in old_pkg_map: + if key not in seen_in_new: + removed_count += 1 - new_unconditional_lines.sort(key=sort_key) - - # Build new content - new_content = "\n".join(new_unconditional_lines) + for e in old_entries: + if isinstance(e, TextEntry) and not e.is_header: + new_entries.append(e) + + new_entries.sort(key=lambda x: x.sort_key()) + + output_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 new_unconditional_lines: + if output_lines and output_lines[-1].strip(): new_content += "\n\n" new_content += footer.strip() + "\n" else: @@ -166,21 +266,33 @@ def main(args): else: if args.dry_run: print("Changes detected (dry run):") - diff = difflib.unified_diff( + diff = list(difflib.unified_diff( old_content.splitlines(keepends=True), new_content.splitlines(keepends=True), fromfile='Brewfile (original)', tofile='Brewfile (new)' - ) - sys.stdout.writelines(diff) + )) + if sys.stdout.isatty(): + sys.stdout.writelines(colorize_diff(diff)) + else: + sys.stdout.writelines(diff) else: - with open(brewfile_path, 'w') as f: - f.write(new_content) + dir_name = os.path.dirname(brewfile_path) + 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.") + if args.verbose: + print(f"Summary: {added_count} added, {removed_count} removed, {merged_count} kept/merged.") + if __name__ == "__main__": 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("--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() main(args) diff --git a/dotfiles/config/fish/config.fish b/dotfiles/config/fish/config.fish index f79a11a..787f7e8 100644 --- a/dotfiles/config/fish/config.fish +++ b/dotfiles/config/fish/config.fish @@ -30,5 +30,8 @@ if status --is-interactive install_fisher 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 +if test (uname) = "Darwin" + fish_add_path --move --path {$HOME}/bin/macos +end diff --git a/dotfiles/shenv b/dotfiles/shenv index 645bcb3..cdb0382 100755 --- a/dotfiles/shenv +++ b/dotfiles/shenv @@ -189,6 +189,7 @@ if [ "$(uname)" = "Darwin" ] ; then # Using id -u for better POSIX compatibility than $UID export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$TMPDIR/runtime-$(id -u)}" export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" + export PATH="${HOME}/bin/macos:${PATH}" fi if test -e "$HOME/.localenv"; then diff --git a/dotfiles/zshrc b/dotfiles/zshrc index dde5475..3dcdbde 100755 --- a/dotfiles/zshrc +++ b/dotfiles/zshrc @@ -271,8 +271,11 @@ if [ -x /usr/bin/ack-grep ] ; then alias ack='/usr/bin/ack-grep' fi -# I want this first always +# I want these first always PATH="${HOME}/bin:${PATH}" +if [[ "$(uname)" == "Darwin" ]]; then + PATH="${HOME}/bin/macos:${PATH}" +fi # Load any local settings source_if_existing $HOME/.zshrc.local