Add weekly launchd commit reminder (com.umbraaudio.commitcheck)

Wires the commit-cadence policy to an actual reminder instead of a script
nobody runs.

- scripts/commit_check_notify.py: runs scripts/commit_check.sh (piped to
  bash -s so the script is never opened on the external volume), logs to
  logs/commit_check.log and posts a macOS notification via osascript when the
  tree is dirty. Defaults to --any (week's work) ; --age-only restores the
  >threshold behaviour; --no-notify for testing.
- scripts/commit_check.sh: new --any mode (warn on any uncommitted change,
  ignoring age) for the weekly job.
- LaunchAgent fires Sunday 18:00; launcher
  ~/.umbraaudio/umbra_commit_check.sh mirrors umbraweb_login.sh (cd project,
  export PATH, exec .venv/bin/python). The venv python is the only interpreter
  approved for the external volume, so a shell never reads a project file.
- Verified under launchd: detects dirty tree, notification sent, exit 1;
  clean tree exits 0. Probe artifacts removed.
This commit is contained in:
B.D. Murphy
2026-09-21 07:09:52 -07:00
parent 2ea2b7c065
commit 5ed9db7871
4 changed files with 180 additions and 6 deletions

View File

@@ -12,8 +12,18 @@
for days. for days.
- Mechanical nudge: **`scripts/commit_check.sh`** exits non-zero (1) when the tree has been - 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 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 working-tree dirt, and prints a reminder block. Options: `--days N`, `--any` (warn on any
hand (`scripts/commit_check.sh`) or wire it to launchd/cron for a weekly reminder. dirt, ignoring age), `--quiet`, `--help`. Run it by hand or via the reminder below.
- Weekly reminder (installed): LaunchAgent **`com.umbraaudio.commitcheck`** fires **Sunday
18:00** and posts a macOS notification when the tree is dirty. Plist loaded from
`/Users/worstauthor/Library/LaunchAgents/com.umbraaudio.commitcheck.plist` and mirrored to
`/Volumes/Dock/worstauthor/Library/LaunchAgents/` (login-scanned). It runs
`/bin/zsh /Users/worstauthor/.umbraaudio/umbra_commit_check.sh` → `cd` project →
`exec .venv/bin/python scripts/commit_check_notify.py`. As with the web server, **a shell
never opens an external-volume file**: the venv python reads the repo, pipes
`scripts/commit_check.sh` to `bash -s` (so the script itself is never opened on the volume),
and shells `osascript` for the notification. Logs to `logs/commit_check.log`; exit 1 = dirty
(deliberate). Test with `launchctl kickstart -k gui/$(id -u)/com.umbraaudio.commitcheck`.
## System & Deployment Notes ## System & Deployment Notes
- **macOS 26.6.2, home relocated**: user `worstauthor`'s home dir is - **macOS 26.6.2, home relocated**: user `worstauthor`'s home dir is

View File

@@ -37,11 +37,13 @@ pytest tests/ -v
> month of changes to `umbraaudio/web/routes.py`. See `AGENTS.md` → *Commit Cadence*. > 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 `scripts/commit_check.sh` enforces the nudge: it exits non-zero once the tree has been dirty
longer than a week. longer than a week. The LaunchAgent `com.umbraaudio.commitcheck` runs it every **Sunday at
18:00** and posts a macOS notification when there is uncommitted work.
```bash ```bash
scripts/commit_check.sh # warn if dirty > 7 days scripts/commit_check.sh # warn if dirty > 7 days
scripts/commit_check.sh --days 3 # tighter threshold scripts/commit_check.sh --days 3 # tighter threshold
scripts/commit_check.sh --any # warn on any uncommitted change
``` ```
## Rebuilding from source ## Rebuilding from source

View File

@@ -12,7 +12,7 @@
# by hand, or wire it to launchd/cron for a weekly reminder. # by hand, or wire it to launchd/cron for a weekly reminder.
# #
# Usage: # Usage:
# scripts/commit_check.sh [--days N] [--quiet] # scripts/commit_check.sh [--days N] [--any] [--quiet]
# #
# Exit codes: # Exit codes:
# 0 clean, or uncommitted work is within the threshold # 0 clean, or uncommitted work is within the threshold
@@ -22,16 +22,19 @@ set -u
DAYS=7 DAYS=7
QUIET=0 QUIET=0
ANY=0
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
commit_check.sh — warn when uncommitted work is older than the threshold. commit_check.sh — warn when uncommitted work is older than the threshold.
Usage: Usage:
scripts/commit_check.sh [--days N] [--quiet] scripts/commit_check.sh [--days N] [--any] [--quiet]
Options: Options:
--days N Threshold in days (default: 7). --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. --quiet, -q Print nothing when the tree is clean.
-h, --help Show this help. -h, --help Show this help.
@@ -51,6 +54,7 @@ while [ "$#" -gt 0 ]; do
if [ "$#" -ge 2 ]; then shift 2; else shift; fi if [ "$#" -ge 2 ]; then shift 2; else shift; fi
;; ;;
--days=*) DAYS="${1#*=}"; shift ;; --days=*) DAYS="${1#*=}"; shift ;;
--any) ANY=1; shift ;;
--quiet|-q) QUIET=1; shift ;; --quiet|-q) QUIET=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "commit_check.sh: unknown argument: $1" >&2; usage >&2; exit 2 ;; *) echo "commit_check.sh: unknown argument: $1" >&2; usage >&2; exit 2 ;;
@@ -126,7 +130,7 @@ else
NEWEST_DAYS="" NEWEST_DAYS=""
fi fi
if [ "$AGE_DAYS" -le "$DAYS" ]; then if [ "$ANY" -eq 0 ] && [ "$AGE_DAYS" -le "$DAYS" ]; then
if [ "$QUIET" -eq 0 ]; then if [ "$QUIET" -eq 0 ]; then
echo "commit_check: $CHANGED uncommitted path(s); dirty ${AGE_DAYS}d (threshold ${DAYS}d) - OK." echo "commit_check: $CHANGED uncommitted path(s); dirty ${AGE_DAYS}d (threshold ${DAYS}d) - OK."
fi fi
@@ -136,6 +140,9 @@ fi
{ {
echo "commit_check: COMMIT REMINDER" echo "commit_check: COMMIT REMINDER"
echo " Working tree: $CHANGED uncommitted path(s)" 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)" echo " Dirty for: ${AGE_DAYS} day(s) ($AGE_SOURCE)"
if [ -n "$NEWEST_DAYS" ]; then if [ -n "$NEWEST_DAYS" ]; then
echo " Last edit: ${NEWEST_DAYS} day(s) ago" echo " Last edit: ${NEWEST_DAYS} day(s) ago"

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Weekly commit reminder for the UmbraAudio working tree.
Invoked by ``~/.umbraaudio/umbra_commit_check.sh`` from the launchd job
``com.umbraaudio.commitcheck``. Under macOS's external-volume TCC policy only
the project's venv python may read ``/Volumes/1TB_APL_Storage``, so this file
(and ``commit_check.sh``, which it pipes to bash) is opened by python, never by
a shell.
The actual test lives in ``scripts/commit_check.sh`` — this module only
schedules it, records the outcome in ``logs/commit_check.log`` and raises a
macOS notification when the tree is dirty.
"""
from __future__ import annotations
import argparse
import logging
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CHECK_SCRIPT = REPO_ROOT / "scripts" / "commit_check.sh"
LOG_PATH = REPO_ROOT / "logs" / "commit_check.log"
SAFE_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
LOGGER = logging.getLogger("umbraaudio.commit_check")
def _configure_logging() -> None:
"""Log to stderr and to logs/commit_check.log (best effort)."""
LOGGER.setLevel(logging.INFO)
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
stream = logging.StreamHandler(sys.stdout)
stream.setFormatter(formatter)
LOGGER.addHandler(stream)
try:
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(LOG_PATH)
file_handler.setFormatter(formatter)
LOGGER.addHandler(file_handler)
except OSError: # pragma: no cover - logging must never break the reminder
LOGGER.warning("cannot write %s", LOG_PATH, exc_info=True)
def _applescript_string(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def send_notification(title: str, subtitle: str, message: str) -> bool:
"""Post a macOS notification; returns False when osascript is unavailable."""
parts = [
f"display notification {_applescript_string(message)}",
f"with title {_applescript_string(title)}",
]
if subtitle:
parts.append(f"subtitle {_applescript_string(subtitle)}")
try:
result = subprocess.run(
["/usr/bin/osascript", "-e", " ".join(parts)],
capture_output=True,
timeout=20,
)
except (OSError, subprocess.TimeoutExpired):
LOGGER.warning("could not run osascript", exc_info=True)
return False
if result.returncode != 0:
LOGGER.warning(
"osascript exited %s: %s",
result.returncode,
result.stderr.decode(errors="replace").strip(),
)
return False
return True
def run_check(days: int, any_dirty: bool) -> subprocess.CompletedProcess[bytes]:
"""Run scripts/commit_check.sh via ``bash -s`` (stdin avoids the volume)."""
args = ["/bin/bash", "-s", "--"]
if any_dirty:
args.append("--any")
args += ["--days", str(days)]
env = dict(os.environ)
env["PATH"] = SAFE_PATH
return subprocess.run(
args,
input=CHECK_SCRIPT.read_bytes(),
cwd=str(REPO_ROOT),
capture_output=True,
env=env,
)
def main(argv: list[str] | None = None) -> int:
"""Run the reminder; returns the check's exit code (0 clean, 1 dirty, 2 error)."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--days", type=int, default=7, help="age threshold in days")
parser.add_argument(
"--age-only",
action="store_true",
help="only remind when the tree has been dirty longer than --days",
)
parser.add_argument(
"--no-notify",
action="store_true",
help="log the result but do not post a notification",
)
args = parser.parse_args(argv)
any_dirty = not args.age_only
_configure_logging()
LOGGER.info(
"commit check starting (repo=%s, days=%d, any=%s)",
REPO_ROOT,
args.days,
any_dirty,
)
try:
result = run_check(args.days, any_dirty)
except OSError:
LOGGER.exception("could not execute scripts/commit_check.sh")
return 2
output = result.stderr.decode(errors="replace").strip()
if result.returncode == 0:
LOGGER.info("clean / within threshold")
return 0
if result.returncode == 2:
LOGGER.error("commit_check.sh failed (rc=2): %s", output)
return 2
LOGGER.warning("uncommitted work detected:\n%s", output)
if not args.no_notify:
dirty_line = next(
(line.strip() for line in output.splitlines()
if line.strip().lower().startswith("dirty for")),
"",
)
sent = send_notification(
title="UmbraAudio: commit reminder",
subtitle="Uncommitted work",
message=dirty_line or "The working tree is dirty - commit your work.",
)
LOGGER.info("notification %s", "sent" if sent else "not sent")
return 1
if __name__ == "__main__":
raise SystemExit(main())