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:
@@ -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
|
||||
|
||||
@@ -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
154
scripts/commit_check.sh
Executable 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
|
||||
Reference in New Issue
Block a user