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.
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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