|
|
|
|
@@ -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)
|
|
|
|
|
|