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:
14
AGENTS.md
14
AGENTS.md
@@ -12,8 +12,18 @@
|
||||
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.
|
||||
working-tree dirt, and prints a reminder block. Options: `--days N`, `--any` (warn on any
|
||||
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
|
||||
- **macOS 26.6.2, home relocated**: user `worstauthor`'s home dir is
|
||||
|
||||
@@ -37,11 +37,13 @@ pytest tests/ -v
|
||||
> 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.
|
||||
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
|
||||
scripts/commit_check.sh # warn if dirty > 7 days
|
||||
scripts/commit_check.sh --days 3 # tighter threshold
|
||||
scripts/commit_check.sh --any # warn on any uncommitted change
|
||||
```
|
||||
|
||||
## Rebuilding from source
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# by hand, or wire it to launchd/cron for a weekly reminder.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/commit_check.sh [--days N] [--quiet]
|
||||
# scripts/commit_check.sh [--days N] [--any] [--quiet]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 clean, or uncommitted work is within the threshold
|
||||
@@ -22,16 +22,19 @@ 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] [--quiet]
|
||||
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.
|
||||
|
||||
@@ -51,6 +54,7 @@ while [ "$#" -gt 0 ]; do
|
||||
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 ;;
|
||||
@@ -126,7 +130,7 @@ else
|
||||
NEWEST_DAYS=""
|
||||
fi
|
||||
|
||||
if [ "$AGE_DAYS" -le "$DAYS" ]; then
|
||||
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
|
||||
@@ -136,6 +140,9 @@ 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"
|
||||
|
||||
155
scripts/commit_check_notify.py
Normal file
155
scripts/commit_check_notify.py
Normal 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())
|
||||
Reference in New Issue
Block a user