Files
UmbraAudio/scripts/commit_check.sh
2026-10-18 19:01:04 -07:00

165 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
#
# 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] [--any] [--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
ANY=0
usage() {
cat <<'USAGE'
commit_check.sh — warn when uncommitted work is older than the threshold.
Usage:
scripts/commit_check.sh [--days N] [--any] [--quiet]
Options:
--days N Threshold in days (default: 7).
--any Warn on ANY uncommitted change, ignoring the age threshold
(used by the weekly Sunday reminder job).
--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 ;;
--any) ANY=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 [ "$ANY" -eq 0 ] && [ "$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)"
if [ "$ANY" -eq 1 ]; then
echo " Trigger: any uncommitted change (--any)"
fi
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