{"type":"blog","id":"2026/application-sync-homebrew","title":"How I sync applications between Macs using Homebrew","url":"/blog/2026/application-sync-homebrew","summary":"How I attempted to sync applications between Macs using Homebrew and Brewfiles.","date":"2026-09-11","tags":["Homebrew","macOS","Productivity"],"categories":["tech"],"body":"For a while, I have been using the [`brew bundle`](https://docs.brew.sh/Brew-Bundle-and-Brewfile) command to create a bundle file of everything installed with Homebrew. Then, by checking it into version control, I can use `brew bundle` on the other computer to install everything. Often I don't want to have every formula on both machines. For example, I don't want to install some audio applications on my laptop that I only use on my desktop Mac.\n\nIt's also worth noting that the resulting Brewfile from a `brew bundle` command can contain installation candidates for Mac App Store apps, WinGet packages on WSL, VS Code extensions, Go packages, Cargo packages, npm packages, uv tools, Flatpak packages and krew kubectl plugins. This means it's a good way to keep a list of all sorts of things in sync.\n\nWhat I wanted was a way to specify a core list of items to install, and then specify which items to install only on a certain computer. This led me to think about \"tagging\" formulas with something like a machine identifier, and I posted in [the Homebrew GitHub discussions](https://github.com/orgs/Homebrew/discussions/6291) for ideas. This wasn't possible, but if you didn't know, Brew formulas and bundle files use Ruby as the domain-specific language (DSL), so in theory, you can manipulate them programmatically to suit your needs, which is what I ended up doing. There's a little bit to make this work, so here's what I implemented and what I changed as I tested it.\n\n## First the `Brewfile`s\n\nOne of the great aspects of how `brew bundle` works is that it only installs a formula if it's missing or out of date on a machine. Installing the same formula repeatedly is not a problem. This is a feature I have also often found useful when setting up a new machine and running many install commands. If something fails, you restart the process, and Homebrew picks up where it left off.\n\nFor my setup, I needed three Brewfiles:\n\n- _Brewfile.miniChinch_: Formulas just for my Mac mini. The suffix matches the machine's hostname.\n- _Brewfile.ChinchPro_: Formulas just for my MacBook Pro. The suffix matches the machine’s hostname.\n- _Brewfile.common_: Formulas shared between the two machines\n\nSplitting out the dependencies between files takes time, but because of how the bundle command works, as I mentioned above, it doesn't matter in the meantime.\n\nThe `brew bundle` command won't recognise any of these files. It only works with a `Brewfile`, and that is where we use Ruby's underpinnings.\n\n```ruby\n# path/to/Brewfile\ndef load_brewfile(name)\n  path = File.join(__dir__, name)\n  instance_eval(File.read(path), path) if File.exist?(path)\nend\n\nload_brewfile(\"Brewfile.common\")\n\nhostname = `hostname -s`.strip\nload_brewfile(\"Brewfile.#{hostname}\")\n```\n\nThis loads _Brewfile.common_ first, then loads any other Brewfile that matches the hostname, so on each machine you end up with a combination of the two Brewfiles returned to the `brew bundle` command.\n\n## Keeping the `Brewfile`s in sync\n\nSo far so good, but I didn't want to change my daily experience installing and uninstalling formula, so I needed two scripts to be able to continue this.\n\n- One to check when installed formulas drifted from the Brewfile.\n- One to regularly sync the various `Brewfile`s between the two machines using git.\n\nThis is the sync script, which runs a git pull, installs any changes, and displays a notification.\n\n```sh\n#!/bin/zsh\nset -e\n\n# launchd runs this as a non-interactive login shell, which never sources\n# ~/.zshrc — so Homebrew's PATH entry (set there) isn't present. Set it\n# explicitly rather than depending on shell-rc sourcing.\nexport PATH=\"/opt/homebrew/bin:/opt/homebrew/sbin:$PATH\"\n\ncd ~/path/to/home-folder/homebrew\n\n# Ensure repo is up to date \ngit pull --rebase --autostash\n# Install any changes to the Homebrew file\nbrew bundle install --file=./Brewfile\n# Display a notification\nosascript -e 'display notification \"Brew bundle synced\" with title \"brew-sync\"' 2>/dev/null || true\n```\n\nThe drift, or triage, script is more complex. Again, it updates from GitHub, parsing the Brewfiles to find items installed locally that aren’t in a Brewfile, and then asking which Brewfile to add them to.\n\n```sh\n#!/usr/bin/env zsh\n# ~/path/to/script/brew-triage.sh\nset -e\n\n# launchd runs this as a non-interactive login shell, which never sources\n# ~/.zshrc — so Homebrew's PATH entry (set there) isn't present. Set it\n# explicitly rather than depending on shell-rc sourcing.\nexport PATH=\"/opt/homebrew/bin:/opt/homebrew/sbin:$PATH\"\n\n# Move to brew files\nREPO=~/path/to/home-folder\nBREWDIR=\"$REPO/homebrew\"\n\n# Built from $REPO, before cd'ing into $BREWDIR: the glob below is resolved\n# relative to the current directory, and must match repo-root-relative paths\n# since that's what `git add`/`git status` expect later.\ncd \"$REPO\"\nBREW_PATHS=(homebrew/Brewfile homebrew/Brewfile.*(N))\n\ncd \"$BREWDIR\"\n\n# Pull first so not committing on top of stale history\ngit -C \"$REPO\" pull --rebase --autostash\n\n# --- triage step (same as before) ---\nbrew bundle dump --file=/tmp/Brewfile.current --force\n\ncat Brewfile.common Brewfile.*(N) 2>/dev/null | grep -E '^(brew|cask) ' | sort -u > /tmp/tracked.txt\ngrep -E '^(brew|cask) ' /tmp/Brewfile.current | sort -u > /tmp/current.txt\ncomm -23 /tmp/current.txt /tmp/tracked.txt > /tmp/untracked.txt\n\ntypeset -a additions   # track what we add, for the commit message\n\nif [[ -s /tmp/untracked.txt ]]; then\n  echo \"Untracked packages found:\"\n  while read -r line <&3; do\n    echo \"  $line\"\n    # Ask where to add untracked package\n    read \"target?  → which file (common/ChinchPro/miniChinch/skip)? \"\n    [[ \"$target\" == \"skip\" ]] && continue\n    echo \"$line\" >> \"Brewfile.$target\"\n    additions+=(\"$line -> Brewfile.$target\")\n  done 3< /tmp/untracked.txt\nelse\n  echo \"Nothing untracked.\"\nfi\n\n# --- commit + push, scoped ONLY to the Brewfile paths ---\ncd \"$REPO\"\n\nif [[ -n $(git status --porcelain -- \"${BREW_PATHS[@]}\") ]]; then\n  git add -- \"${BREW_PATHS[@]}\"\n\n  if [[ ${#additions[@]} -gt 0 ]]; then\n    msg=\"brew: add $(IFS=, ; echo \"${additions[*]}\")\"\n  else\n    msg=\"brew: sync Brewfile changes from $(hostname -s)\"\n  fi\n\n  git commit -m \"$msg\" -- \"${BREW_PATHS[@]}\"\n  git push\n  echo \"→ committed and pushed: $msg\"\nelse\n  echo \"No Brewfile changes to commit.\"\nfi\n```\n\nGreat, but remembering to manually run two scripts doesn't help much. Enter the wonderful and much-underused world (on macOS, at least) of [launchd](https://en.wikipedia.org/wiki/Launchd). Typically added by macOS and 3rd-party apps, launchd lets you run a script periodically in the background, generally without user interaction. Less known is that you can also add your own scripts to do whatever you want.\n\nFor these scripts, the code is fairly simple.\n\nThe sync launchd script, named _com.chrischinchilla.brewsync.plist_, which you should update to match your own name, runs every day at 11 am. It also logs errors to the _/tmp/_ directory.\n\n```xml\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n<dict>\n  <key>Label</key><string>com.chrischinchilla.brewsync</string>\n  <key>ProgramArguments</key>\n  <array>\n    <string>/bin/zsh</string>\n    <string>-lc</string>\n    <string>~/path/to/script/brew-sync.sh</string>\n  </array>\n  <key>StartCalendarInterval</key>\n  <dict><key>Hour</key><integer>11</integer><key>Minute</key><integer>0</integer></dict>\n  <key>StandardOutPath</key><string>/tmp/brewsync.log</string>\n  <key>StandardErrorPath</key><string>/tmp/brewsync.log</string>\n</dict>\n</plist>\n```\n\nThe triage launchd script, with the file name _com.chrischinchilla.brewtriage.plist_ runs every day at 3pm:\n\n```xml\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n<dict>\n  <key>Label</key><string>com.chrischinchilla.brewtriage</string>\n  <key>ProgramArguments</key>\n  <array>\n    <string>/bin/zsh</string>\n    <string>-lc</string>\n    <string>~/Workspace/home-folder/bin/brew-triage.sh</string>\n  </array>\n  <key>StartCalendarInterval</key>\n  <dict><key>Hour</key><integer>15</integer><key>Minute</key><integer>0</integer></dict>\n  <key>StandardOutPath</key><string>/tmp/brewtriage.log</string>\n  <key>StandardErrorPath</key><string>/tmp/brewtriage.log</string>\n</dict>\n</plist>\n```\n\nMove both of these files to _~/Library/LaunchAgents_, macOS uses the terms LaunchAgents and LaunchDaemons interchangeably, but typically LaunchAgents are used for user-level scripts. Logging out and logging back in, or restarting, is enough to get the scripts running, or if you want to do it manually, you can use:\n\n```sh\nlaunchctl bootstrap gui/$(id -u) <Script>\n```\n\n## Does it work?\n\nAfter some trial and error, the process does work as expected. The biggest annoyance is that launchd only runs LaunchAgents when the machine is awake. I am not too fussed if they don't run every day. A couple of times a week is enough, and as it requires human input to select the Brewfile location anyway, I think it is enough to keep the machines in reasonable sync, but it is easy to forget it hasn't run for a while and thus defeat the whole of the exercise in the first place. I have found myself running the scripts manually instead, which is a reasonable workaround. It's not perfect, but it's getting there.","links":[],"fields":{}}