Skip to content

Instantly share code, notes, and snippets.

@jaredmdobson
Last active June 11, 2026 14:23
Show Gist options
  • Select an option

  • Save jaredmdobson/6d5ec01ec7c8659cf0cbc9c0c79c1602 to your computer and use it in GitHub Desktop.

Select an option

Save jaredmdobson/6d5ec01ec7c8659cf0cbc9c0c79c1602 to your computer and use it in GitHub Desktop.
Auto-pull every git repo in ~/Developer on macOS (git pull --ff-only or gitup, via launchd, with osascript notify)

Auto-pull every git repo in ~/Developer on macOS

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-only loop. No dependencies beyond git. Start here.
  • gitup — uses gitup to 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 deprecated NSUserNotification API, which Notification Center silently drops on modern macOS. It returns exit 0 and shows nothing.
  • A custom osacompile applet 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, just open -t ~/Library/Logs/dev-pull.log.


Simple version (recommended)

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.

1. The script

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\""
fi

2. Run it on an interval via launchd

mkdir -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.plist

StartInterval is in seconds1800 = every 30 min. RunAtLoad triggers one run immediately.

Gotchas this avoids:

  • launchd does not expand $HOME at runtime — but the heredoc interpolates it at write-time, so the plist ends up with absolute paths.
  • ProgramArguments is a clean two-element array (/bin/zsh + the script path). Don't try to inline the whole loop as a zsh -c string in the plist — the > / & shell metachars are illegal in XML (& must be &amp;) and will fail plutil -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).

3. Verify

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 now

Management

launchctl 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 log

Uninstall

launchctl unload ~/Library/LaunchAgents/com.user.devpull.plist
rm ~/Library/LaunchAgents/com.user.devpull.plist
rm ~/.local/bin/dev-pull.sh

Advanced version (gitup)

gitup 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.

⚠️ The worktree crash

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.

1. Install

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                # verify

2. The per-repo wrapper

Save 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\""
fi

3. launchd

Same 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.

Uninstall

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 list

Why the worktree discipline matters

Neither 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment