165 lines
5.0 KiB
Bash
Executable File
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
|