diff --git a/docs/config-schema.md b/docs/config-schema.md index 27797d6..9d0b107 100644 --- a/docs/config-schema.md +++ b/docs/config-schema.md @@ -1671,6 +1671,54 @@ tests/smoke never touch the real launchd domain or delete the predecessor file. --- +## `internal_dev` + +For internal development of the rig-ecosystem tools themselves (rig, tg-cli, review-cli, …): when a +repo is developed IN PLACE — the checked-out files ARE the running binary (a live symlink) or a +long-running daemon reads them — a code change only takes effect after the daemon is restarted. This +block wires that restart to the commit: an opt-in `post-commit` git hook that, when a commit touches +the configured daemon-source paths, runs a graceful reload command (`tg-ctl restart` by default). +This is a per-REPO, **committed** concern (the enablement + source paths travel with the repo's +`rig.yaml`, exactly like `agent_hooks.worktree_only`) — **not** the global config. **Default OFF** +(an absent or empty `internal_dev:` block installs nothing). + +```yaml +internal_dev: + auto_reload_on_commit: true # opt-in; default false + daemon_source_paths: ["src/daemon/*", "bin/tg-ctl"] # POSIX `case` globs, repo-relative + reload_command: "tg-ctl restart" # default shown; any shell command +``` + +| Key | Type | Default | Meaning | +|-----|------|---------|---------| +| `auto_reload_on_commit` | bool | `false` | install a `post-commit` hook that reloads the daemon when a commit touches a daemon-source path | +| `daemon_source_paths` | array of str | `[]` | repo-relative shell-glob patterns (POSIX `case`, a `*` spans `/`) whose change in a commit triggers the reload | +| `reload_command` | str | `tg-ctl restart` | the graceful reload command the hook runs on a match | + +**What rig writes.** The repo-local `/hooks/post-commit` (worktree-correct — resolved via +`git rev-parse --git-common-dir`, not assumed to be `/.git`; a linked worktree's hooks live in +the repo's COMMON dir, not its private per-worktree admin dir). When a global `core.hooksPath` +composer shadows repo-local hooks (the rig global-hook dispatcher sets one machine-wide), rig also +writes a generic `post-commit` COMPOSER trampoline into that composer dir — the agent-tools +dispatcher ships no `post-commit` of its own, so without the trampoline a composer-shadowed +repo-local hook would never fire. + +**The reload never blocks a commit.** `post-commit` runs after the commit is recorded; a failed +reload is reported and swallowed — a broken daemon must never wedge a developer's commit. + +**Idempotent, marker-keyed, backup-on-conflict** — like every other rig-managed artifact +(`on_conflict` applies to both the hook and the composer). + +**Drift.** `rig status` flags a missing or modified `post-commit` hook under the `internal_dev` +category (REPO layer). + +**Dry-run seam.** `RIG_DEV_RELOAD_DRY_RUN=1` (mirrors `RIG_TG_CTL_DRY_RUN` / `RIG_TMUX_DRY_RUN`): +the hook reads it at FIRE time and skips the real reload; the runner reads it at APPLY time and +skips writing the machine-global composer (the one live/global mutation) — so tests/CI never fire a +real reload nor touch the real global hooks dir. + +--- + ## Editing this config — `rig setup` (wizard) and `rig config get|set` `rig setup` is the **interactive configuration wizard**. In a terminal it (1) SHOWS what is diff --git a/riglib/actions/runner.py b/riglib/actions/runner.py index 19faea2..7c618e4 100644 --- a/riglib/actions/runner.py +++ b/riglib/actions/runner.py @@ -5696,6 +5696,49 @@ def _do_provision_spotlight(action: Action, on_conflict: str) -> ActionResult: return ActionResult(action, "created", "; ".join(notes), out.backup) +def _do_install_dev_reload_hook(action: Action, on_conflict: str) -> ActionResult: + """Install the per-repo post-commit daemon auto-reload hook (+ global composer trampoline). + + Writes the repo-local ``/hooks/post-commit`` at the resolved (worktree-correct) path + unconditionally — it is the managed, reviewable artifact. Honors ``dev_reload.DRY_RUN_ENV``: + when set, the machine-global composer trampoline (the one live/global mutation, needed only + when a ``core.hooksPath`` composer shadows the repo-local hook) is skipped, mirroring + ``RIG_TG_CTL_DRY_RUN`` / ``RIG_TMUX_DRY_RUN``. + """ + from .. import dev_reload + + repo_root = action.source + dplan = dev_reload.build_dev_reload( + repo_root=repo_root, + daemon_source_paths=action.options.get("daemon_source_paths", []), + reload_command=action.options.get("reload_command", dev_reload.DEFAULT_RELOAD_COMMAND), + ) + hook_target = dev_reload.post_commit_hook_path(repo_root) + out = fsutil.write_file(hook_target, dplan.render_hook(), on_conflict) + _chmod_x_if_changed(hook_target, out) + notes = [f"post-commit hook {out.detail}"] + status, backup = out.status, out.backup + + if os.environ.get(dev_reload.DRY_RUN_ENV): + return ActionResult(action, status, "; ".join(notes), backup) + + composer_target = dev_reload.composer_post_commit_path(repo_root) + if composer_target is not None: + c_out = fsutil.write_file(composer_target, dev_reload.render_post_commit_composer(), on_conflict) + _chmod_x_if_changed(composer_target, c_out) + notes.append(f"composer {c_out.detail}") + # Surface the composer's own outcome — it is the one machine-global mutation this + # action makes. A backup there must not be silently dropped (per the backup-on-conflict + # rule), and a composer-only change (hook unchanged, composer newly written/backed up) + # must not report "skipped" and hide that a real live/global write happened. + if c_out.backup is not None: + backup = c_out.backup + if c_out.status in _CHANGED_STATUSES and status == "skipped": + status = c_out.status + + return ActionResult(action, status, "; ".join(notes), backup) + + _HANDLERS: dict[str, Callable[[Action, str], ActionResult]] = { "record_mode": _do_record_mode, "copy_skill": _do_copy_skill, @@ -5722,4 +5765,5 @@ def _do_provision_spotlight(action: Action, on_conflict: str) -> ActionResult: "provision_spotlight": _do_provision_spotlight, "provision_tools": _do_provision_tools, "provision_tg_ctl": _do_provision_tg_ctl, + "install_dev_reload_hook": _do_install_dev_reload_hook, } diff --git a/riglib/areas.py b/riglib/areas.py index f6e1077..a34e9a2 100644 --- a/riglib/areas.py +++ b/riglib/areas.py @@ -88,6 +88,7 @@ class Area: Area("ci", "CI gates", REPO, ("ci",), ship_slot=False), Area("ship", "ship / `gh ship` merge gate", REPO, ("ci",), ship_slot=True), Area("ship_delegator", "`gh ship` delegator (.claude/scripts/pr-ship.sh)", REPO, ("ship_delegator",)), + Area("internal_dev", "internal-dev daemon auto-reload hook", REPO, ("internal_dev",)), Area("linters", "linter / formatter config files", REPO, ("linters",)), Area("project_tools", "project tools (Haft / Serena / Sverklo)", REPO, ("project_tools",)), Area("agents_md", "AGENTS.md / CLAUDE.md symlinks", REPO, ("agents_md",)), diff --git a/riglib/config.py b/riglib/config.py index ce234e4..aa260ba 100644 --- a/riglib/config.py +++ b/riglib/config.py @@ -56,6 +56,7 @@ "spotlight", "tools", "tg_ctl", + "internal_dev", "ship_delegator", "linters", "project_tools", @@ -467,6 +468,16 @@ def _merge_layer(path: Path, label: str) -> None: fix=f"move mode to {global_config_path()} or run `rig config set mode.name ... --global`", schema_path="mode", ) + if label == "global" and data.get("internal_dev"): + raise ConfigError( + "internal_dev is a repo-only config block", + why=( + f"{path} is the GLOBAL layer, but internal_dev.daemon_source_paths is " + "repo-specific — a global block would silently cascade into every repo apply" + ), + fix="move internal_dev into this repo's committed ./rig.yaml", + schema_path="internal_dev", + ) merged = _deep_merge(merged, data) for k in data: key_sources[k] = path @@ -572,6 +583,7 @@ def validate(data: dict[str, Any]) -> None: _validate_spotlight(data.get("spotlight", {})) _validate_tools(data.get("tools", {})) _validate_tg_ctl(data.get("tg_ctl", {})) + _validate_internal_dev(data.get("internal_dev", {})) _validate_ship_delegator(data.get("ship_delegator", {})) _validate_linters(data.get("linters", {})) _validate_project_tools(data.get("project_tools", {})) @@ -2022,3 +2034,38 @@ def _validate_tg_ctl(t: dict[str, Any]) -> None: f"tg_ctl.{strkey} must be a string, got {value!r}", schema_path=f"tg_ctl.{strkey}", ) + + +def _validate_internal_dev(d: dict[str, Any]) -> None: + """Validate the ``internal_dev`` block — the per-repo daemon auto-reload post-commit hook. + + A per-repo COMMITTED concern (the enablement + source paths travel with the repo's rig.yaml, + like ``agent_hooks.worktree_only``), so it belongs in the REPO layer — NOT the global config. + Default **OFF** (opt-in): an EMPTY/absent block is a no-op. Fail-closed, consistent with every + other block, on: a non-mapping block, an unknown key (typo guard), a non-bool + ``auto_reload_on_commit``, a non-string-list ``daemon_source_paths``, a non-string + ``reload_command``, and (a review-caught gap) enabling with no paths at all — that would + install a hook that can never match anything, silently. + """ + if not isinstance(d, dict): + raise ConfigError("internal_dev must be a mapping", schema_path="internal_dev") + if not d: + return + _reject_unknown_keys(d, "internal_dev") + _check_bool(d, "auto_reload_on_commit", "internal_dev.auto_reload_on_commit") + paths = d.get("daemon_source_paths") + if paths is not None and (not isinstance(paths, list) or not all(isinstance(e, str) for e in paths)): + raise ConfigError( + f"internal_dev.daemon_source_paths must be a list of strings, got {paths!r}", + schema_path="internal_dev.daemon_source_paths", + ) + _check_str(d, "reload_command", "internal_dev.reload_command") + # Whitespace-only entries strip to nothing in dev_reload.build_dev_reload, so a list of + # blank strings passes the type check above yet still installs the same dead hook the + # empty-list check exists to prevent — require at least one NON-blank pattern. + if d.get("auto_reload_on_commit") and not any(str(p).strip() for p in (paths or [])): + raise ConfigError( + "internal_dev.auto_reload_on_commit is true but daemon_source_paths is empty — " + "the hook would never match anything", + schema_path="internal_dev.daemon_source_paths", + ) diff --git a/riglib/config_schema.py b/riglib/config_schema.py index 0b58854..0f3a64f 100644 --- a/riglib/config_schema.py +++ b/riglib/config_schema.py @@ -791,6 +791,33 @@ def to_node(self) -> dict[str, Any]: }, ) +_INTERNAL_DEV_BLOCK = Block( + doc=( + "internal development of the rig-ecosystem tools themselves (rig, tg-cli, review-cli, …): " + "a per-repo COMMITTED opt-in that installs a post-commit git hook so a commit touching the " + "daemon's source gracefully reloads the running daemon (tg-ctl restart) — zero manual restart." + ), + leaves={ + "auto_reload_on_commit": Leaf( + "boolean", + "install a post-commit hook that reloads the daemon when a commit touches a " + "daemon-source path (opt-in)", + default=False, + ), + "daemon_source_paths": Leaf( + "array", + "repo-relative shell-glob patterns (POSIX `case`, `*` spans `/`) whose change in a " + "commit triggers the reload", + items_type="string", + ), + "reload_command": Leaf( + "string", + "the graceful reload command the hook runs on a match", + default="tg-ctl restart", + ), + }, +) + # The top-level shape: the scalar top keys + every block, in the order config.py validates them. _TOP_LEAVES: dict[str, Leaf] = { @@ -828,6 +855,7 @@ def to_node(self) -> dict[str, Any]: "spotlight": _SPOTLIGHT_BLOCK, "tools": _TOOLS_BLOCK, "tg_ctl": _TG_CTL_BLOCK, + "internal_dev": _INTERNAL_DEV_BLOCK, "ship_delegator": _SHIP_DELEGATOR_BLOCK, "linters": _LINTERS_BLOCK, "project_tools": _PROJECT_TOOLS_BLOCK, diff --git a/riglib/dev_reload.py b/riglib/dev_reload.py new file mode 100644 index 0000000..d05a270 --- /dev/null +++ b/riglib/dev_reload.py @@ -0,0 +1,300 @@ +"""internal-dev daemon auto-reload — PURE planning + rendering of the post-commit hook. + +What this is +------------ +When a rig-ecosystem tool repo (rig, tg-cli, review-cli, …) is developed IN PLACE — the +checked-out files ARE the running binary (a live symlink) or a long-running daemon reads +them — a code change only takes effect after the daemon is restarted. This block wires that +restart to the commit: a repo that opts in gets a ``post-commit`` git hook that, when a commit +touches the configured daemon-source paths, runs a GRACEFUL reload command (``tg-ctl restart`` +by default — Part 1 makes that reload drop no channel). So committing a change to tg-cli's +daemon auto-reloads the running daemon with zero manual restart. + +This is a PER-REPO, COMMITTED concern (the enablement + the source paths travel with the repo's +``rig.yaml``, reproducibly, exactly like ``agent_hooks.worktree_only``) — so the block lives in +the REPO layer, NOT the global config. Only the reload COMMAND is machine-shaped, and it carries +a sensible default, so the whole block stays repo-owned. + +How it is reached +----------------- +``plan._build_internal_dev`` reads the ``internal_dev:`` block; when ``auto_reload_on_commit`` is +truthy it emits ONE ``install_dev_reload_hook`` action carrying the resolved paths + command. +``runner._do_install_dev_reload_hook`` renders + writes the repo-local ``/hooks/ +post-commit`` and (when a global ``core.hooksPath`` composer shadows it) ensures a generic +``post-commit`` composer trampoline exists. ``drift._check_internal_dev`` re-renders and diffs. + +The composer gap (why a bare repo-local hook is not enough) +----------------------------------------------------------- +When git's ``core.hooksPath`` is set (the rig global-hook dispatcher sets it machine-wide), git +runs ONLY that dir's hooks and SHADOWS every repo's ``.git/hooks/*``. The agent-tools dispatcher +composer ships ``pre-commit``/``commit-msg``/``pre-push`` — but NO ``post-commit`` — so under the +composer a repo-local ``post-commit`` never fires. This module therefore also renders a generic +``post-commit`` COMPOSER that trampolines the shadowed ``/hooks/post-commit`` and then +the ``run-global-hooks post-commit`` dispatcher fragments (mirroring how the ``pre-commit`` +composer trampolines the repo-local pre-commit). rig writes it only when a composer is actually +active, so a raw-``.git/hooks`` repo is untouched. + +Invariants +---------- +- **Idempotent, marker-keyed.** Both artifacts carry a version sentinel (:data:`HOOK_MARKER` / + :data:`COMPOSER_MARKER`); a re-apply with identical content is a no-op, a differing prior is + backed up per ``on_conflict``. +- **The reload never blocks a commit.** post-commit runs AFTER the commit is recorded; the hook + reports and swallows a failed reload (a broken daemon must not wedge the developer's commit). +- **Dry-run gate (:data:`DRY_RUN_ENV`).** The hook reads it at FIRE time and skips the real + reload; the runner reads it at APPLY time and skips the machine-global composer write (the one + live/global mutation). The unit suite + smoke set it so tests/CI never fire ``tg-ctl restart`` + nor touch the real global hooks dir. + +Stdlib-only (``subprocess``/``shlex``/``pathlib``): safe to import at module load. +""" + +from __future__ import annotations + +import shlex +import subprocess +from dataclasses import dataclass +from pathlib import Path + +# The default graceful reload command. tg-ctl's `restart` is graceful (durable deferred-message +# queue + cooperative SIGTERM drain) so a reload drops no inbound channel — see Part 1 in tg-cli. +DEFAULT_RELOAD_COMMAND = "tg-ctl restart" + +# Version sentinels embedded in each generated artifact. Drift + idempotency key off the FULL +# rendered content, but the marker lets a human (and a grep) tell a rig-managed hook from a +# hand-written one, and bumps if the template changes shape. +HOOK_MARKER = "rig-dev-reload-hook: v1" +COMPOSER_MARKER = "rig-dev-reload-composer: v1" + +# The env var that neutralizes the live reload (hook, fire time) + the global composer write +# (runner, apply time). Mirrors RIG_TG_CTL_DRY_RUN / RIG_TMUX_DRY_RUN. +DRY_RUN_ENV = "RIG_DEV_RELOAD_DRY_RUN" + + +@dataclass(frozen=True) +class DevReloadPlan: + """The desired post-commit auto-reload state, fully resolved. Pure data, no I/O.""" + + repo_root: Path + daemon_source_paths: tuple[str, ...] + reload_command: str + + def render_hook(self) -> str: + """The repo-local ``post-commit`` hook: reload the daemon when a commit touches a + daemon-source path. + + The paths + command are embedded shell-safely (``shlex.quote``) at render time, so the + hook is self-contained and needs no runtime YAML parse. Path patterns are matched as + POSIX ``case`` globs against each changed file (repo-relative, from ``git diff-tree``); + in ``case`` a ``*`` spans ``/`` too, so ``src/daemon/*`` matches nested files. + """ + patterns_blob = shlex.quote("\n".join(self.daemon_source_paths)) + reload_cmd = shlex.quote(self.reload_command) + return _HOOK_TEMPLATE.format( + marker=HOOK_MARKER, + dry_env=DRY_RUN_ENV, + patterns=patterns_blob, + reload_command=reload_cmd, + ) + + +def build_dev_reload( + *, + repo_root: Path, + daemon_source_paths: list[str] | tuple[str, ...], + reload_command: str = DEFAULT_RELOAD_COMMAND, +) -> DevReloadPlan: + """Resolve a :class:`DevReloadPlan` from the (already-validated) ``internal_dev`` block.""" + paths = tuple(str(p) for p in daemon_source_paths if str(p).strip()) + cmd = str(reload_command).strip() or DEFAULT_RELOAD_COMMAND + return DevReloadPlan(repo_root=Path(repo_root), daemon_source_paths=paths, reload_command=cmd) + + +def resolve_git_dir(repo_root: Path) -> Path: + """The COMMON git dir for ``repo_root`` — correct for a LINKED worktree, where hooks live. + + Git hooks are not per-worktree: a linked worktree (``git worktree add``, which is how this + very repo's own agent checkouts under ``.claude/worktrees/*`` are made) reads + ```` from the repo's COMMON dir, not from its own private + ``
/.git/worktrees//`` administrative dir. ``--absolute-git-dir`` returns the + latter (correct for e.g. ``info/exclude``, which IS per-worktree) — using it here would + install the hook where git never looks, silently. ``--git-common-dir`` is the shared dir + every worktree's hooks resolve through (absent a ``core.hooksPath`` override, handled + separately by :func:`composer_post_commit_path`). Falls back to ``/.git`` when + git is unavailable (a non-worktree repo only — a linked worktree's ``.git`` is a FILE, so + this fallback is a last resort, not worktree-safe). + """ + try: + out = subprocess.run( + ["git", "-C", str(repo_root), "rev-parse", "--path-format=absolute", "--git-common-dir"], + capture_output=True, + text=True, + check=True, + ) + resolved = out.stdout.strip() + if resolved: + p = Path(resolved) + return p if p.is_absolute() else Path(repo_root) / p + except (OSError, subprocess.SubprocessError): + pass + return Path(repo_root) / ".git" + + +def post_commit_hook_path(repo_root: Path) -> Path: + """The repo-local ``post-commit`` hook path (``/hooks/post-commit``).""" + return resolve_git_dir(repo_root) / "hooks" / "post-commit" + + +def effective_hooks_path(repo_root: Path) -> Path | None: + """The repo's effective ``core.hooksPath`` (repo-local or global), expanded, or ``None``. + + ``None`` means git looks in ``/hooks`` (a repo-local hook fires directly). A value + means a composer shadows the repo-local hooks — and, since the agent-tools composer ships no + ``post-commit``, the repo-local ``post-commit`` will NOT fire without our trampoline. + """ + try: + out = subprocess.run( + ["git", "-C", str(repo_root), "config", "--get", "core.hooksPath"], + capture_output=True, + text=True, + check=True, + ) + except (OSError, subprocess.SubprocessError): + return None + raw = out.stdout.strip() + if not raw: + return None + return Path(raw).expanduser() + + +def composer_post_commit_path(repo_root: Path) -> Path | None: + """Where the generic ``post-commit`` composer trampoline belongs, or ``None`` when not needed. + + Returns a path ONLY when a composer is active (``core.hooksPath`` set to a dir OTHER than the + repo-local ``/hooks``) AND that dir is the rig/agent-tools dispatcher composer layout + (a sibling ``run-global-hooks`` exists). Otherwise ``None``: a raw-``.git/hooks`` repo needs + no composer, and an unrelated ``core.hooksPath`` (e.g. a lefthook dir) is not ours to touch. + """ + hooks_path = effective_hooks_path(repo_root) + if hooks_path is None: + return None + local_hooks = resolve_git_dir(repo_root) / "hooks" + if _same_dir(hooks_path, local_hooks): + return None + if not (hooks_path.parent / "run-global-hooks").exists(): + return None + return hooks_path / "post-commit" + + +def render_post_commit_composer() -> str: + """The generic ``post-commit`` COMPOSER: run the shadowed repo-local hook, then the dispatcher. + + Repo-agnostic (same bytes in every wired repo): the repo-specific reload logic lives in + ``/hooks/post-commit``; this only restores the shadowed-by-core.hooksPath call to it, + plus the ``run-global-hooks post-commit`` fragments. post-commit is informational — git ignores + its exit status — so it never blocks and swallows a child failure. + """ + return _COMPOSER_TEMPLATE.format(marker=COMPOSER_MARKER) + + +def _same_dir(a: Path, b: Path) -> bool: + try: + return a.resolve() == b.resolve() + except OSError: + return a == b + + +_HOOK_TEMPLATE = """\ +#!/bin/sh +# rig-managed post-commit — graceful dev daemon auto-reload. +# GENERATED by rig (internal_dev.auto_reload_on_commit). Do NOT edit by hand; `rig apply` +# reconciles this file. See docs/config-schema.md#internal_dev. +# {marker} +# +# On a commit that touches a configured daemon-source path, run the graceful reload command so +# the running daemon picks up the new code with no manual restart. Runs AFTER the commit is +# recorded (post-commit) and NEVER fails the commit. +set -eu +# -f: disable shell pathname expansion. Without it, `for p in $patterns` (below) expands each +# glob AGAINST THE WORKING TREE before `case` ever sees it — e.g. `src/daemon/*` becomes the +# literal on-disk entries `src/daemon/loop.ts src/daemon/sub` (and `*` does NOT span `/` in +# shell pathname expansion, unlike in `case`), so a nested changed file never matches. `case` +# does its OWN glob matching on `$p`, which is what this hook actually relies on. +set -f + +# The daemon-source path patterns (POSIX `case` globs; a `*` spans `/`). A commit whose changed +# files match ANY pattern triggers the reload. +patterns={patterns} +reload_command={reload_command} + +# 1. which files did THIS commit touch? --root: a parentless (repo-initial) commit otherwise +# prints nothing from plain `diff-tree HEAD`, so the very first commit could never reload. +changed="$(git diff-tree --no-commit-id --name-only -r --root HEAD 2>/dev/null || true)" +[ -n "$changed" ] || exit 0 + +# 2. does any changed file match a daemon-source pattern? +matched=0 +oldifs="$IFS" +IFS=' +' +for f in $changed; do + [ -n "$f" ] || continue + for p in $patterns; do + [ -n "$p" ] || continue + case "$f" in + $p) matched=1 ;; + esac + [ "$matched" -eq 1 ] && break + done + [ "$matched" -eq 1 ] && break +done +IFS="$oldifs" +[ "$matched" -eq 1 ] || exit 0 + +# 3. dry-run gate — tests / CI never fire a real reload. +if [ -n "${{{dry_env}:-}}" ]; then + echo "rig dev-reload: {dry_env} set — would run: $reload_command" >&2 + exit 0 +fi + +# 4. the reload command must be available (no-op with a note when it is not on PATH). +cmd_name="${{reload_command%% *}}" +if ! command -v "$cmd_name" >/dev/null 2>&1; then + echo "rig dev-reload: '$cmd_name' not on PATH — skipping graceful reload" >&2 + exit 0 +fi + +# 5. graceful reload — never fail the commit (post-commit is informational). +echo "rig dev-reload: daemon source changed — running: $reload_command" >&2 +$reload_command || echo "rig dev-reload: '$reload_command' failed (non-fatal)" >&2 +exit 0 +""" + + +_COMPOSER_TEMPLATE = """\ +#!/bin/sh +# rig-managed post-commit COMPOSER (core.hooksPath = this dir). +# GENERATED by rig. Do NOT edit by hand. +# {marker} +# +# A global core.hooksPath shadows each repo's own .git/hooks/post-commit, and the agent-tools +# dispatcher composer ships NO post-commit — so without this, repo-local post-commit hooks never +# fire. This generic trampoline restores them: run the shadowed repo-local hook, then the +# global-hooks.d/post-commit dispatcher fragments. post-commit is informational (git ignores the +# exit status), so it never blocks and swallows a child failure. +# --git-common-dir (not --absolute-git-dir): hooks are shared across a repo's worktrees, living +# in the COMMON dir — a linked worktree's own private git-dir is the wrong place to look. +git_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || exit 0 +[ -n "$git_dir" ] || exit 0 +HOOK_DIR="$(dirname "$0")" + +local_hook="$git_dir/hooks/post-commit" +if [ -x "$local_hook" ] && [ "$local_hook" != "$0" ]; then + "$local_hook" "$@" || true +fi + +if [ -x "$HOOK_DIR/../run-global-hooks" ]; then + "$HOOK_DIR/../run-global-hooks" post-commit "$@" || true +fi +exit 0 +""" diff --git a/riglib/drift.py b/riglib/drift.py index a7a90f7..63618c1 100644 --- a/riglib/drift.py +++ b/riglib/drift.py @@ -176,6 +176,8 @@ def detect( _check_tools(action, report) elif action.kind == "provision_tg_ctl": _check_tg_ctl(action, report) + elif action.kind == "install_dev_reload_hook": + _check_internal_dev(action, report) _extras_skills(declared_skill_dirs, report) _extras_ci(declared_ci_dirs, report) @@ -1655,6 +1657,37 @@ def _check_tg_ctl(action: Action, report: DriftReport) -> None: ) +def _check_internal_dev(action: Action, report: DriftReport) -> None: + """Flag drift on the per-repo daemon auto-reload post-commit hook. + + Compares the rendered hook against the file at the resolved (worktree-correct) git-hooks + path — the SAME rendering the runner writes, so status and apply can never disagree. The + machine-global composer trampoline is a live/global apply-time concern (mirrors tg_ctl's + boot plist) and is not checked here; a missing repo-local hook already surfaces the drift + that matters to the repo. + """ + from . import dev_reload + + repo_root = action.source + dplan = dev_reload.build_dev_reload( + repo_root=repo_root, + daemon_source_paths=action.options.get("daemon_source_paths", []), + reload_command=action.options.get("reload_command", dev_reload.DEFAULT_RELOAD_COMMAND), + ) + target = dev_reload.post_commit_hook_path(repo_root) + if not target.is_file(): + report.items.append( + DriftItem("missing", "internal_dev", action.item, target, + "dev-reload post-commit hook not installed") + ) + return + if target.read_text(encoding="utf-8") != dplan.render_hook(): + report.items.append( + DriftItem("modified", "internal_dev", action.item, target, + "post-commit hook differs from the rig-generated dev-reload hook") + ) + + def _file_drift(report: DriftReport, action: Action, path: Path, desired: str, label: str) -> None: """Append a ``missing`` (absent) or ``modified`` (content differs) DriftItem for a rig file.""" if not path.is_file(): diff --git a/riglib/layers.py b/riglib/layers.py index a76b20e..27fb727 100644 --- a/riglib/layers.py +++ b/riglib/layers.py @@ -48,6 +48,7 @@ # REPO — this repo, from ./rig.yaml "ci": REPO, "ship_delegator": REPO, + "internal_dev": REPO, "linters": REPO, "project_tools": REPO, "agents_md": REPO, diff --git a/riglib/plan.py b/riglib/plan.py index b98c237..f25f607 100644 --- a/riglib/plan.py +++ b/riglib/plan.py @@ -786,6 +786,9 @@ def build(config: LoadedConfig, catalog: Catalog, *, project_type: str = "unknow # ── tg_ctl (rig-managed tg-ctl inbound daemon LaunchAgent) ───────────────────── _build_tg_ctl(config, plan) + # ── internal_dev (per-repo daemon auto-reload post-commit hook) ──────────────── + _build_internal_dev(config, plan) + return plan @@ -1947,3 +1950,36 @@ def _build_tg_ctl(config: LoadedConfig, plan: InstallPlan) -> None: }, ) ) + + +def _build_internal_dev(config: LoadedConfig, plan: InstallPlan) -> None: + """Plan the per-repo daemon auto-reload post-commit hook, when ``auto_reload_on_commit`` is on. + + Default **OFF** (opt-in): an ABSENT/empty/``false`` block emits NOTHING. When enabled, one + ``install_dev_reload_hook`` action carries the resolved daemon-source patterns + reload command; + the runner writes the repo-local ``/hooks/post-commit`` (and, under a global + ``core.hooksPath`` composer, a generic post-commit trampoline). REPO-owned: the enablement + + paths live in the committed ``rig.yaml``, exactly like ``agent_hooks.worktree_only``. + """ + from .dev_reload import DEFAULT_RELOAD_COMMAND + + d = config.data.get("internal_dev") or {} + if not d.get("auto_reload_on_commit"): + return + + paths = list(d.get("daemon_source_paths") or []) + reload_command = d.get("reload_command") or DEFAULT_RELOAD_COMMAND + plan.actions.append( + Action( + kind="install_dev_reload_hook", + category="internal_dev", + item="post-commit", + source=config.repo_root, # the repo whose commits trigger the reload + # display target — the runner re-resolves the real git dir (worktree-correct) at apply. + target=config.repo_root / ".git" / "hooks" / "post-commit", + options={ + "daemon_source_paths": paths, + "reload_command": reload_command, + }, + ) + ) diff --git a/riglib/schema.py b/riglib/schema.py index ab6074f..d5e8492 100644 --- a/riglib/schema.py +++ b/riglib/schema.py @@ -354,6 +354,18 @@ def _opt( "Auto-start the daemon at login via a launchd boot agent (macOS). Off = install but do not boot."), ), ), + Area( + "internal_dev", "internal-dev daemon auto-reload", + "A per-repo committed post-commit hook that gracefully reloads the daemon when a commit touches its source.", + ( + _opt("internal_dev.auto_reload_on_commit", KIND_BOOL, False, + "Opt-IN: install a post-commit git hook (in THIS repo) that runs the reload " + "command when a commit touches a daemon-source path, so a change to the " + "rig-ecosystem tool's own daemon auto-reloads with no manual restart. Off by " + "default. REPO-owned (committed rig.yaml), like agent_hooks — the enablement + " + "paths travel with the repo. Reload / composer live-write gate: RIG_DEV_RELOAD_DRY_RUN."), + ), + ), Area( "linters", "linter / formatter config files", "Per-repo linter + formatter config files rig writes/reconciles (tool + content per repo).", ( diff --git a/schema/rig.schema.json b/schema/rig.schema.json index f218c00..c72e8da 100644 --- a/schema/rig.schema.json +++ b/schema/rig.schema.json @@ -1221,6 +1221,30 @@ }, "additionalProperties": false }, + "internal_dev": { + "type": "object", + "description": "internal development of the rig-ecosystem tools themselves (rig, tg-cli, review-cli, …): a per-repo COMMITTED opt-in that installs a post-commit git hook so a commit touching the daemon's source gracefully reloads the running daemon (tg-ctl restart) — zero manual restart.", + "properties": { + "auto_reload_on_commit": { + "type": "boolean", + "description": "install a post-commit hook that reloads the daemon when a commit touches a daemon-source path (opt-in)", + "default": false + }, + "daemon_source_paths": { + "type": "array", + "description": "repo-relative shell-glob patterns (POSIX `case`, `*` spans `/`) whose change in a commit triggers the reload", + "items": { + "type": "string" + } + }, + "reload_command": { + "type": "string", + "description": "the graceful reload command the hook runs on a match", + "default": "tg-ctl restart" + } + }, + "additionalProperties": false + }, "ship_delegator": { "type": "object", "description": "a per-repo .claude/scripts/pr-ship.sh delegator so `gh ship` works in this repo (ignored in .git/info/exclude).", diff --git a/tests/conftest.py b/tests/conftest.py index 000c7c8..6c544b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -183,6 +183,18 @@ def _isolate_tmux_activation(monkeypatch): monkeypatch.setenv("RIG_TMUX_DRY_RUN", "1") +@pytest.fixture(autouse=True) +def _isolate_dev_reload_activation(monkeypatch): + """Never let a test write the LIVE machine-global dev-reload composer or fire a real reload. + + Mirrors ``_isolate_tmux_activation``: ``RIG_DEV_RELOAD_DRY_RUN=1`` suite-wide so a future + full-apply test against a composer-layout fixture can't accidentally write into the real + global hooks dir. The dedicated ``tests/test_internal_dev.py`` tests clear/override this + (``monkeypatch.delenv`` / explicit ``setenv``) to exercise the real composer-write logic. + """ + monkeypatch.setenv("RIG_DEV_RELOAD_DRY_RUN", "1") + + @pytest.fixture def fake_agent_tools(tmp_path: Path) -> Path: """A minimal but structurally-valid agent-tools checkout.""" diff --git a/tests/test_internal_dev.py b/tests/test_internal_dev.py new file mode 100644 index 0000000..9609692 --- /dev/null +++ b/tests/test_internal_dev.py @@ -0,0 +1,519 @@ +"""internal-dev daemon auto-reload — config, pure hook rendering, plan, install, drift. + +The ``internal_dev`` block wires a repo's commit to a graceful daemon reload: an opt-in, +per-repo (committed ``rig.yaml``) concern that installs a ``post-commit`` git hook. When a commit +touches the configured daemon-source paths, the hook runs the reload command (``tg-ctl restart``). + +These tests are HOME-isolated and NEVER fire a real reload nor touch the real global hooks dir: +the hook + runner both honor ``RIG_DEV_RELOAD_DRY_RUN``, and the end-to-end hook test injects a +FAKE ``tg-ctl`` onto PATH and asserts against a sentinel file it writes — no real daemon involved. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from riglib import config as configmod +from riglib import dev_reload +from riglib import drift as driftmod +from riglib.actions import runner +from riglib.config import ConfigError, LoadedConfig, validate +from riglib.plan import Action, InstallPlan, _build_internal_dev + + +# ── config validation ──────────────────────────────────────────────────────────────────── +def test_internal_dev_block_accepted(): + validate( + { + "version": 1, + "internal_dev": {"auto_reload_on_commit": True, "daemon_source_paths": ["src/*"]}, + } + ) + + +def test_internal_dev_block_empty_ok(): + validate({"version": 1, "internal_dev": {}}) + + +def test_internal_dev_full_block_accepted(): + validate( + { + "version": 1, + "internal_dev": { + "auto_reload_on_commit": True, + "daemon_source_paths": ["src/daemon/*", "bin/tg-ctl"], + "reload_command": "tg-ctl restart", + }, + } + ) + + +def test_internal_dev_unknown_key_rejected(): + with pytest.raises(ConfigError) as exc: + validate({"version": 1, "internal_dev": {"auto_relaod": True}}) + assert "internal_dev" in str(exc.value.schema_path) + + +def test_internal_dev_bad_bool_rejected(): + with pytest.raises(ConfigError): + validate({"version": 1, "internal_dev": {"auto_reload_on_commit": "yes"}}) + + +def test_internal_dev_bad_paths_rejected(): + with pytest.raises(ConfigError): + validate({"version": 1, "internal_dev": {"daemon_source_paths": "src/daemon"}}) + + +def test_internal_dev_bad_command_rejected(): + with pytest.raises(ConfigError): + validate({"version": 1, "internal_dev": {"reload_command": ["tg-ctl", "restart"]}}) + + +def test_internal_dev_enabled_without_paths_rejected(): + # a review-caught gap: enabling with no daemon_source_paths installs a hook that can never + # match anything — fail closed instead of shipping a silently-dead config. + with pytest.raises(ConfigError) as exc: + validate({"version": 1, "internal_dev": {"auto_reload_on_commit": True}}) + assert "daemon_source_paths" in str(exc.value.schema_path) + with pytest.raises(ConfigError): + validate( + { + "version": 1, + "internal_dev": {"auto_reload_on_commit": True, "daemon_source_paths": []}, + } + ) + + +def test_internal_dev_enabled_with_only_blank_paths_rejected(): + # review-caught: dev_reload.build_dev_reload() strips whitespace-only entries to nothing, + # so a list of blank strings passed the plain non-empty-list check yet still installed the + # same dead hook the empty-list guard exists to prevent. + with pytest.raises(ConfigError) as exc: + validate( + { + "version": 1, + "internal_dev": {"auto_reload_on_commit": True, "daemon_source_paths": [" ", ""]}, + } + ) + assert "daemon_source_paths" in str(exc.value.schema_path) + + +def test_internal_dev_forbidden_in_global_layer(tmp_path, monkeypatch): + # review-caught: internal_dev is documented + schema-registered as REPO-only, but nothing + # enforced that at the raw config-load level — a global block would silently cascade into + # every repo's apply. Mirrors the existing `mode`-in-repo-layer guard, opposite direction. + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + global_path = configmod.global_config_path() + global_path.parent.mkdir(parents=True, exist_ok=True) + global_path.write_text( + "version: 1\ninternal_dev: {auto_reload_on_commit: true, daemon_source_paths: [src/*]}\n", + encoding="utf-8", + ) + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + + with pytest.raises(ConfigError) as exc: + configmod.load(repo) + assert exc.value.schema_path == "internal_dev" + assert "global-only" in exc.value.what or "repo-only" in exc.value.what + + +def test_internal_dev_empty_block_allowed_in_global_layer(tmp_path, monkeypatch): + # review-caught: the global-layer guard must key off CONTENT, not mere key presence — an + # empty/inert `internal_dev: {}` is explicitly a valid no-op (test_internal_dev_block_empty_ok) + # and must not be rejected just because the key exists in the global file. + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + global_path = configmod.global_config_path() + global_path.parent.mkdir(parents=True, exist_ok=True) + global_path.write_text("version: 1\ninternal_dev: {}\n", encoding="utf-8") + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + + configmod.load(repo) # must not raise + + +# ── pure hook rendering ────────────────────────────────────────────────────────────────── +def test_render_hook_embeds_paths_and_command(tmp_path): + plan = dev_reload.build_dev_reload( + repo_root=tmp_path, + daemon_source_paths=["src/daemon/*", "bin/tg-ctl"], + reload_command="tg-ctl restart", + ) + hook = plan.render_hook() + assert dev_reload.HOOK_MARKER in hook + assert "src/daemon/*" in hook + assert "bin/tg-ctl" in hook + assert "tg-ctl restart" in hook + assert dev_reload.DRY_RUN_ENV in hook + + +def test_render_hook_is_valid_posix_sh(tmp_path): + plan = dev_reload.build_dev_reload( + repo_root=tmp_path, daemon_source_paths=["src/daemon/*"], reload_command="tg-ctl restart" + ) + script = tmp_path / "post-commit" + script.write_text(plan.render_hook(), encoding="utf-8") + # `sh -n` parses without executing — a syntax error fails here. + subprocess.run(["sh", "-n", str(script)], check=True) + + +def test_render_hook_default_reload_command(tmp_path): + plan = dev_reload.build_dev_reload(repo_root=tmp_path, daemon_source_paths=["x"]) + assert plan.reload_command == dev_reload.DEFAULT_RELOAD_COMMAND + assert dev_reload.DEFAULT_RELOAD_COMMAND in plan.render_hook() + + +def test_render_composer_is_valid_posix_sh(tmp_path): + script = tmp_path / "post-commit-composer" + script.write_text(dev_reload.render_post_commit_composer(), encoding="utf-8") + subprocess.run(["sh", "-n", str(script)], check=True) + assert dev_reload.COMPOSER_MARKER in script.read_text(encoding="utf-8") + + +# ── plan builder ───────────────────────────────────────────────────────────────────────── +def _cfg(data, repo_root): + return LoadedConfig(data=data, repo_root=repo_root) + + +def test_plan_emits_action_only_when_enabled(tmp_path): + plan = InstallPlan() + _build_internal_dev(_cfg({}, tmp_path), plan) # absent → no action + assert not plan.actions + + plan = InstallPlan() + _build_internal_dev(_cfg({"internal_dev": {}}, tmp_path), plan) # present-but-empty → OFF + assert not plan.actions + + plan = InstallPlan() + _build_internal_dev( + _cfg({"internal_dev": {"auto_reload_on_commit": False}}, tmp_path), plan + ) # disabled → no action + assert not plan.actions + + plan = InstallPlan() + _build_internal_dev( + _cfg( + {"internal_dev": {"auto_reload_on_commit": True, "daemon_source_paths": ["src/*"]}}, + tmp_path, + ), + plan, + ) + assert len(plan.actions) == 1 + act = plan.actions[0] + assert act.kind == "install_dev_reload_hook" + assert act.category == "internal_dev" + assert act.options["daemon_source_paths"] == ["src/*"] + assert act.options["reload_command"] == dev_reload.DEFAULT_RELOAD_COMMAND + + +# ── runner: install / idempotency / conflict / dry-run ─────────────────────────────────── +def _git_init(repo: Path, monkeypatch=None) -> None: + repo.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.email", "t@t"], check=True) + subprocess.run(["git", "-C", str(repo), "config", "user.name", "t"], check=True) + if monkeypatch is not None: + # Review-caught: `effective_hooks_path()` runs plain `git config --get core.hooksPath`, + # which layers in GLOBAL/system config too. On a machine that has the rig global-hook + # dispatcher installed (core.hooksPath machine-wide) — exactly the environment this + # feature targets — a test with the dry-run guard disabled could otherwise write into + # the developer's REAL global hooks dir. The suite-wide HOME/XDG isolation fixture + # happens to neutralize this today, but that protection is incidental to an unrelated + # fixture; make each test process's git blind to any external config so it can't depend + # on fixture ordering. (NOT `git config core.hooksPath ""` locally — git treats an empty + # hooksPath as a REAL, resolvable-to-nothing path rather than "unset", which broke the + # hook it was meant to protect.) These env vars propagate to every git subprocess this + # test's process spawns, including the hook script git itself invokes on commit. + monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/dev/null") + monkeypatch.setenv("GIT_CONFIG_SYSTEM", "/dev/null") + + +def _action(repo: Path, *, paths=("src/daemon/*",), cmd="tg-ctl restart") -> Action: + return Action( + kind="install_dev_reload_hook", + category="internal_dev", + item="post-commit", + source=repo, + target=repo / ".git" / "hooks" / "post-commit", + options={"daemon_source_paths": list(paths), "reload_command": cmd}, + ) + + +def test_runner_writes_executable_hook(tmp_path, monkeypatch): + monkeypatch.setenv(dev_reload.DRY_RUN_ENV, "1") + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + res = runner._do_install_dev_reload_hook(_action(repo), "backup") + assert res.status == "created" + hook = repo / ".git" / "hooks" / "post-commit" + assert hook.is_file() + assert os.access(hook, os.X_OK) + assert dev_reload.HOOK_MARKER in hook.read_text(encoding="utf-8") + + +def test_runner_idempotent(tmp_path, monkeypatch): + monkeypatch.setenv(dev_reload.DRY_RUN_ENV, "1") + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + runner._do_install_dev_reload_hook(_action(repo), "backup") + res = runner._do_install_dev_reload_hook(_action(repo), "backup") + assert res.status == "skipped" + + +def test_runner_backs_up_conflicting_hook(tmp_path, monkeypatch): + monkeypatch.setenv(dev_reload.DRY_RUN_ENV, "1") + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + hook = repo / ".git" / "hooks" / "post-commit" + hook.write_text("#!/bin/sh\necho hand-written\n", encoding="utf-8") + res = runner._do_install_dev_reload_hook(_action(repo), "backup") + assert res.status == "backed_up" + assert res.backup is not None and res.backup.is_file() + assert "hand-written" in res.backup.read_text(encoding="utf-8") + assert dev_reload.HOOK_MARKER in hook.read_text(encoding="utf-8") + + +def test_runner_dry_run_skips_global_composer(tmp_path, monkeypatch): + """Under the dry-run gate the repo-local hook IS written, the machine-global composer is NOT.""" + monkeypatch.setenv(dev_reload.DRY_RUN_ENV, "1") + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + # wire a composer core.hooksPath with the dispatcher layout (sibling run-global-hooks). + composer_dir = tmp_path / "gitconfig" / "hooks" + composer_dir.mkdir(parents=True) + (composer_dir.parent / "run-global-hooks").write_text("#!/bin/sh\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "config", "core.hooksPath", str(composer_dir)], check=True) + + runner._do_install_dev_reload_hook(_action(repo), "backup") + assert (repo / ".git" / "hooks" / "post-commit").is_file() # managed artifact written + assert not (composer_dir / "post-commit").exists() # live mutation skipped under dry-run + + +def test_runner_writes_composer_when_active(tmp_path, monkeypatch): + monkeypatch.delenv(dev_reload.DRY_RUN_ENV, raising=False) + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + composer_dir = tmp_path / "gitconfig" / "hooks" + composer_dir.mkdir(parents=True) + (composer_dir.parent / "run-global-hooks").write_text("#!/bin/sh\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "config", "core.hooksPath", str(composer_dir)], check=True) + + runner._do_install_dev_reload_hook(_action(repo), "backup") + composer = composer_dir / "post-commit" + assert composer.is_file() + assert os.access(composer, os.X_OK) + assert dev_reload.COMPOSER_MARKER in composer.read_text(encoding="utf-8") + + +def test_runner_surfaces_composer_write_in_result(tmp_path, monkeypatch): + """Regression: the composer write's status/backup used to be discarded — a composer-only + change (repo-local hook already correct, composer newly written) reported `skipped`, hiding + the one live/global mutation this action makes.""" + monkeypatch.delenv(dev_reload.DRY_RUN_ENV, raising=False) + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + composer_dir = tmp_path / "gitconfig" / "hooks" + composer_dir.mkdir(parents=True) + (composer_dir.parent / "run-global-hooks").write_text("#!/bin/sh\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "config", "core.hooksPath", str(composer_dir)], check=True) + + first = runner._do_install_dev_reload_hook(_action(repo), "backup") + assert first.status == "created" + assert "composer" in first.detail + + # a hand-written composer the runner must back up on the next apply. + (composer_dir / "post-commit").write_text("#!/bin/sh\necho hand-written\n", encoding="utf-8") + second = runner._do_install_dev_reload_hook(_action(repo), "backup") + # the repo-local hook is unchanged (skipped) but the composer was just backed up — the + # overall result must surface that, not silently report "skipped". + assert second.status == "backed_up" + assert second.backup is not None + assert "hand-written" in second.backup.read_text(encoding="utf-8") + + +def test_runner_no_composer_for_raw_hooks_repo(tmp_path, monkeypatch): + """A repo without a core.hooksPath composer needs no trampoline — none is written.""" + monkeypatch.delenv(dev_reload.DRY_RUN_ENV, raising=False) + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + res = runner._do_install_dev_reload_hook(_action(repo), "backup") + assert res.status == "created" + # nothing was created outside the repo's own .git/hooks + assert (repo / ".git" / "hooks" / "post-commit").is_file() + + +# ── drift ──────────────────────────────────────────────────────────────────────────────── +def test_drift_missing_when_hook_absent(tmp_path): + repo = tmp_path / "repo" + _git_init(repo) + report = driftmod.DriftReport() + driftmod._check_internal_dev(_action(repo), report) + assert any(i.direction == "missing" and i.category == "internal_dev" for i in report.items) + + +def test_drift_modified_when_hook_differs(tmp_path, monkeypatch): + monkeypatch.setenv(dev_reload.DRY_RUN_ENV, "1") + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + (repo / ".git" / "hooks" / "post-commit").write_text("#!/bin/sh\necho other\n", encoding="utf-8") + report = driftmod.DriftReport() + driftmod._check_internal_dev(_action(repo), report) + assert any(i.direction == "modified" and i.category == "internal_dev" for i in report.items) + + +def test_drift_clean_when_hook_current(tmp_path, monkeypatch): + monkeypatch.setenv(dev_reload.DRY_RUN_ENV, "1") + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + runner._do_install_dev_reload_hook(_action(repo), "backup") + report = driftmod.DriftReport() + driftmod._check_internal_dev(_action(repo), report) + assert not [i for i in report.items if i.category == "internal_dev"] + + +# ── schema registry (wizard + config_web hints) ────────────────────────────────────────── +def test_schema_exposes_internal_dev_options_with_hints(): + from riglib import schema + + area = schema.area_for_category("internal_dev") + assert area is not None + keys = {o.key for o in area.options} + assert "internal_dev.auto_reload_on_commit" in keys + for o in area.options: + assert o.hint.strip(), f"{o.key} has no wizard hint" + # REPO-owned (committed rig.yaml), like agent_hooks — NOT a global-only category. + opt = schema.option_for_key("internal_dev.auto_reload_on_commit") + assert opt is not None and opt.layer == schema.REPO + + +# ── end-to-end: the hook actually reloads on a daemon-source commit (adversarial) ───────── +def _fake_tg_ctl_on_path(tmp_path, monkeypatch) -> Path: + """Put a fake `tg-ctl` on PATH that records its invocation into a sentinel file.""" + bindir = tmp_path / "bin" + bindir.mkdir(exist_ok=True) + sentinel = tmp_path / "reloaded.txt" + fake = bindir / "tg-ctl" + fake.write_text(f'#!/bin/sh\necho "$@" >> {sentinel}\n', encoding="utf-8") + fake.chmod(0o755) + monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}") + return sentinel + + +def _commit(repo: Path, path: str, body: str) -> None: + f = repo / path + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(body, encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) + subprocess.run( + ["git", "-C", str(repo), "commit", "-q", "-m", f"touch {path}"], + check=True, + env={**os.environ, "SKIP_RIG_SMOKE": "1"}, + ) + + +def test_hook_reloads_only_on_daemon_source_change(tmp_path, monkeypatch): + monkeypatch.delenv(dev_reload.DRY_RUN_ENV, raising=False) + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + sentinel = _fake_tg_ctl_on_path(tmp_path, monkeypatch) + runner._do_install_dev_reload_hook( + _action(repo, paths=("src/daemon/*",), cmd="tg-ctl restart"), "backup" + ) + + # a NON-daemon file → no reload + _commit(repo, "README.md", "docs\n") + assert not sentinel.exists() + + # a daemon-source file → reload fires + _commit(repo, "src/daemon/loop.ts", "loop\n") + assert sentinel.exists() + assert "restart" in sentinel.read_text(encoding="utf-8") + + +def test_hook_dry_run_suppresses_reload(tmp_path, monkeypatch): + monkeypatch.setenv(dev_reload.DRY_RUN_ENV, "1") + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + sentinel = _fake_tg_ctl_on_path(tmp_path, monkeypatch) + runner._do_install_dev_reload_hook(_action(repo, paths=("src/daemon/*",)), "backup") + _commit(repo, "src/daemon/loop.ts", "loop\n") + assert not sentinel.exists() # dry-run gate in the hook suppressed the real reload + + +def test_hook_matches_nested_daemon_source_path(tmp_path, monkeypatch): + """Regression for a review-caught bug: unquoted `for p in $patterns` used to get shell-glob- + expanded against the working tree BEFORE `case` ever ran, so `src/daemon/*` degraded to + matching only the flat on-disk entries under `src/daemon/` — a nested changed file like + `src/daemon/sub/x.ts` silently never matched, contradicting the documented `case`-globs-span- + `/` invariant. `set -f` in the hook template is the fix; this seeds MULTIPLE on-disk siblings + (including one that pre-expansion would have matched) so a pass here can't be a fluke.""" + monkeypatch.delenv(dev_reload.DRY_RUN_ENV, raising=False) + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + _commit(repo, "README.md", "seed\n") # isolates this test to path-matching, not root-commit + sentinel = _fake_tg_ctl_on_path(tmp_path, monkeypatch) + runner._do_install_dev_reload_hook( + _action(repo, paths=("src/daemon/*",), cmd="tg-ctl restart"), "backup" + ) + # a sibling flat file first, so `src/daemon/*` has something to (mis)expand to on disk. + _commit(repo, "src/daemon/loop.ts", "loop\n") + assert sentinel.exists() + sentinel.unlink() + + _commit(repo, "src/daemon/sub/nested.ts", "nested\n") + assert sentinel.exists(), "nested daemon-source file must match src/daemon/* like `case` promises" + + +def test_hook_reloads_on_root_commit(tmp_path, monkeypatch): + """Regression: `git diff-tree HEAD` (no `--root`) prints nothing for a parentless commit, so + a repo whose very first commit introduces the daemon source used to never reload. `--root` + in the hook template is the fix.""" + monkeypatch.delenv(dev_reload.DRY_RUN_ENV, raising=False) + repo = tmp_path / "repo" + _git_init(repo, monkeypatch) + sentinel = _fake_tg_ctl_on_path(tmp_path, monkeypatch) + runner._do_install_dev_reload_hook( + _action(repo, paths=("src/daemon/*",), cmd="tg-ctl restart"), "backup" + ) + # the daemon-source file IS the repo's first-ever (parentless) commit. + _commit(repo, "src/daemon/loop.ts", "loop\n") + assert sentinel.exists(), "a repo's root commit touching daemon source must still reload" + + +def test_hook_installs_into_common_dir_from_a_linked_worktree(tmp_path, monkeypatch): + """Regression for a review-caught bug: resolving via `--absolute-git-dir` returns a linked + worktree's PRIVATE administrative dir (`
/.git/worktrees/`), which git does NOT + read hooks from — hooks live in the COMMON dir. A hook written to the wrong path never fires, + while `rig status`/apply both agreed with each other and were both wrong. This drives a REAL + `git worktree add` checkout (not a fresh repo) and commits IN the worktree.""" + monkeypatch.delenv(dev_reload.DRY_RUN_ENV, raising=False) + main_repo = tmp_path / "main" + _git_init(main_repo, monkeypatch) + _commit(main_repo, "README.md", "seed\n") + subprocess.run(["git", "-C", str(main_repo), "branch", "feature"], check=True) + worktree = tmp_path / "worktree" + subprocess.run( + ["git", "-C", str(main_repo), "worktree", "add", str(worktree), "feature"], check=True + ) + + sentinel = _fake_tg_ctl_on_path(tmp_path, monkeypatch) + res = runner._do_install_dev_reload_hook( + _action(worktree, paths=("src/daemon/*",), cmd="tg-ctl restart"), "backup" + ) + assert res.status == "created" + # the installed hook must land in the COMMON dir main git actually consults... + common_hook = main_repo / ".git" / "hooks" / "post-commit" + assert common_hook.is_file() + # ...NOT under the worktree's private per-worktree administrative dir. + private_hook = main_repo / ".git" / "worktrees" / "worktree" / "hooks" / "post-commit" + assert not private_hook.is_file() + + # and a real commit made IN the worktree must actually fire the reload. + _commit(worktree, "src/daemon/loop.ts", "loop\n") + assert sentinel.exists(), "post-commit hook installed via a linked worktree must actually fire" diff --git a/tests/test_setup_wizard.py b/tests/test_setup_wizard.py index 4ae8f82..3c9c665 100644 --- a/tests/test_setup_wizard.py +++ b/tests/test_setup_wizard.py @@ -33,7 +33,7 @@ def test_registry_covers_every_status_area(): expected = { "skills", "agent_hooks", "git_hooks", "ci", "mcp", "harness", "permissions", "mode", "models", "agents_md", "github", "tmux", "gitignore", "spotlight", "tg_ctl", - "linters", "project_tools", + "linters", "project_tools", "internal_dev", } assert cats == expected @@ -333,8 +333,9 @@ def test_writable_layer_agrees_with_the_scaffold(): scaffolded = set(default_state()) # REPO-writable areas that are default-ON at plan level and genuine repo artifacts, but carry NO # scaffolded default content (so the scaffold can't pre-write them): agents_md (a file IN the - # repo) and linters (config files declared per-repo — there is no sensible default item to seed). - _repo_unscaffolded_ok = {"agents_md", "linters", "permissions"} + # repo), linters (config files declared per-repo), and internal_dev (opt-in, off by default, + # with no universal daemon_source_paths to seed) — there is no sensible default item to seed. + _repo_unscaffolded_ok = {"agents_md", "linters", "permissions", "internal_dev"} for area in schema.AREAS: if schema.writable_layer_for_category(area.category) == schema.REPO: assert area.category in scaffolded or area.category in _repo_unscaffolded_ok, area.category