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.
156 lines
5.0 KiB
Python
156 lines
5.0 KiB
Python
#!/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())
|