6 min read

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 a launchd LaunchAgent, not cron.

This guide walks you through:

  • A simple shell script that auto-commits and pushes your note repos.
  • A LaunchAgent that 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:

  1. Script: ~/bin/auto-push-notes.sh
    • Loops through your note repos.
    • Optionally auto-commits any changes.
    • Pushes to origin HEAD.
  2. LaunchAgent plist: ~/Library/LaunchAgents/com.stefan.autopush-notes.plist
    • Tells launchd to run the script every hour (or whatever interval you prefer).
    • Runs as your user, when you’re logged in.
  3. launchctl commands
    • 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
done

Make 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/LaunchAgents

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


Step 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 || true

If you used load before, this still cleans things up.

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-notes

You should see a line with com.stefan.autopush-notes.

To force a run immediately:

launchctl kickstart -k gui/$(id -u)/com.stefan.autopush-notes

Watch 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 error

This 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-notes

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

Then:

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-notes

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