Add scripts/commit_check.sh weekly commit-cadence guard

Mechanical nudge for the policy in AGENTS.md -> "Commit Cadence": it measures
how long the working tree has been dirty (oldest mtime among staged, unstaged
and untracked files) and exits 1 once that exceeds --days (default 7).

- Ignores submodule working-tree dirt (separate repo); still surfaces a moved
  submodule pointer.
- Reports dirty age, last edit, last commit; prints a reminder block.
- --quiet silences the clean case only; --help documents exit codes.
- Bash 3.2 compatible (macOS /bin/bash); no external deps.
- Verified: clean/dirty/old-dirt/threshold/bad-args/quiet all behave.

Also cross-reference it from AGENTS.md and README.md.
This commit is contained in:
B.D. Murphy
2026-09-21 07:04:52 -07:00
parent df36dad8aa
commit 2ea2b7c065
3 changed files with 166 additions and 0 deletions

View File

@@ -10,6 +10,10 @@
`umbraaudio/web/static/samples/` A/B demo are intentionally ignored (see `.gitignore`);
never `git add -A` without checking, and never leave a dirty `umbraaudio/web/routes.py`
for days.
- Mechanical nudge: **`scripts/commit_check.sh`** exits non-zero (1) when the tree has been
dirty longer than a week — it measures the *oldest* uncommitted edit, ignores submodule
working-tree dirt, and prints a reminders block. Options: `--days N`, `--quiet`. Run it by
hand (`scripts/commit_check.sh`) or wire it to launchd/cron for a weekly reminder.
## System & Deployment Notes
- **macOS 26.6.2, home relocated**: user `worstauthor`'s home dir is

View File

@@ -36,6 +36,14 @@ pytest tests/ -v
> month) of work uncommitted — a single accidental `git checkout` on 2026-09-20 wiped out a
> month of changes to `umbraaudio/web/routes.py`. See `AGENTS.md` → *Commit Cadence*.
`scripts/commit_check.sh` enforces the nudge: it exits non-zero once the tree has been dirty
longer than a week.
```bash
scripts/commit_check.sh # warn if dirty > 7 days
scripts/commit_check.sh --days 3 # tighter threshold
```
## Rebuilding from source
The repo tracks source only; large runtime data is reconstructed as needed.

