{"slug": "3-weeks-of-silent-backup-failure-why-launchd-can-t-write-to-documents-on-macos", "title": "3 Weeks of Silent Backup Failure: Why launchd Can't Write to ~/Documents on macOS", "summary": "A developer discovered that their macOS backup job had silently failed for three weeks because launchd couldn't write to ~/Documents due to macOS TCC permissions. The developer solved the issue by moving the backup target to ~/.claude/config-snapshots/ and implementing a two-stage backup system that uses rsync to copy only config files to a separate directory under git, avoiding the noise of the full ~/.claude directory.", "body_md": "Every Sunday at 6:00 AM, my backup job ran, logged `no changes`\n\n, and exited 0. For three weeks straight it never copied a single file. macOS was blocking it, and nothing in the system told me.\n\nThe first problem you hit when you automate personal development work is \"I can't roll back when my config breaks.\"\n\nOnce you build out a Claude Code environment, hundreds of lines of configuration accumulate under `~/.claude/`\n\n. `settings.json`\n\n(tool permissions, model selection, hook paths), `CLAUDE.md`\n\n(the full text of global operating rules), the `hooks/`\n\ndirectory (automated checks like Stop hooks and PreTool hooks), `agents/`\n\n(specialized agent definition files), `skills/auto/`\n\n(skills that get self-generated during real usage), `rules/`\n\n(the ECC rules digest) — dozens of places in these change every week. Claude Code updates, hook script improvements, new agent definitions. The more actively you use it, the more the configuration changes like a living thing.\n\nThe problem is that `~/.claude/`\n\nisn't under version control. A Claude Code cache update wipes out a config file, a mistake in a hook script takes down every PreTool hook, a one-line typo in `settings.json`\n\nmakes permission prompts explode everywhere — and when that happens, with no diff and no history, you can't even tell *when* it broke. Reconstructing by feel from \"it was working a week ago\" is pure lost time.\n\nThe two approaches most people reach for here are \"back it up manually\" or \"put the whole thing in git.\"\n\n**Manual backups** get tedious even at weekly cadence. And your environment breaks precisely during the week you skipped. \"I was going to get around to it\" doesn't help you recover.\n\n**The whole thing in git** doesn't work in practice, because `~/.claude/`\n\nmixes in noise like plugin cache, session history, telemetry, and paste-cache, which balloons the repository to several GB. It also can't rule out the risk of secrets ending up in the cache.\n\nThe solution is a two-stage structure: rsync only the config files to a separate directory, and put only that directory under git. Never touch `~/.claude/`\n\nitself, and define the sync targets explicitly with an INCLUDE list. Even when the human forgets, launchd runs every Sunday at 6:00. If there's a diff, commit it in conventional commits format. If there's no diff, write `no changes`\n\nto the log and exit.\n\nThis works because **it takes human willpower out of the loop**. Config files are too fine-grained to manage on the strength of \"I should really back this up.\" But losing them costs hours to reconstruct. Automation fills that asymmetry, and launchd sits at the bottom layer of that automation as a highly reliable OS feature.\n\nThere are side benefits too. Because the diffs stay in git, you can track down \"which change last week broke the hook\" with `git log --oneline`\n\n. You can discover configuration drift (parts that changed unintentionally). You can reproduce the setup immediately when moving to another machine. You can build up improvements while checking \"what did I do a week ago\" with `git diff`\n\n.\n\nThe header comment of the actual script (`~/.claude/scripts/dotfiles-snapshot.sh`\n\n) reads like this:\n\n```\n# dotfiles-snapshot.sh — ~/.claude の設定だけを別ディレクトリに同期して git 管理\n# 元の ~/.claude は触らない（plugin cache 等のノイズと混ざらないため）\n# 同期先: ~/.claude/config-snapshots/\n#   （旧 ~/Documents/claude-config-snapshots は launchd 実行時に macOS TCC で\n#     \"Operation not permitted\" になり全コピー失敗していたため 2026-06-01 に移設）\n# 既存 ~/Documents/my-knowledge-base/ の SessionEnd auto-commit と分離管理\n```\n\nThe line \"never touch the original `~/.claude`\n\n\" expresses the design philosophy. The backup source directory itself isn't under git; only the necessary config files are copied to a separate directory, and only that becomes a repository. The INCLUDE list is the \"canonical definition\" of what counts as a config file.\n\nAnd the fourth line of that comment — *the old ~/Documents/claude-config-snapshots hit macOS TCC \"Operation not permitted\" when run under launchd and every copy failed, so it was relocated on 2026-06-01* — is the actual subject of this article. Before that relocation on 2026-06-01, the backup ran every Sunday at 6:00 \"looking like it worked\" while doing nothing at all.\n\nHere's the structure of the current system.\n\n```\n┌─────────────────────────────────────────────────────────┐\n│  launchd                                                │\n│  Label: com.shun.dotfiles-snapshot                      │\n│  毎週日曜 06:00                                          │\n│  ProcessType: Background / Nice: 10 / LowPriorityIO: true│\n└──────────────────────┬──────────────────────────────────┘\n                       │ /bin/zsh -c <script>\n                       ▼\n┌─────────────────────────────────────────────────────────┐\n│  dotfiles-snapshot.sh                                   │\n│                                                         │\n│  SRC: ~/.claude/                                        │\n│  DST: ~/.claude/config-snapshots/   ← 重要              │\n│                                                         │\n│  1. INCLUDE 11項目を rsync -a でコピー                   │\n│  2. settings.json に APIキー混入チェック                  │\n│  3. git add -A && git commit (変更ゼロなら skip)          │\n│  4. HEAD 前進チェック（exit 0 でも HEAD 不動なら exit 3） │\n└──────────────────────┬──────────────────────────────────┘\n                       │\n              ┌────────┴────────┐\n              ▼                 ▼\n┌─────────────────┐  ┌──────────────────────────────────┐\n│ git repository  │  │ ~/.claude/logs/                  │\n│ config-snapshots│  │ dotfiles-snapshot.log            │\n│ (conventional   │  │ com.shun.dotfiles-snapshot.log   │\n│  commits)       │  │ （plist StandardOutPath と共用）  │\n└─────────────────┘  └──────────────────────────────────┘\n```\n\nLet's look at the core of the plist.\n\n```\n<key>StartCalendarInterval</key>\n<dict>\n  <key>Hour</key>    <integer>6</integer>\n  <key>Minute</key>  <integer>0</integer>\n  <key>Weekday</key> <integer>0</integer>\n</dict>\n<key>ProcessType</key>   <string>Background</string>\n<key>LowPriorityIO</key> <true/>\n<key>Nice</key>          <integer>10</integer>\n```\n\n`Weekday=0`\n\nis Sunday. `Nice=10`\n\nand `LowPriorityIO=true`\n\nare appropriate settings for a 6 AM background job — they avoid competing for IO with the workload spike right after startup. It's weekly rather than daily because config changes are coarser-grained than daily. Claude Code configuration alternates between \"weeks I touched a ton\" and \"weeks I didn't touch at all.\" Weekly gives sufficient diff resolution.\n\nLog output is split across two destinations.\n\n```\n<key>StandardErrorPath</key>\n<string>~/.claude/logs/com.shun.dotfiles-snapshot.log</string>\n<key>StandardOutPath</key>\n<string>~/.claude/logs/com.shun.dotfiles-snapshot.log</string>\n```\n\nThe plist-level stdout/stderr are written by launchd. Logging inside the script flows to `LOGFILE=\"$HOME/.claude/logs/dotfiles-snapshot.log\"`\n\n. Since the script does `echo \"[$(ts)] ...\" >> \"$LOGFILE\"`\n\n, launchd's startup log and the script's progress log get recorded in two separate files.\n\nNext, the INCLUDE list. The script's backup targets are these 11 items:\n\n```\nINCLUDE=(\n  \"settings.json\"\n  \"settings.local.json\"\n  \"CLAUDE.md\"\n  \"hooks/\"\n  \"commands/\"\n  \"agents/\"\n  \"skills/auto/\"\n  \"skills/ecc/\"\n  \"scripts/\"\n  \"rules/\"\n  \"improvements/\"\n)\n```\n\nCache, session, telemetry, paste-cache, and file-history are nowhere in there. Conversely, look at the EXCLUDES list:\n\n```\nEXCLUDES=(\n  \"--exclude=*.log\"\n  \"--exclude=.cache/\"\n  \"--exclude=node_modules/\"\n  \"--exclude=*.tsbuildinfo\"\n  \"--exclude=.harvest-watermark\"\n  \"--exclude=tmp/\"\n)\n```\n\n`--exclude=.harvest-watermark`\n\nstands out. That's a watermark file generated by Claude Code's automatic skill-harvesting feature, and it becomes diff noise if it makes it into commits. By explicitly excluding environment-specific junk, `git log`\n\nrecords only meaningful configuration changes.\n\nThe rsync loop is simple.\n\n```\nfor item in \"${INCLUDE[@]}\"; do\n  if [ -e \"$SRC/$item\" ]; then\n    rsync -a \"${EXCLUDES[@]}\" \"$SRC/$item\" \"$DST/$item\" 2>>\"$LOGFILE\"\n  fi\ndone\n```\n\nBecause it checks existence with `[ -e \"$SRC/$item\" ]`\n\nbefore rsyncing, an item that's listed in INCLUDE but hasn't been created yet is harmlessly skipped. Errors flow to the log via `2>>\"$LOGFILE\"`\n\n, so you can check afterward what happened during the run.\n\nBefore committing, an API key detection check runs.\n\n```\nif grep -qE 'sk-[A-Za-z0-9_-]{30,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}' \\\n    \"$DST/settings.json\" 2>/dev/null; then\n  echo \"[$(ts)] ABORT: secret detected in settings.json copy\" >> \"$LOGFILE\"\n  rm -f \"$DST/settings.json\"\n  exit 1\nfi\n```\n\nIt greps for three patterns: `sk-*`\n\n(Anthropic API key), `ghp_*`\n\n(GitHub Personal Access Token), and `AKIA*`\n\n(AWS Access Key). If you accidentally wrote an API key into settings.json, it deletes the copied file and terminates immediately with exit 1. The three steps — detect secret, delete the copy, abort — are consolidated in one place, so it stays safe even combined with pushing to a public repository.\n\nThe commit logic has one more defense.\n\n```\nPREV_HEAD=$(git rev-parse --verify --quiet HEAD || echo \"\")\nif git commit -m \"chore(snapshot): claude-config $(date '+%Y-%m-%d %H:%M')\" \\\n    >>\"$LOGFILE\" 2>&1; then\n  NEW_HEAD=$(git rev-parse --verify --quiet HEAD || echo \"\")\n  if [ -z \"$NEW_HEAD\" ] || [ \"$NEW_HEAD\" = \"$PREV_HEAD\" ]; then\n    echo \"[$(ts)] commit reported success but HEAD did not advance \\\n(prev=$PREV_HEAD new=$NEW_HEAD) — ABORT\" >> \"$LOGFILE\"\n    exit 3\n  fi\n  COMMIT=$(git rev-parse --short HEAD)\n  echo \"[$(ts)] snapshot done: ${CHANGED} files changed, commit=${COMMIT}\" >> \"$LOGFILE\"\n```\n\nEven if `git commit`\n\nreturns exit 0, if HEAD hasn't moved it terminates abnormally with exit 3. A comment in the script says *前は -q で潰していて exit code も拾えていなかった* (\"previously `-q`\n\nsuppressed it and the exit code wasn't captured either\"), and this was implemented as a countermeasure for a past bug where *the commit hook (commit-msg validator) rejected the commit but the exit code appeared to be 0*. The line `commit reported success but HEAD did not advance`\n\nin the log lets you trace the cause quickly.\n\nA normal run's log looks like this:\n\n```\n[2026-06-08 06:00:12] snapshot start\n[2026-06-08 06:00:13] snapshot done: 3 files changed, commit=a1b2c3d\n```\n\nAnd with no diff:\n\n```\n[2026-06-08 06:00:12] snapshot start\n[2026-06-08 06:00:12] no changes\n```\n\nJust those two lines tell you whether the backup ran and whether anything changed. No dashboard, no notifications needed. You only open the log when something goes wrong.\n\nThat's the full picture of the current (correctly working) system.\n\nSo what was happening before 2026-06-01, when the backup destination was `~/Documents/claude-config-snapshots/`\n\n? Where did the `\"Operation not permitted\"`\n\nfrom that header comment come from, and why did everything fail silently? The next section breaks down the evidence.\n\n`-e`\n\nisn't added to `set -uo pipefail`\n\nThe script starts with `set -uo pipefail`\n\n. Undefined variable references (`-u`\n\n) and mid-pipeline errors (`-o pipefail`\n\n) kill the script immediately, while `-e`\n\n(exit immediately on any non-zero command) is deliberately left out.\n\nThere are two reasons.\n\nThe first is the secret check.\n\n```\nif grep -qE 'sk-[A-Za-z0-9_-]{30,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}' \\\n    \"$DST/settings.json\" 2>/dev/null; then\n  echo \"[$(ts)] ABORT: secret detected in settings.json copy\" >> \"$LOGFILE\"\n  rm -f \"$DST/settings.json\"\n  exit 1\nfi\n```\n\n`grep -q`\n\nreturns exit code 1 when the pattern is **not** found (i.e., no API key present, the normal state). Enabling `-e`\n\nwould produce the backwards behavior of \"no secret present = normal = exit 1 = script aborts.\" Since `if grep ...`\n\nmakes the control flow explicit, `-e`\n\nisn't just unnecessary — it's harmful.\n\nThe second is HEAD retrieval.\n\n```\nPREV_HEAD=$(git rev-parse --verify --quiet HEAD || echo \"\")\n```\n\n`git rev-parse HEAD`\n\nreturns exit 1 right after `git init`\n\n(when no commit exists yet). `|| echo \"\"`\n\nfalls back to an empty string so the first run works safely too, but with `-e`\n\nthe script would die before the `||`\n\nis evaluated.\n\nIn a design that controls failures with explicit `exit 1 / exit 2 / exit 3`\n\n, `-e`\n\nis noise. `set -uo pipefail`\n\nexpresses a deliberate policy: \"kill on undefined variables and mid-pipeline errors; handle command non-zero returns myself.\"\n\nThe plist's `EnvironmentVariables`\n\nblock enumerates a complete PATH including nvm-managed Node.js, Homebrew, and `~/.local/bin`\n\n.\n\nBackground processes launched by launchd read neither `~/.zshrc`\n\nnor `~/.zprofile`\n\n. The default PATH is barely more than `/usr/bin:/bin:/usr/sbin:/sbin`\n\nand doesn't even include `/opt/homebrew/bin`\n\n. `git`\n\nand `rsync`\n\nlive in the system's `/usr/bin`\n\n, but git hooks that need Node.js will absolutely jam.\n\nThis script itself is written with only bash, rsync, and git, and doesn't call node.js directly. So why is the Node.js path needed? Because the **global git hook ~/.git-hooks/commit-msg** calls a Node-based validator in some environments when verifying conventional commits format. With an incomplete PATH, you end up in a situation where\n\n`git commit`\n\nruns but the hook silently fails every time.Writing the full PATH into the launchd process is a one-time cost. Far cheaper than getting stuck later on \"for some reason only git commit fails.\"\n\nThe plist says this:\n\n```\n<key>StandardErrorPath</key>\n<string>~/.claude/logs/com.shun.dotfiles-snapshot.log</string>\n<key>StandardOutPath</key>\n<string>~/.claude/logs/com.shun.dotfiles-snapshot.log</string>\n```\n\nBut the execution string in `ProgramArguments`\n\ncontains a shell-level redirect.\n\n```\n<string>/bin/zsh</string>\n<string>-c</string>\n<string>/path/to/dotfiles-snapshot.sh >> /dev/null 2>&1</string>\n```\n\nWith `>> /dev/null 2>&1`\n\n, the script's own stdout/stderr are thrown into `/dev/null`\n\nat shell evaluation time. The fd that launchd set up via `StandardOutPath`\n\ngets overwritten. So what is `StandardOutPath`\n\nactually capturing?\n\nThe answer is \"zsh's own startup errors.\" If zsh fails before evaluating the `-c`\n\nstring — a broken script path, zsh not found, and so on — that error flows to the fd launchd set up, ahead of the shell-level redirect. `com.shun.dotfiles-snapshot.log`\n\nfunctions not as the script's progress log but as a **fallback that catches launchd-layer startup failures**.\n\nThe script's progress is recorded via explicit `>>`\n\nwrites to `LOGFILE=\"$HOME/.claude/logs/dotfiles-snapshot.log\"`\n\n.\n\n```\nts() { date '+%Y-%m-%d %H:%M:%S'; }\necho \"[$(ts)] snapshot start\" >> \"$LOGFILE\"\n```\n\nThis two-path separation lets you track \"did the script start (launchd log)\" and \"what happened inside the script (dotfiles-snapshot.log)\" in separate files. The starting point for troubleshooting is the latter.\n\nThe rsync loop sends errors to the log but doesn't stop the loop.\n\n```\nfor item in \"${INCLUDE[@]}\"; do\n  if [ -e \"$SRC/$item\" ]; then\n    rsync -a \"${EXCLUDES[@]}\" \"$SRC/$item\" \"$DST/$item\" 2>>\"$LOGFILE\"\n  fi\ndone\n```\n\nIt doesn't check rsync's exit code. This is an intentional design. If the `agents/`\n\ncopy fails on a permission error, I don't want that to stop the `CLAUDE.md`\n\nor `hooks/`\n\ncopies too. The idea is: tolerate partial failure, copy as many files as possible, and leave errors in the log.\n\nBut this design also means \"the script exits normally even if every item errors.\" If nothing gets copied to DST, no diff appears in git, and it records `no changes`\n\nand finishes. The TCC bug described later is exactly this case.\n\n```\ngrep -qE 'sk-[A-Za-z0-9_-]{30,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}'\n```\n\n`sk-*`\n\nis an Anthropic API key, `ghp_*`\n\na GitHub Personal Access Token, and `AKIA*`\n\nan AWS Access Key ID. settings.json is where you write Claude Code tool permissions and model settings, so these three families are chosen because they're the ones you might accidentally hardcode. A generic \"long alphanumeric string\" pattern produces too many false positives, so the policy is to narrow by prefix.\n\nWhen something is detected, it **deletes the copied file and then exits 1**. If you exit 1 with the copied file still there, the next run records the \"deleted state\" as a diff and leaves contamination in the git log. The order — delete, then exit 1 — matters.\n\n**Symptom**: launchd starts every Sunday at 6:00. The log shows `[2026-05-xx 06:00:12] snapshot start`\n\nand `[2026-05-xx 06:00:12] no changes`\n\nlined up weekly. At a glance, it looks like it's working fine.\n\nBut when I checked `~/.claude/config-snapshots/.git/`\n\n, `git log`\n\nhad stopped at a commit from a month earlier. `~/.claude/CLAUDE.md`\n\nhad 50 lines added last week, but the snapshot repository's copy was still the old one.\n\n**Cause**: The reason for `no changes`\n\nwasn't \"no changes in the config files\" but \"rsync couldn't copy anything, so no diff appears in DST.\"\n\nThe backup destination at the time was `~/Documents/claude-config-snapshots/`\n\n. Picking up rsync's error log, the same line was repeated for all 11 items.\n\n```\nrsync: [sender] send_files failed to open \".../Documents/claude-config-snapshots/...\" : Operation not permitted (1)\n```\n\nmacOS TCC (Transparency, Consent, and Control) was blocking writes to `~/Documents/`\n\n.\n\nTCC is macOS's mechanism for protecting user data. Access to `~/Desktop/`\n\n, `~/Documents/`\n\n, `~/Downloads/`\n\n, and external storage prompts for permission via a system dialog on an app's first access. But background processes launched by launchd **get no dialog**. Without permission, it quietly returns Operation not permitted with no user notification and no warning log.\n\nrsync's errors were being written to the log file via `2>>\"$LOGFILE\"`\n\n, but the script body proceeds to the next item without checking that exit code. Even if all 11 items fail, if DST is unchanged then `CHANGED=0`\n\nand it records `no changes`\n\nand exits normally.\n\n**Fix**: I relocated the backup destination to `~/.claude/config-snapshots/`\n\n(2026-06-01). `~/.claude/`\n\nis a dot folder directly under the home directory and isn't among TCC's protected targets (Desktop/Documents/Downloads). Even a launchd background process can read and write it without access restrictions.\n\nAfter the relocation, rsync copied all items correctly, and the first commit recorded hundreds of lines of diff at once. That was the moment \"three weeks of configuration changes had been treated as if they never happened\" first became visible.\n\nThe single line in the script's header comment (*the old ~/Documents/claude-config-snapshots hit macOS TCC \"Operation not permitted\" when run under launchd and every copy failed, so it was relocated on 2026-06-01*) is there to pass this lesson on to future me.\n\n**The asymmetry between TCC and launchd**: When a GUI app accesses `~/Documents/`\n\nfor the first time, the system shows a permission dialog. The user gets to notice \"ah, this app is trying to write there.\" A launchd background daemon has no such feedback. The gap between \"looks successful\" and \"actually failed\" goes unclosed for weeks because no notification mechanism exists. When writing automation on macOS, the places you can safely write to from launchd are `~/.`\n\n-style dot folders, `/tmp/`\n\n, and app containers. You have to accept `~/Documents/`\n\nas protected territory for GUI apps.\n\n`git commit`\n\nemitted a \"success log\" while HEAD didn't move\n**Symptom**: The log says `[2026-xx-xx 06:00:13] snapshot done: 5 files changed, commit=a1b2c3d`\n\n. It even records the commit hash. But running `cd ~/.claude/config-snapshots && git log --oneline`\n\n, that hash doesn't exist. It hasn't moved from a commit a week earlier.\n\n**Cause**: The global commit-msg hook was rejecting the commit, but that wasn't being communicated to the script.\n\nThe commit execution code at the time had the `-q`\n\n(quiet) flag.\n\n```\n# 旧コード（問題版）\nif git commit -q -m \"chore(snapshot): claude-config $(date '+%Y-%m-%d %H:%M')\"; then\n  echo \"[$(ts)] snapshot done: ...\" >> \"$LOGFILE\"\nfi\n```\n\n`-q`\n\nsuppresses git's stdout output. The problem is recorded in a comment.\n\n```\n# stdout/stderr を両方 LOG に流す（前は -q で潰していて exit code も拾えていなかった）\n```\n\nWith stdout suppressed by `-q`\n\n, there were cases where the exit code the script received appeared to be 0 even when the `~/.git-hooks/commit-msg`\n\nvalidator returned non-zero and aborted the commit. The `if git commit -q ...`\n\ncondition evaluated true, and the log recorded it as \"succeeded.\"\n\nBefore the HEAD-advancement check was added, \"a hash appears in the commit log\" was the only means of confirming normal operation, so looking at the log told you nothing about the cause.\n\n**Fix**: I made two changes at once.\n\nFirst, drop `-q`\n\nand send both stdout and stderr to `$LOGFILE`\n\n.\n\n```\nif git commit -m \"chore(snapshot): claude-config $(date '+%Y-%m-%d %H:%M')\" \\\n    >>\"$LOGFILE\" 2>&1; then\n```\n\nNext, explicitly confirm HEAD advanced.\n\n```\nPREV_HEAD=$(git rev-parse --verify --quiet HEAD || echo \"\")\n# ... commit実行後 ...\nNEW_HEAD=$(git rev-parse --verify --quiet HEAD || echo \"\")\nif [ -z \"$NEW_HEAD\" ] || [ \"$NEW_HEAD\" = \"$PREV_HEAD\" ]; then\n  echo \"[$(ts)] commit reported success but HEAD did not advance \\\n(prev=$PREV_HEAD new=$NEW_HEAD) — ABORT\" >> \"$LOGFILE\"\n  exit 3\nfi\n```\n\nThe reason for the custom `exit 3`\n\ncode is the `LastExitStatus`\n\nvalue you can check with `launchctl list com.shun.dotfiles-snapshot`\n\n. launchd stores the script's exit code as `code × 256`\n\n. If `LastExitStatus`\n\nis `768`\n\n, that's `3 × 256`\n\n, so you know it terminated with `exit 3`\n\n. By distinguishing exit 2 (commit failure), exit 3 (HEAD didn't move), and exit 1 (secret detected), you can identify the failure type from the exit code without looking at a dashboard.\n\n**Never trust the commit exit code**: You must not judge `git commit`\n\n's success by exit code alone. commit-msg hooks, pre-commit hooks, and post-commit hooks run inside git's process, and exit code propagation varies with the git version and the hook implementation. Directly checking \"did HEAD advance\" with `git rev-parse`\n\nis the only reliable verification method.\n\n`git init`\n\nalways ended in exit 3\n**Symptom**: When I deleted the backup destination directory manually and retested, the first run always terminated with `exit 3`\n\n. The log said `commit reported success but HEAD did not advance (prev= new=) — ABORT`\n\n.\n\nThis surfaced right after adding the HEAD-advancement check.\n\n**Cause**: A freshly `git init`\n\ned repository has no HEAD (no commits at all). So `PREV_HEAD=\"\"`\n\n. If the commit succeeds, `NEW_HEAD`\n\ngets the new hash and `[ -z \"$NEW_HEAD\" ]`\n\nshould be false.\n\nBut the comment records: *過去バグ: 初回 init 時 validator が弾いたのに success ログ* (\"past bug: on first init the validator rejected it but a success log was written\"). When the commit-msg hook rejects, `NEW_HEAD`\n\nstays `\"\"`\n\n. The check condition is `[ -z \"$NEW_HEAD\" ] || [ \"$NEW_HEAD\" = \"$PREV_HEAD\" ]`\n\n, so:\n\n`PREV_HEAD=\"\"`\n\nand `NEW_HEAD=\"abc1234\"`\n\n→ left side false, right side false → check passes`PREV_HEAD=\"\"`\n\nand `NEW_HEAD=\"\"`\n\n→ left side true → exit 3These two patterns are handled by one line with `||`\n\n.\n\nThe exit 3 during testing was the \"first run plus hook error\" combination. The commit-msg hook was rejecting the state where git config's `user.name`\n\nand `user.email`\n\nwere unset. The script does have a line that writes repo-local settings at `git init`\n\ntime (inside the first-init block), but in my test I had manually deleted only `.git/`\n\n, so the user settings didn't remain and the hook refused.\n\n```\nif [ ! -d \"$DST/.git\" ]; then\n  ( cd \"$DST\" && git init -q && git config user.name \"...\" && git config user.email \"...\" )\n  echo \"[$(ts)] git init at $DST\" >> \"$LOGFILE\"\nfi\n```\n\nIf you delete all of `DST`\n\nand rerun, the order `mkdir -p \"$DST\"`\n\n→ `git init`\n\n→ user settings → commit success is guaranteed. Deleting only `.git/`\n\nproduces \"partial initialization\" and breaks that ordering. My test procedure was wrong.\n\n**The correct reproduction test procedure**: Delete everything with `rm -rf \"$DST\"`\n\n, then start manually with `launchctl start com.shun.dotfiles-snapshot`\n\n. Partial deletion creates a half-baked state and complicates debugging. A flaw in the test procedure was manufacturing a bug that doesn't reproduce in production, since production launchd always starts from `mkdir -p \"$DST\"`\n\n.\n\nIn the section above I dug into three cases: \"the TCC bug where no backups were taken for three weeks,\" \"the bug where `git commit`\n\nreturned exit 0 while HEAD didn't move,\" and \"the bug where the first commit right after `git init`\n\nexits 3.\" Here I'll cover the other pitfalls you commonly hit in a launchd × rsync × git setup.\n\n**You can't use ~ in plist path values (write absolute paths)**\n\nlaunchd interprets the XML values of `StandardOutPath`\n\nand `StandardErrorPath`\n\nliterally. `~`\n\nis a shell expansion feature, and the plist parser has no such logic. Even if you write `~/.claude/logs/...`\n\n, launchd goes looking for a path literally named `~/.claude/`\n\n, which of course doesn't exist, so the output is lost. The actual plist writes absolute paths like this:\n\n```\n<key>StandardErrorPath</key>\n<string>/Users/home_dir/.claude/logs/com.shun.dotfiles-snapshot.log</string>\n<key>StandardOutPath</key>\n<string>/Users/home_dir/.claude/logs/com.shun.dotfiles-snapshot.log</string>\n```\n\nA plist copied from a template and reused with `~`\n\nintact will keep running while producing no logs whatsoever. If you find yourself thinking \"it's launching under launchd but I can't capture zsh startup errors,\" check here first.\n\n**Shell redirects in ProgramArguments override StandardOutPath**\n\n```\n<string>/bin/zsh</string>\n<string>-c</string>\n<string>/path/to/dotfiles-snapshot.sh &gt;&gt; /dev/null 2&gt;&amp;1</string>\n```\n\nWhen you write `>> /dev/null 2>&1`\n\n, the write destination that launchd set via `StandardOutPath`\n\ngets replaced with `/dev/null`\n\nat the zsh level. As explained above, this design is an intentional separation: \"capture only zsh errors before the script starts via `StandardOutPath`\n\n.\" But if you do it unintentionally, you end up with \"nothing shows up in the launchd log.\" While debugging, the safe procedure is to temporarily remove `>> /dev/null`\n\nso output flows to StandardOutPath, then restore it once you've confirmed behavior.\n\n**Omitting EnvironmentVariables means neither Homebrew nor nvm is visible**\n\nlaunchd background processes read neither `.zshrc`\n\nnor `.zprofile`\n\n. The default PATH is effectively `/usr/bin:/bin:/usr/sbin:/sbin`\n\n. `git`\n\nand `rsync`\n\nhappen to be in the system's `/usr/bin`\n\nso they work, but Homebrew's `git`\n\n(a newer version) and nvm-managed Node.js are invisible.\n\nThe problem becomes visible in the case where **the global git hook is Node-based**. This script itself doesn't call Node.js directly. But if a conventional commits validator like `~/.git-hooks/commit-msg`\n\nis written in Node, Node.js becomes indirectly required. The actual plist writes the full PATH like this:\n\n```\n<key>PATH</key>\n<string>/Users/home_dir/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/home_dir/.local/bin</string>\n```\n\nYou need to update this path every time the nvm version changes. Rewrite it based on the version you check with `nvm current`\n\n.\n\n**StartCalendarInterval skips scheduled runs during sleep**\n\nOne of the big differences between cron and launchd is how missed fires are handled. If the Mac was asleep at 6 AM Sunday, the StartCalendarInterval launch is silently skipped. There's no catch-up behavior after wake. It won't run until the next Sunday at 6 AM.\n\nIn an environment where you frequently shut down or sleep a laptop, a weekly schedule risks leaving holes. There are two countermeasures: switch to fixed-interval execution with `StartInterval`\n\n(e.g., 86400 seconds = daily), or add `RunAtLoad: true`\n\nso it also runs at load time. Running daily greatly reduces the probability of a gap longer than a week.\n\n**Confusing launchctl load with launchctl start**\n\n`launchctl load ~/Library/LaunchAgents/com.shun.dotfiles-snapshot.plist`\n\nonly registers the schedule. It doesn't run immediately. Doing `load`\n\nwhile debugging and then agonizing for 10 minutes over \"why isn't it running\" is an extremely common way to get stuck.\n\nImmediate execution is `launchctl start com.shun.dotfiles-snapshot`\n\n. The test cycle is as follows.\n\n```\n# plistを更新した場合\nlaunchctl unload ~/Library/LaunchAgents/com.shun.dotfiles-snapshot.plist\nlaunchctl load   ~/Library/LaunchAgents/com.shun.dotfiles-snapshot.plist\nlaunchctl start  com.shun.dotfiles-snapshot\n\n# 実行後に確認\nlaunchctl list com.shun.dotfiles-snapshot\n# → \"LastExitStatus\" = 0 なら正常\n```\n\n**How to read LastExitStatus (what does 768 mean?)**\n\nWhen `launchctl list com.shun.dotfiles-snapshot`\n\noutputs `\"LastExitStatus\" = 768`\n\n, that's `768 ÷ 256 = 3`\n\n, meaning it terminated with `exit 3`\n\n. launchd stores the script's exit code as `exit_code × 256`\n\n. This script assigns the following meanings to its exit codes:\n\n`0`\n\n— normal termination (changes committed successfully, or no changes)`1`\n\n— secret detected in settings.json; copy deleted and aborted`2`\n\n— `git commit`\n\nfailed with a non-zero exit`3`\n\n— `git commit`\n\nexited 0 but `git rev-parse HEAD`\n\nshows HEAD didn't advanceChecking `LastExitStatus`\n\nwith `launchctl list`\n\nbefore opening the log is the fastest first move when something breaks.\n\n**You have to maintain the INCLUDE list yourself**\n\nEven if you create a new directory in `~/.claude/`\n\n, it won't be backed up unless you add it to the script's INCLUDE list.\n\n```\nINCLUDE=(\n  \"settings.json\"\n  \"settings.local.json\"\n  \"CLAUDE.md\"\n  \"hooks/\"\n  \"commands/\"\n  \"agents/\"\n  \"skills/auto/\"\n  \"skills/ecc/\"\n  \"scripts/\"\n  \"rules/\"\n  \"improvements/\"\n)\n```\n\nIn my environment, I ran a `memory/`\n\ndirectory (files written by agentmemory) for a month before noticing \"that's not in INCLUDE.\" The commit the week I added it was the first time that month's worth of changes made it into a snapshot. Get in the habit of reconciling `ls ~/.claude/`\n\nagainst this list monthly.\n\n**The script rewrites .gitignore every run**\n\nThe script overwrites `.gitignore`\n\nfrom a heredoc on every run.\n\n```\ncat > \"$DST/.gitignore\" << 'GITIGNORE'\n# Auto-generated by dotfiles-snapshot.sh\n*.log\n.DS_Store\ntmp/\n...\nGITIGNORE\n```\n\nAnything you append to `.gitignore`\n\nby hand disappears on the next run. If you want to add exclusion patterns, edit the heredoc in the script body. The `# Auto-generated`\n\ncomment signals that intent.\n\n`2>>\"$LOGFILE\"`\n\nalone won't make you notice rsync errors\n\nrsync sends errors to the log inside the loop, but proceeds to the next item without checking the exit code.\n\n```\nfor item in \"${INCLUDE[@]}\"; do\n  if [ -e \"$SRC/$item\" ]; then\n    rsync -a \"${EXCLUDES[@]}\" \"$SRC/$item\" \"$DST/$item\" 2>>\"$LOGFILE\"\n  fi\ndone\n```\n\nEven if all 11 items fail, it's `CHANGED=0`\n\n→ `no changes`\n\n→ normal exit. If you get the nagging feeling that \"`no changes`\n\nhas continued for weeks even though I'm sure I changed the config,\" check the log with this command:\n\n```\ngrep \"Operation not permitted\\|rsync error\" ~/.claude/logs/dotfiles-snapshot.log | tail -20\n```\n\nIf anything shows up, that leads you to the cause.\n\n**TCC protected targets aren't just ~/Documents/**\n\nThe TCC problem detailed above isn't limited to `~/Documents/`\n\n. The directories where a launchd background process gets silently blocked are:\n\n`~/Desktop/`\n\n`~/Documents/`\n\n`~/Downloads/`\n\n`~/Movies/`\n\n, `~/Music/`\n\n, `~/Pictures/`\n\n`~/Library/Mobile Documents/`\n\n)Conversely, the **directories launchd can write to without permission** are:\n\n`~/`\n\n(`~/.claude/`\n\n, `~/.config/`\n\n, `~/.local/`\n\n, etc.)`~/Library/Application Support/`\n\n, `~/Library/Logs/`\n\n, `~/Library/Caches/`\n\n`/tmp/`\n\n, `/var/folders/`\n\n(temporary files)Before designing an automation script, confirm which of these two lists its write destination belongs to.\n\n**Set git's local user config at the same time as git init**\n\nThe global `~/.gitconfig`\n\nis normally read even in a launchd environment, but if a commit-msg hook assumes the existence of `user.name`\n\n/`user.email`\n\n, it can be rejected when something like `GIT_CONFIG_NOSYSTEM`\n\nis set in the launchd environment. That's why this script writes `git config user.name \"...\"`\n\nand `git config user.email \"...\"`\n\nlocally at the same time as `git init`\n\n— as a failsafe.\n\n```\nif [ ! -d \"$DST/.git\" ]; then\n  ( cd \"$DST\" && git init -q \\\n    && git config user.name \"...\" \\\n    && git config user.email \"...\" )\nfi\n```\n\nUnless you delete all of `DST`\n\nbefore retesting, the `git init`\n\nblock won't execute. A test that deletes only `.git/`\n\ncreates a \"partially initialized\" state and induces bugs that don't reproduce in production (see Stuck 3 above for details).\n\n`~/Documents/`\n\nas a backup destination\nlaunchd background processes get rejected silently. The first choice for a backup destination is a `~/.`\n\n-style dot folder. This setup's `~/.claude/config-snapshots/`\n\nis the correct example.\n\nIf you use `~`\n\n, that character isn't expanded and doesn't function as a path. `StandardOutPath`\n\n, `StandardErrorPath`\n\n, the script path inside `ProgramArguments`\n\n— write them all in `/Users/username/...`\n\nabsolute path form.\n\nThe default PATH is basically `/usr/bin:/bin`\n\n. Unless you explicitly include `/opt/homebrew/bin`\n\n, the nvm-managed Node.js path, and `~/.local/bin`\n\n, it's no surprise when a tool your script depends on stops being found. The fastest way to get the PATH is from `echo $PATH`\n\nin the shell you're currently working in.\n\n`git commit`\n\nsuccess directly with `git rev-parse HEAD`\n\nDon't trust the exit code. Exit code propagation is unstable depending on commit-msg hooks, pre-commit hooks, and the hook implementation. Comparing `PREV_HEAD`\n\nand `NEW_HEAD`\n\nto directly confirm \"did HEAD advance\" is the only reliable means.\n\n```\nPREV_HEAD=$(git rev-parse --verify --quiet HEAD || echo \"\")\ngit commit -m \"chore(snapshot): ...\" >>\"$LOGFILE\" 2>&1\nNEW_HEAD=$(git rev-parse --verify --quiet HEAD || echo \"\")\n[ -z \"$NEW_HEAD\" ] || [ \"$NEW_HEAD\" = \"$PREV_HEAD\" ] && exit 3\n```\n\nAssigning `exit 1`\n\n(secret detected), `exit 2`\n\n(commit failure), and `exit 3`\n\n(HEAD didn't move) means you can identify the cause type just by dividing `launchctl list`\n\n's `LastExitStatus`\n\nby 256. If you only use a generic `exit 1`\n\n, you have to read through logs afterward to trace \"what did it fail on.\"\n\n```\ngrep -qE 'sk-[A-Za-z0-9_-]{30,}|ghp_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}'\n```\n\nA generic pattern like \"30+ alphanumeric characters\" also catches Base64-encoded config values. Narrowing by prefix — `sk-*`\n\n(Anthropic), `ghp_*`\n\n(GitHub PAT), `AKIA*`\n\n(AWS) — covers the substantive risk while keeping false positives near zero.\n\nCatch zsh startup errors with `StandardOutPath`\n\n, and send the script's internal progress to a separate file with `>> \"$LOGFILE\"`\n\n. This two-path separation lets you track \"did the script start\" and \"what happened inside the script\" independently. The first step in troubleshooting is: `launchctl list`\n\n→ check `LastExitStatus`\n\n→ check `dotfiles-snapshot.log`\n\n.\n\n`set -uo pipefail`\n\nand deliberately leave out `-e`\n\nUndefined variables (`-u`\n\n) and pipeline interruptions (`-o pipefail`\n\n) should kill the script. But `-e`\n\ndoesn't play well with a secret check where `grep -q`\n\ntreats \"pattern not found (exit 1)\" as normal, or with first-init handling that has a `git rev-parse ... || echo \"\"`\n\nfallback. Leaving `-e`\n\nout and explicitly handling non-zero returns with `if`\n\nmakes the intent clearer.\n\nIt's important to keep copying the rest even when 1 of 11 items fails. Proceed to the next item without checking rsync's exit code, and record errors with `2>>\"$LOGFILE\"`\n\n. But note that in this design, even a total failure of every item ends in `no changes`\n\n. Check the log for `Operation not permitted`\n\nperiodically.\n\n`launchctl start`\n\nand check LastExitStatus with `launchctl list`\n\nWhen you change the plist, test immediately with the `unload → load → start`\n\ncycle. If `launchctl list com.shun.dotfiles-snapshot`\n\n's `LastExitStatus`\n\nisn't 0, trace the cause in this order: divide by 256 to identify the type → check the log. Waiting for the next scheduled run is a waste of time.\n\n`Nice=10`\n\nand `LowPriorityIO=true`\n\nas a set\n\n```\n<key>LowPriorityIO</key>  <true/>\n<key>Nice</key>           <integer>10</integer>\n<key>ProcessType</key>    <string>Background</string>\n```\n\n`Nice=10`\n\nalone only lowers CPU priority. If you omit `LowPriorityIO: true`\n\n, IO runs at normal priority and competes with other writes on a freshly booted Mac. For background backups, specify all three as a set.\n\n`ls ~/.claude/`\n\nEvery time a new directory appears in `~/.claude/`\n\n, it needs to be added to the INCLUDE list. Either build the habit of comparing `ls ~/.claude/`\n\nagainst the list monthly, or run this command periodically:\n\n```\n# INCLUDE未収録のディレクトリを抽出する例\ncomm -23 <(ls ~/.claude/ | sort) \\\n         <(echo -e \"CLAUDE.md\\nhooks\\ncommands\\nagents\\nskills\\nscripts\\nrules\\nimprovements\\nconfig-snapshots\\nlogs\\nsettings.json\\nsettings.local.json\" | sort)\n```\n\n`rm -rf \"$DST\"`\n\nRetesting after deleting only `.git/`\n\ncreates a \"partially initialized\" state and produces bugs that don't reproduce in production. Always start tests from a state where all of `$DST`\n\nhas been deleted. Since production launchd always starts from `mkdir -p \"$DST\"`\n\n, this fully reproduces that condition.\n\nThe core thing I wanted to convey in this article is **the structure of the \"looks successful but actually failed\" trap**.\n\nmacOS TCC shows GUI apps a dialog asking \"allow access?\" The user learns that the app is trying to write to `~/Documents/`\n\n. But a launchd background process has no such feedback. `Operation not permitted`\n\nflows into the log as an rsync error, the script proceeds to the next item, and even with every item failing it records `no changes`\n\nand exits normally. launchd's LastExitStatus is 0. The signal conveyed to the user is zero.\n\n`git commit`\n\n's exit code had the same structure. Even when a hook rejects, it can look like exit 0. Even when the log says `commit=a1b2c3d`\n\n, that hash may not exist in `git log`\n\n. A log record saying \"succeeded\" does not guarantee that it actually succeeded.\n\nThe common remedy for both traps is \"confirm facts that are verifiable from the outside.\" Not \"did rsync succeed\" but \"does the file exist at the destination.\" Not \"did the git commit succeed\" but \"did HEAD advance,\" confirmed with `git rev-parse`\n\n. \"The command returned exit 0\" is not the same as \"the intended side effect happened.\"\n\nOne of the reasons I spent half a year building out my Claude Code autonomous environment was to find and eliminate these \"silent failures\" one by one. The environment rests less on flashy AI usage than on an accumulation of unglamorous defensive lines. The line left in the script's header comment — *the old ~/Documents/claude-config-snapshots hit macOS TCC \"Operation not permitted\" when run under launchd and every copy failed, so it was relocated on 2026-06-01* — is the lesson learned from three weeks of silent failure.\n\nThe code is simple. `dotfiles-snapshot.sh`\n\nis 112 lines: rsync over the INCLUDE list, the secrets check, the HEAD-advancement check, and log output. That's it. The complexity came not from the design but from accurately understanding macOS behavior.\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/3-weeks-of-silent-backup-failure-why-launchd-can-t-write-to-documents-on-macos", "canonical_source": "https://dev.to/bokuwalily/3-weeks-of-silent-backup-failure-why-launchd-cant-write-to-documents-on-macos-4ecm", "published_at": "2026-08-19 05:00:06+00:00", "updated_at": "2026-08-19 05:12:40.913357+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Claude Code", "macOS", "launchd", "rsync", "git"], "alternates": {"html": "https://wpnews.pro/news/3-weeks-of-silent-backup-failure-why-launchd-can-t-write-to-documents-on-macos", "markdown": "https://wpnews.pro/news/3-weeks-of-silent-backup-failure-why-launchd-can-t-write-to-documents-on-macos.md", "text": "https://wpnews.pro/news/3-weeks-of-silent-backup-failure-why-launchd-can-t-write-to-documents-on-macos.txt", "jsonld": "https://wpnews.pro/news/3-weeks-of-silent-backup-failure-why-launchd-can-t-write-to-documents-on-macos.jsonld"}}