Keep all your local clones up to date in the background, so you never cd into a stale checkout. A launchd agent fires on an interval and fast-forwards each repo, and pops a notification if any repo can't update.
Two versions:
- Simple — a tiny
git pull --ff-onlyloop. No dependencies beyond git. Start here. - gitup — uses
gitupto update every tracking branch, not just the checked-out one. More powerful, but fragile around git worktrees (see the warning).
Both rely on the same discipline: only edit inside a git worktree so your primary checkouts stay clean mirrors of origin. --ff-only (and gitup) refuse to clobber a dirty or diverged tree — they skip it. The flip side: a repo you've left dirty silently stops auto-updating until you clean it, so the notifier tells you when that happens.
A note on notifications (macOS Sonoma/Tahoe+). This guide uses plain
osascript -e 'display notification'because it's the only zero-dependency method that reliably delivers a banner. Two things people reach for that don't work well anymore:
terminal-notifier— the current Homebrew bottle is the 2017 build using the deprecatedNSUserNotificationAPI, which Notification Center silently drops on modern macOS. It returns exit 0 and shows nothing.- A custom
osacompileapplet to own the click — executes fine but ad-hoc-signed applets are unreliable notification sources on Tahoe (banner often suppressed), and every rebuild resets the permission.Tradeoff you can't avoid with
osascript: the banner is "owned" by Script Editor, so clicking it opens Script Editor. You can't redirect that click. Enable it once under System Settings → Notifications → Script Editor. To read the pull log, justopen -t ~/Library/Logs/dev-pull.log.
For each repo directly under ~/Developer, run git pull --ff-only on whatever branch is checked out. It never touches other branches, so it cannot crash on a worktree branch the way gitup does. If any repo fails to update, it pops a macOS notification listing them.
Save as ~/.local/bin/dev-pull.sh and chmod +x it:
#!/bin/zsh
# Auto-pull every git repo directly under ~/Developer.
# Minimal & crash-proof: fast-forward only, never touches worktree/feature branches.
# Notifies via macOS Notification Center if any repo couldn't update.
# launchd runs a bare non-interactive shell; ensure user bins are found.
export PATH="/opt/homebrew/bin:$HOME/.local/bin:$PATH"
failed=""
for d in "$HOME"/Developer/*/; do
git -C "$d" rev-parse --git-dir >/dev/null 2>/dev/null || continue
name="${${d%/}:t}"
echo "== $name"
if ! git -C "$d" pull --ff-only; then
echo "skipped $name"
failed="$failed $name"
fi
done
if [ -n "$failed" ]; then
# Banner is owned by Script Editor — enable it once under
# System Settings > Notifications > Script Editor. Clicking it opens
# Script Editor (osascript can't redirect that); to read the pull log:
# open -t ~/Library/Logs/dev-pull.log
repos="${failed# }"
osascript -e "display notification \"Can't pull: $repos\" with title \"Can't pull: $repos\" sound name \"Funk\""
fimkdir -p ~/Library/LaunchAgents ~/Library/Logs
cat > ~/Library/LaunchAgents/com.user.devpull.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.devpull</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>$HOME/.local/bin/dev-pull.sh</string>
</array>
<key>StartInterval</key>
<integer>1800</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>$HOME/Library/Logs/dev-pull.log</string>
<key>StandardErrorPath</key>
<string>$HOME/Library/Logs/dev-pull.log</string>
</dict>
</plist>
EOF
launchctl unload ~/Library/LaunchAgents/com.user.devpull.plist 2>/dev/null
launchctl load ~/Library/LaunchAgents/com.user.devpull.plistStartInterval is in seconds — 1800 = every 30 min. RunAtLoad triggers one run immediately.
Gotchas this avoids:
launchddoes not expand$HOMEat runtime — but the heredoc interpolates it at write-time, so the plist ends up with absolute paths.ProgramArgumentsis a clean two-element array (/bin/zsh+ the script path). Don't try to inline the whole loop as azsh -cstring in the plist — the>/&shell metachars are illegal in XML (&must be&) and will failplutil -lint. Keeping the logic in a script file sidesteps all of it.- The script sets its own
PATH, so it doesn't depend on.zshrc(which a non-interactive launchd shell won't source).
plutil -lint ~/Library/LaunchAgents/com.user.devpull.plist # should say OK
tail -f ~/Library/Logs/dev-pull.log
launchctl kickstart -k gui/$(id -u)/com.user.devpull # force a run nowlaunchctl unload ~/Library/LaunchAgents/com.user.devpull.plist # pause
launchctl load ~/Library/LaunchAgents/com.user.devpull.plist # resume
launchctl list | grep devpull # is it loaded?
launchctl kickstart -k gui/$(id -u)/com.user.devpull # run now
open -t ~/Library/Logs/dev-pull.log # read the loglaunchctl unload ~/Library/LaunchAgents/com.user.devpull.plist
rm ~/Library/LaunchAgents/com.user.devpull.plist
rm ~/.local/bin/dev-pull.shgitup fetches and fast-forwards every local branch that tracks a remote, across a set of bookmarked repos — not just the one checked out. Handy if you want all your tracking branches current, not only main.
gitup fast-forwards a tracking branch with git branch --force <branch> <upstream>. If that branch is checked out in a git worktree (e.g. Claude Code's .claude/worktrees/), git refuses (fatal: cannot force update the branch … used by worktree) and gitup raises an uncaught exception that aborts the whole run — so every repo after it (alphabetically) silently stops updating.
The fix is to run gitup once per repo so a crash is contained to that single repo instead of killing the sweep. The script below does that and reuses the same osascript notifier.
curl -LsSf https://astral.sh/uv/install.sh | sh # if you don't have uv
uv tool install gitup
gitup --add ~/Developer/* # bookmark every repo (skips non-git dirs)
gitup --list # verifySave as ~/.local/bin/dev-gitup.sh, chmod +x:
#!/bin/zsh
export PATH="/opt/homebrew/bin:$HOME/.local/bin:$PATH"
GITUP="$HOME/.local/bin/gitup"
"$GITUP" --add "$HOME"/Developer/* 2>/dev/null # pick up newly-cloned repos
failed=""
for d in "$HOME"/Developer/*/; do
git -C "$d" rev-parse --git-dir >/dev/null 2>/dev/null || continue
name="${${d%/}:t}"
# Run gitup on this repo alone so a worktree crash can't abort the rest.
if ! "$GITUP" "$d"; then
echo "gitup crashed on $name"
failed="$failed $name"
fi
done
if [ -n "$failed" ]; then
repos="${failed# }"
osascript -e "display notification \"Can't pull: $repos\" with title \"Can't pull: $repos\" sound name \"Funk\""
fiSame plist as the simple version, but point Label at com.user.gitup and the script path at $HOME/.local/bin/dev-gitup.sh, with its own log path. Load it the same way.
launchctl unload ~/Library/LaunchAgents/com.user.gitup.plist
rm ~/Library/LaunchAgents/com.user.gitup.plist ~/.local/bin/dev-gitup.sh
uv tool uninstall gitup
rm -rf ~/.gitup # bookmark listNeither tool will clobber a dirty tree — they skip and warn. So if you sit on uncommitted edits in ~/Developer/foo, that repo silently stops auto-updating until you clean up. Drift sneaks in.
The fix: never edit the main checkout directly. Use git worktree add ../foo-feature -b my-feature (or your editor / agent's worktree integration) so the primary checkout always tracks origin and your edits live in a sibling directory. The notifier is your early warning when a repo falls behind.