Auto-push Your Notes Repos on macOS with launchd (Not cron)
You’ve got a few Git repos where you keep notes, and you want them to be pushed automatically on a schedule from macOS.
On Linux you’d probably reach for cron.
On macOS the idiomatic answer is:
Use alaunchdLaunchAgent, notcron.
This guide walks you through:
- A simple shell script that auto-commits and pushes your note repos.
- A
LaunchAgentthat runs it on an interval. - Real-world gotchas:
Load failed: 5: Input/output error, already-loaded jobs, permissions, and path issues.
Everything here is written for a single user on a single Mac, pushing to a remote (GitHub, GitLab, etc.) using SSH or cached credentials.
Overview
We’ll set up:
- Script:
~/bin/auto-push-notes.sh- Loops through your note repos.
- Optionally auto-commits any changes.
- Pushes to
origin HEAD.
- LaunchAgent plist:
~/Library/LaunchAgents/com.stefan.autopush-notes.plist- Tells
launchdto run the script every hour (or whatever interval you prefer). - Runs as your user, when you’re logged in.
- Tells
launchctlcommands- Load / unload the agent.
- Debug when things go wrong.
Step 1: Create the auto-push script
Create a directory for your personal scripts if you don’t already have one:
mkdir -p "$HOME/bin"Create ~/bin/auto-push-notes.sh:
#!/usr/bin/env bash
set -euo pipefail
# List your note repos here
REPOS=(
"$HOME/notes/personal-notes"
"$HOME/notes/work-notes"
"$HOME/notes/research-notes"
)
for repo in "${REPOS[@]}"; do
if [ -d "$repo/.git" ]; then
cd "$repo"
# Make sure git pull knows how to reconcile branches:
# - pull.rebase=false => use merge, not rebase
# This also silences the "how to reconcile divergent branches" warning.
git config pull.rebase false
# Optional: allow normal merge commits on pull
# (comment out if you prefer fast-forward-only)
git config pull.ff false
# Auto-commit uncommitted changes (so pulls don't trip over local edits)
if ! git diff --quiet || ! git diff --cached --quiet; then
git add -A
git commit -m "Auto-save notes $(date '+%Y-%m-%d %H:%M:%S')" || true
fi
# Pull and merge remote changes. --no-edit prevents an interactive editor
# when a merge commit is created.
git pull --no-edit
# Push; ignore failure if something odd happens upstream
git push --quiet origin HEAD || true
fi
doneMake it executable:
chmod +x "$HOME/bin/auto-push-notes.sh"Test it manually:
"$HOME/bin/auto-push-notes.sh"If it fails here, fix that first (missing repos, auth issues, etc.) before involving launchd.
Auth tip:
Make sure pushing works without prompting for a password.
Use SSH keys or the macOS keychain credential helper.
Step 2: Create the LaunchAgent plist
launchd looks for per-user agents in:
~/Library/LaunchAgentsCreate that directory if needed:
mkdir -p "$HOME/Library/LaunchAgents"Create ~/Library/LaunchAgents/com.yourname.autopush-notes.plist:
⚠️ Use an absolute path, not $HOME, inside the plist.
Environment variables are not expanded there.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.stefan.autopush-notes</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-lc</string>
<string>/Users/YOURUSERNAME/bin/auto-push-notes.sh</string>
</array>
<!-- Run once when the agent is loaded (e.g. on login) -->
<key>RunAtLoad</key>
<true/>
<!-- Run every 3600 seconds (1 hour) -->
<key>StartInterval</key>
<integer>3600</integer>
</dict>
</plist>Replace YOURUSERNAME with your actual macOS username.
Set safe permissions (required by launchd):
chmod 644 "$HOME/Library/LaunchAgents/com.yourname.autopush-notes.plist"You should end up with something like:
ls -l "$HOME/Library/LaunchAgents/com.stefan.autopush-notes.plist"
# -rw-r--r-- 1 you staff ... com.stefan.autopush-notes.plistStep 3: Load the LaunchAgent
There are two ways: modern (bootstrap) and legacy (load). Prefer the modern one.
Unload any stale instance first
If you’ve been experimenting, you might already have a job with the same label loaded.
Unload / boot it out before loading the new plist:
launchctl bootout gui/$(id -u)/com.stefan.autopush-notes 2>/dev/null || trueIf you used load before, this still cleans things up.
Load using bootstrap (recommended)
launchctl bootstrap gui/$(id -u) "$HOME/Library/LaunchAgents/com.stefan.autopush-notes.plist"If that returns with no output, it’s usually a success.
(Optional) Legacy load
This is the older style, still works but is considered legacy:
launchctl load "$HOME/Library/LaunchAgents/com.stefan.autopush-notes.plist"If you use load, you can unload with:
launchctl unload "$HOME/Library/LaunchAgents/com.stefan.autopush-notes.plist"Step 4: Verify and test
Check that the agent is registered:
launchctl list | grep autopush-notesYou should see a line with com.stefan.autopush-notes.
To force a run immediately:
launchctl kickstart -k gui/$(id -u)/com.stefan.autopush-notesWatch your repos:
- git log in one of your note repos should show a fresh auto-commit.
- git status should be clean after the script runs.
Common Gotchas (And Fixes)
Gotcha 1: Load failed: 5: Input/output error
You run launchctl load or launchctl bootstrap and get:
Load failed: 5: Input/output errorThis is launchd’s wonderfully opaque way of saying “something is wrong with this job.”
Typical causes and fixes:
1.1 The job is already loaded
If a job with that Label is already registered, re-loading can throw error 5.
Fix:
launchctl bootout gui/$(id -u)/com.stefan.autopush-notes 2>/dev/null || true
launchctl bootstrap gui/$(id -u) "$HOME/Library/LaunchAgents/com.stefan.autopush-notes.plist"Then check:
launchctl list | grep autopush-notes1.2 Bad plist format or key names
Even tiny XML mistakes can trigger error 5.
Checks:
plutil -lint "$HOME/Library/LaunchAgents/com.stefan.autopush-notes.plist"- You should see: OK.
- Make sure keys are exactly correct: Label, ProgramArguments, RunAtLoad, StartInterval (capitalisation matters).
If plutil reports any errors, fix them and retry bootstrap.
1.3 Permissions too loose
launchd refuses plists that are world-writable or otherwise “unsafe”.
Fix:
cd "$HOME/Library/LaunchAgents"
chown "$USER":staff com.stefan.autopush-notes.plist
chmod 644 com.stefan.autopush-notes.plistThen:
launchctl bootout gui/$(id -u)/com.stefan.autopush-notes 2>/dev/null || true
launchctl bootstrap gui/$(id -u) "$HOME/Library/LaunchAgents/com.stefan.autopush-notes.plist"Gotcha 2: Script path or non-executable script
If the plist points to a path that doesn’t exist, or the script isn’t executable, the job will silently fail or show errors in logs.
Fix:
ls -l "$HOME/bin/auto-push-notes.sh"
chmod +x "$HOME/bin/auto-push-notes.sh"Then run it directly:
"$HOME/bin/auto-push-notes.sh"If this doesn’t work interactively, launchd won’t be able to run it either.
Also double-check you used an absolute path in the plist:
<string>/Users/YOURUSERNAME/bin/auto-push-notes.sh</string>not:
<string>$HOME/bin/auto-push-notes.sh</string>Gotcha 3: $HOME or other env vars in the plist
launchd does not expand $HOME, $PATH, etc. inside the plist’s ProgramArguments.
This will not do what you think:
<string>$HOME/bin/auto-push-notes.sh</string>Fix: always use full absolute paths:
<string>/Users/YOURUSERNAME/bin/auto-push-notes.sh</string>If you want shell expansion and your shell config, wrap it like this (as in the example):
<array>
<string>/bin/zsh</string>
<string>-lc</string>
<string>/Users/YOURUSERNAME/bin/auto-push-notes.sh</string>
</array>-l makes it a login shell, -c executes the command string.
Gotcha 4: Log files and StandardOutPath / StandardErrorPath
If you add log paths, e.g.:
<key>StandardOutPath</key>
<string>/Users/YOURUSERNAME/Library/Logs/autopush-notes.log</string>
<key>StandardErrorPath</key>
<string>/Users/YOURUSERNAME/Library/Logs/autopush-notes-error.log</string>and the parent directory doesn’t exist or has bad permissions, you can get cryptic errors.
Fix:
Make sure the directory exists:
mkdir -p "$HOME/Library/Logs"Ensure it’s owned by you:
chown "$USER":staff "$HOME/Library/Logs"Only then add those keys to the plist and reload.
To keep things simple, it’s often best to skip logging until everything else works.
Gotcha 5: Job not running after sleep / reboot
- Per-user LaunchAgents run when you are logged in.
- If you reboot, it will start once you log in.
- With RunAtLoad and StartInterval set, it will keep running at that interval as long as your account is logged in.
If you suspect it isn’t running:
Kickstart it manually:
launchctl kickstart -k gui/$(id -u)/com.stefan.autopush-notesCheck git log or add a quick echo "$(date)" >> ~/autopush-debug.log line in the script to confirm.
Variations & Customisation
Change the schedule
- Every 10 minutes:
<key>StartInterval</key>
<integer>600</integer>- Specific times of day instead of an interval:
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Hour</key><integer>21</integer>
<key>Minute</key><integer>0</integer>
</dict>
</array>Use either StartInterval or StartCalendarInterval, not both.
Recap
- On macOS, the clean, idiomatic way to schedule git push for your notes repos is a LaunchAgent under launchd, not cron.
- A small script plus a plist in ~/Library/LaunchAgents gets you automatic commits and pushes on whatever interval you want.
- If you hit Load failed: 5: Input/output error, it’s almost always one of:
- Job already loaded → bootout then bootstrap.
- Bad plist syntax → plutil -lint.
- Permissions too loose → chmod 644 and correct owner.
- Wrong paths or non-executable script.
Member discussion