#!/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 <&2 exit 1