154
scripts/commit_check.sh Executable file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
#
# commit_check.sh — warn when the working tree has been dirty too long.
#
# UmbraAudio convention (AGENTS.md -> "Commit Cadence"): commit at least once a
# week, on Sunday. On 2026-09-20 a single `git checkout --` wiped a month of
# uncommitted edits to umbraaudio/web/routes.py; it survived only because the
# editor kept a snapshot. Uncommitted work has no such guarantee.
#
# The script measures how long the tree has been dirty (the oldest edit among
# uncommitted files) and exits non-zero once it exceeds the threshold. Run it
# by hand, or wire it to launchd/cron for a weekly reminder.
#
# Usage:
# scripts/commit_check.sh [--days N] [--quiet]
#
# Exit codes:
# 0 clean, or uncommitted work is within the threshold
# 1 uncommitted work is overdue — commit now
# 2 not a git repository / bad usage
set -u
DAYS=7
QUIET=0
usage() {
cat <<'USAGE'
commit_check.sh — warn when uncommitted work is older than the threshold.
Usage:
scripts/commit_check.sh [--days N] [--quiet]
Options:
--days N Threshold in days (default: 7).
--quiet, -q Print nothing when the tree is clean.
-h, --help Show this help.
Exit codes:
0 clean, or uncommitted work is within the threshold
1 uncommitted work is overdue — commit now
2 not a git repository / bad usage
Policy: commit at least once a week, on Sunday (AGENTS.md -> "Commit Cadence").
USAGE
}
while [ "$#" -gt 0 ]; do
case "$1" in
--days)
DAYS="${2:-}"
if [ "$#" -ge 2 ]; then shift 2; else shift; fi
;;
--days=*) DAYS="${1#*=}"; shift ;;
--quiet|-q) QUIET=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "commit_check.sh: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
case "$DAYS" in
''|*[!0-9]*) echo "commit_check.sh: --days must be a non-negative integer" >&2; exit 2 ;;
esac
if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; then
echo "commit_check.sh: not inside a git repository" >&2
exit 2
fi
cd "$REPO_ROOT" || exit 2
# Submodule working-tree dirt is a separate repo; --ignore-submodules=dirty hides
# it while still surfacing a moved submodule pointer (which IS ours to commit).
DIRTY="$(git status --porcelain --ignore-submodules=dirty)"
if [ -z "$DIRTY" ]; then
[ "$QUIET" -eq 0 ] && echo "commit_check: working tree clean ($REPO_ROOT)."
exit 0
fi
CHANGED="$(printf '%s\n' "$DIRTY" | grep -c .)"
NOW="$(date +%s)"
stat_mtime() { stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || echo 0; }
# Union of staged, unstaged and untracked paths (deletions are skipped below).
FILES="$(
{ git diff --ignore-submodules=dirty --name-only
git diff --cached --ignore-submodules=dirty --name-only
git ls-files -o --exclude-standard
} | sort -u
)"
# Oldest uncommitted edit ~ how long the tree has been dirty; newest = last touch.
oldest=0
newest=0
while IFS= read -r path; do
[ -n "$path" ] || continue
[ -e "$path" ] || continue
m="$(stat_mtime "$path")"
case "$m" in ''|*[!0-9]*) continue ;; esac
[ "$m" -gt 0 ] || continue
if [ "$oldest" -eq 0 ] || [ "$m" -lt "$oldest" ]; then oldest="$m"; fi
if [ "$m" -gt "$newest" ]; then newest="$m"; fi
done <<EOF
$FILES
EOF
LAST_EPOCH="$(git log -1 --format=%ct 2>/dev/null || echo 0)"
case "$LAST_EPOCH" in ''|*[!0-9]*) LAST_EPOCH=0 ;; esac
if [ "$LAST_EPOCH" -gt 0 ]; then
LAST_DAYS="$(( (NOW - LAST_EPOCH) / 86400 ))"
else
LAST_DAYS=""
fi
if [ "$oldest" -gt 0 ]; then
AGE_DAYS="$(( (NOW - oldest) / 86400 ))"
AGE_SOURCE="oldest uncommitted edit"
elif [ "$LAST_EPOCH" -gt 0 ]; then
AGE_DAYS="$LAST_DAYS"
AGE_SOURCE="last commit (no file mtimes available)"
else
AGE_DAYS=999999
AGE_SOURCE="repository has no commits"
fi
if [ "$newest" -gt 0 ]; then
NEWEST_DAYS="$(( (NOW - newest) / 86400 ))"
else
NEWEST_DAYS=""
fi
if [ "$AGE_DAYS" -le "$DAYS" ]; then
if [ "$QUIET" -eq 0 ]; then
echo "commit_check: $CHANGED uncommitted path(s); dirty ${AGE_DAYS}d (threshold ${DAYS}d) - OK."
fi
exit 0
fi
{
echo "commit_check: COMMIT REMINDER"
echo " Working tree: $CHANGED uncommitted path(s)"
echo " Dirty for: ${AGE_DAYS} day(s) ($AGE_SOURCE)"
if [ -n "$NEWEST_DAYS" ]; then
echo " Last edit: ${NEWEST_DAYS} day(s) ago"
fi
if [ -n "$LAST_DAYS" ]; then
echo " Last commit: ${LAST_DAYS} day(s) ago"
fi
echo
echo " Policy: commit at least once a week, on Sunday (AGENTS.md)."
echo " Uncommitted work has no backup - a single 'git checkout' on 2026-09-20"
echo " wiped a month of edits to umbraaudio/web/routes.py."
echo
echo " Next: git status && git add -A && git commit"
echo " (book data, tmp_out/, logs and static/samples/ are gitignored)"
} >&2
exit 1