Auto-push Your Notes Repos on macOS with launchd (Not cron)

· 5 min read · 919 words

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:

  • 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 launchd to run the script every hour (or whatever interval you prefer).- Runs as your user, when you’re logged in.
  • 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.


  Label
  com.stefan.autopush-notes

  ProgramArguments

    /bin/zsh
    -lc
    /Users/YOURUSERNAME/bin/auto-push-notes.sh



  RunAtLoad



  StartInterval
  3600

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:

/Users/YOURUSERNAME/bin/auto-push-notes.sh

not:

$HOME/bin/auto-push-notes.sh

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:

$HOME/bin/auto-push-notes.sh

Fix: always use full absolute paths:

/Users/YOURUSERNAME/bin/auto-push-notes.sh

If you want shell expansion and your shell config, wrap it like this (as in the example):


  /bin/zsh
  -lc
  /Users/YOURUSERNAME/bin/auto-push-notes.sh

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

StandardOutPath
/Users/YOURUSERNAME/Library/Logs/autopush-notes.log
StandardErrorPath
/Users/YOURUSERNAME/Library/Logs/autopush-notes-error.log

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:
StartInterval
600
  • Specific times of day instead of an interval:
StartCalendarInterval


    Hour9
    Minute0


    Hour21
    Minute0


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.