On 23 September 2026 at 01:20, a 15-minute auto-commit job pushed a stale copy of my coding agents’ rule folder: 3 of 28 rules deleted, 12 rolled back to older text. A guard now runs before every auto-commit and refuses that shape; a replay of the failure prints BLOCKED and exits 1.
Does this guide apply to you?
- A timer commits and pushes the repo that holds your agent rules: yes.
- You use chezmoi with
git.autoCommit = true: yes, read the root cause first. - You commit your dotfiles by hand: less so. You see the diff before the commit, and the guard runs only if you call it yourself.
The setup
My agents read their global rules from ~/.agents/rules. ~/.agents is a symlink into my chezmoi source repo. A timer ran every 15 minutes: chezmoi re-add, commit, push. Every machine pulls that repo and regenerates each agent’s rule files from it.
Timeline (+07, from git log and the incident note of that night)
- 00:06 Last good commit: 28 rules.
- 01:18 A sync script on my laptop mirrored its own older
~/.agentsto the server withrsync -az --delete. Through the symlink it overwrote the repo. - 01:20:00 The timer committed and pushed it: 30 files, 706 lines added, 942 deleted. Rules went from 28 to 25.
- 01:23 The loss showed up while a new rule was being added.
- 01:28
git revertrestored all 28.
Root cause
Two things lined up. rsync --delete wrote a stale tree through a symlink into a git working tree. And chezmoi’s git.autoCommit = true with autoPush = true made chezmoi re-add commit and push the whole source before my script ran anything.
My first guard sat after re-add, so it never saw the change. Testing it at 01:50, chezmoi committed and pushed a deleted rule by itself: Remove agents/rules/anti-overengineering.md.
The guardrail
autoCommit = falseandautoPush = false, committed at 01:52. The sync script is now the only automatic commit path.- The guard runs after
git add -Aand before the commit. It blocks a deleted or renamed file under protected paths, more than 15 changed files, a modified file whose mtime is older than its last commit, a file that shrank below half, secret-looking names and backup files.
The mtime check catches copies. rsync -a, rclone or a restore keeps the old mtime; a real edit has a new one.
while IFS=$'\t' read -r st path newpath; do
[ -n "$st" ] || continue
count=$((count+1))
case "$st" in
D*) protected "$path" && reasons+=("deletes protected file: $path") ;;
R*) protected "$path" && reasons+=("renames protected file: $path -> $newpath") ;;
M)
# A modified file whose mtime predates the commit that last touched it was
# copied in from an older snapshot (rsync -a, rclone, restore), not edited.
if [ -f "$path" ]; then
last="$(git log -1 --format=%ct -- "$path" 2>/dev/null || echo 0)"
[ "$(mtime_of "$path")" -lt "$last" ] && reasons+=("$path: mtime older than its last commit (stale copy?)")
old_lines="$(git show "HEAD:$path" 2>/dev/null | wc -l)"
new_lines="$(wc -l <"$path")"
if [ "$old_lines" -ge "$SHRINK_MIN_LINES" ] && [ $((new_lines * 2)) -lt "$old_lines" ]; then
reasons+=("$path: shrank from $old_lines to $new_lines lines (truncated copy?)")
fi
fi ;;
esac
# … secret-name and backup-file checks, then the file-count check
How to check it blocks
Replay the failure in a throwaway repo: commit three rules, then rsync -a --delete an older copy over them with an August mtime and one rule missing. Save the full guard (67 lines) as guard.sh next to this script.
#!/usr/bin/env bash
# Reproduce the 23 Sep 2026 failure in a throwaway repo and show the guard refusing it.
# Usage: bash demo-stale-copy.sh (needs git, rsync; guard.sh = copy of the real guard)
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
git init -q "$t/repo"; cd "$t/repo"
git config user.email demo@example.com; git config user.name demo
mkdir -p agents/rules
for r in tdd review banned-typefaces; do printf '# %s\nline 2 of the current rule\n' "$r" > agents/rules/$r.md; done
git add -A; git commit -qm "current rules"
# A stale copy from another machine: older text, older mtime, one rule missing.
mkdir -p "$t/stale/rules"
for r in tdd review; do printf '# %s\nold line 2\n' "$r" > "$t/stale/rules/$r.md"; done
touch -d '2026-08-26 10:00' "$t"/stale/rules/*.md
rsync -a --delete "$t/stale/rules/" agents/rules/
git add -A
echo "\$ git diff --cached --name-status"; git diff --cached --name-status
echo "\$ bash guard.sh; echo exit=\$?"
set +e; bash "$here/guard.sh" .; echo "exit=$?"
$ git diff --cached --name-status
D agents/rules/banned-typefaces.md
M agents/rules/review.md
M agents/rules/tdd.md
$ bash guard.sh; echo exit=$?
chezmoi-sync-guard: BLOCKED — working tree looks wrong, nothing committed or pushed
- deletes protected file: agents/rules/banned-typefaces.md
- agents/rules/review.md: mtime older than its last commit (stale copy?)
- agents/rules/tdd.md: mtime older than its last commit (stale copy?)
exit=1
Two lines with local paths, before exit=1, were cut. The guard’s own test script covers 11 cases:
$ bash bin/test-chezmoi-sync-guard.sh | tail -1
passed=11 failed=0
What it costs
- An intended mass change, such as renaming many rules, is blocked too. Run it once by hand with
CHEZMOI_SYNC_ALLOW_MASS=1after readinggit diff --cached --stat. - A copy that sets new mtimes, such as
cpwithout-p, passes the mtime check. The deletion, file-count and size checks still apply.