diff --git a/docs/config-schema.md b/docs/config-schema.md index 885aaf4..28e0c4b 100644 --- a/docs/config-schema.md +++ b/docs/config-schema.md @@ -39,6 +39,7 @@ models: { ... } # daily model-freshness checker schedule (launchd/ agents_md: { ... } # AGENTS.md (canonical) + CLAUDE.md (symlink), default ON github: { ... } # GitHub repo branch ruleset via gh api, default ON (no-op without a github remote) tmux: { ... } # rig-managed tmux config (generate + migrate ~/.tmux.conf), opt-in +gitignore: { ... } # rig-managed block in the GLOBAL git excludesfile (ignores **/.claude/worktrees/ in EVERY repo), default ON ``` If `agent_tools_source` is omitted, rig resolves it from `$RIG_AGENT_TOOLS_SOURCE`, then @@ -615,6 +616,69 @@ boot-from-cold path can only be fully proven by an actual reboot. --- +## `gitignore` + +Maintains a **rig-managed block** in git's **GLOBAL excludes file** (`core.excludesfile`) so +harness artifacts are ignored in **EVERY repo on the machine** — with **zero per-repo commits** +and no per-repo `rig apply`. The motivating case: Claude Code creates throwaway worktrees under +each repo's `.claude/worktrees/`; those must be gitignored everywhere, and rig owns that ignore +**globally** (one managed block, machine-wide) rather than per-repo. This is **GLOBAL config** — +it belongs in the global rig layer (`~/.config/rig/config.yaml`), wired like the git-hooks +`dispatcher` (a `git config --global` setting plus a managed file). Default **ON** — on `rig init` +AND `rig apply` rig converges the block; idempotent (a re-apply that finds it correct is a no-op). + +```yaml +gitignore: + enabled: true # provision the managed block (default ON; false opts out) + entries: # the ignored paths inside the managed block + - "**/.claude/worktrees/" # default: Claude Code's throwaway worktrees (every repo) + # excludesfile: ~/.gitignore # rare: force a specific file instead of honoring core.excludesfile +``` + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `enabled` | bool | `true` | provision the managed block (set `false` to leave the global excludes file untouched) | +| `entries` | list[str] | `["**/.claude/worktrees/"]` | the paths ignored inside the managed block; an empty/absent list uses the default | +| `excludesfile` | str | *(unset)* | force a specific file; by default rig honors `core.excludesfile` (or sets it — see below) | + +**Target resolution (the headline behavior).** rig decides WHICH file holds the block at apply +time, honoring the user's existing choice: + +- **`core.excludesfile` is already set** (e.g. `~/.gitignore`): rig manages the block **in that + file** and leaves the git config alone — your choice is respected, the block is not moved. +- **`core.excludesfile` is unset**: rig **sets** it to the XDG default `~/.config/git/ignore` + **and** writes the block there. So on a **clean machine** `rig init` does everything itself + (set the git config if absent + write/reconcile the block) — no manual `git config` step. +- **`excludesfile:` override set**: rig reconciles the block in that file and points + `core.excludesfile` at it when git's value doesn't already match. + +**`.serena/` is intentionally NOT ignored.** Serena state is **committed** shared project memory +(project memories travel with the repo), so it is never in the default entries — only throwaway +harness artifacts are. + +**The managed block.** rig fences its lines with explicit markers and a fixed explanatory comment, +and touches **only** what is between them — every other line in the excludes file (the user's or +another tool's) is preserved verbatim (CRLF, trailing blanks, no-final-newline all survive): + +``` +# >>> rig-managed (do not edit) >>> +# Claude Code creates throwaway worktrees under each repo's .claude/worktrees/; rig ignores them globally. +**/.claude/worktrees/ +# <<< rig-managed (do not edit) <<< +``` + +**Strict idempotency.** A re-apply is a **byte-identical no-op** when the block is already correct. +If a prior non-idempotent tool appended the block **more than once**, rig **collapses the entire +rig-managed region to one correct block** (it never duplicates, and never edits lines outside the +markers). An **unbalanced** marker pair (a begin with no end, an end before a begin) is a +`conflict` rig leaves untouched and surfaces for manual reconcile. + +**Drift.** `rig status` flags the GLOBAL block as drift when it is missing, divergent, or +duplicated — **and** when `core.excludesfile` is unset and rig would set it. `rig apply` +reconciles. Shown in the **global** section of status (not the repo section). + +--- + ## Validation `apply`/`status`/`init` validate before touching disk and **fail closed** on: @@ -627,5 +691,8 @@ a malformed/out-of-range `models.schedule.time` or unknown `models` key, a non-b `github.ruleset.required_reviews` that is not an int ≥ 0, a `github.ruleset.required_status_checks` that is not a list of strings, an unknown `tmux`/`tmux.` key, a bad `tmux.apply` enum, a `tmux.resurrect.processes` that is not a list of strings, a `tmux.continuum.save_interval` that is -not an int >= 1, a non-bool `tmux` boolean knob, and an `agent_tools_source` that is not an -agent-tools checkout. `--dry-run` prints the resolved plan and exits 0 without writing. +not an int >= 1, a non-bool `tmux` boolean knob, a non-mapping `gitignore` block / non-bool +`gitignore.enabled` / unknown `gitignore` key / non-string `gitignore.excludesfile` / a +`gitignore.entries` that is not a list of strings or that contains a rig-managed marker line, and an +`agent_tools_source` that is not an agent-tools checkout. `--dry-run` prints the resolved plan and +exits 0 without writing. diff --git a/riglib/actions/runner.py b/riglib/actions/runner.py index 9cc17c6..a7e7144 100644 --- a/riglib/actions/runner.py +++ b/riglib/actions/runner.py @@ -19,6 +19,11 @@ from pathlib import Path from typing import Callable +from ..config import ( + GITIGNORE_BEGIN_MARKER, + GITIGNORE_BLOCK_COMMENT, + GITIGNORE_END_MARKER, +) from ..github_ruleset import ( build_ruleset_body, find_managed_ruleset, @@ -2022,6 +2027,293 @@ def _do_provision_github_ruleset(action: Action, on_conflict: str) -> ActionResu return ActionResult(action, "updated", f"github-ruleset: updated '{name}' (id={rs_id}) on {owner}/{repo}") +# ── rig-managed GLOBAL git-excludes block ────────────────────────────────────────── +# rig maintains a marker-delimited block in git's GLOBAL ``core.excludesfile`` so harness +# artifacts (chiefly Claude Code's throwaway ``**/.claude/worktrees/``) are ignored in EVERY repo +# on the machine, with zero per-repo commits — not by a per-repo committed ``.gitignore`` and not +# by a hand-edited global ignore. This is the global counterpart of the git-hooks dispatcher: a +# ``git config --global`` setting plus a managed file. The markers fence ONLY rig's lines; every +# other line the user (or another tool) has in the excludes file is preserved verbatim. apply and +# drift BOTH go through ``resolve_global_excludes`` so they can never disagree on the desired block +# or whether the file is in sync. The marker constants live in ``config.py`` (the schema layer) so +# validation can reject a marker-colliding entry without an import cycle; this module imports them +# at top for its own block construction/detection. + + +def global_excludes_block_text(entries: list[str]) -> str: + """The exact marker-delimited block rig owns for ``entries`` (no trailing newline). + + Single source of truth shared by the install handler and drift, so both agree byte-for-byte + on what the managed block SHOULD contain. The block is the begin marker, a fixed explanatory + comment line (so a human reading the global excludes file knows what it is), one line per entry + (in the order given), then the end marker. The comment is rendered byte-for-byte and matches the + block already present on a provisioned machine, so a re-apply is a true zero-churn no-op. + """ + return "\n".join([GITIGNORE_BEGIN_MARKER, GITIGNORE_BLOCK_COMMENT, *entries, GITIGNORE_END_MARKER]) + + +@dataclass(frozen=True) +class GlobalExcludesResolution: + """The desired global-excludes outcome — the one source apply + drift share. + + ``state`` is the single discriminator both consumers switch on: + - ``ok`` — the managed block is already present and exactly correct: no-op. + - ``create`` — no excludes file or no managed block: append the block (creating the file + if absent), preserving any existing content. + - ``update`` — a managed block exists but its body differs, OR the file has MULTIPLE + rig-managed blocks (a prior non-idempotent appender): collapse the managed + region to ONE correct block in place, preserving every line outside the + markers (verbatim — CRLF and trailing blanks included). + - ``conflict`` — the file has unbalanced markers (a begin with no end, an end before a + begin): rig won't guess the block's extent, so it leaves the file untouched + and surfaces it. ``detail`` says why. + - ``io_error`` — the path could not be read (unreadable, or a directory sits there). Unlike a + marker ``conflict`` (the file is fine, the operator must reconcile) this is a + failure to even inspect the file: apply reports it as an ERROR, never a silent + skip. ``detail`` carries the OS error. + + ``desired_block`` is the canonical block text; ``new_content`` is the full desired file content + for ``create``/``update`` (``None`` for ``ok``/``conflict``/``io_error``). + """ + + path: Path + state: str + desired_block: str + new_content: str | None = None + detail: str = "" + + +def resolve_global_excludes(path: Path, entries: list[str]) -> GlobalExcludesResolution: + """Classify the on-disk global excludes file vs the desired managed block (pure, no writes). + + Idempotent + non-destructive: rig only ever (a) appends the block to a file that lacks it + (creating the file if absent), (b) collapses the existing managed region to ONE correct block + in place, or (c) no-ops a correct single block. Crucially this is STRICTLY idempotent even when + a prior tool appended the block MORE THAN ONCE: a file with several rig-managed blocks resolves + to ``update`` and collapses to exactly one. An unbalanced marker pair is a ``conflict`` rig + never rewrites. Every line OUTSIDE the managed region is preserved byte-for-byte: the region is + located and spliced by raw character offset (not splitlines/rejoin), so a CRLF file, a file with + no trailing newline, and trailing blank lines all survive untouched. + """ + desired = global_excludes_block_text(entries) + if not path.exists(): + return GlobalExcludesResolution(path, "create", desired, new_content=desired + "\n") + try: + # newline="" disables universal-newline translation so a CRLF file is read (and later + # re-written) byte-for-byte outside the managed region — the documented verbatim guarantee. + with path.open(encoding="utf-8", newline="") as fh: + content = fh.read() + except OSError as exc: + # unreadable, or a directory at the path — a failure to inspect, not a marker conflict. + return GlobalExcludesResolution(path, "io_error", desired, detail=f"cannot read {path}: {exc}") + + # Find each marker line by its raw [start, end_of_line] offsets so the splice preserves every + # other byte verbatim (line ending included). A marker is a line whose stripped text equals the + # marker constant — tolerant of trailing whitespace on the marker line itself. + begins = _find_marker_lines(content, GITIGNORE_BEGIN_MARKER) + ends = _find_marker_lines(content, GITIGNORE_END_MARKER) + # An unbalanced pair (different counts, or an end with no begin) is ambiguous — rig won't guess + # the region's extent. NOTE a balanced N-pairs (N>1) is NOT a conflict: it is a non-idempotent + # duplicate we collapse below. + if len(begins) != len(ends) or (ends and not begins): + return GlobalExcludesResolution( + path, "conflict", desired, + detail=f"{path} has unbalanced rig-managed markers — reconcile by hand, then re-run", + ) + if not begins: + # no managed block: append it, keeping a single blank-line separator from prior content. + body = content.rstrip("\n") + sep = "\n\n" if body else "" + new_content = f"{body}{sep}{desired}\n" + return GlobalExcludesResolution(path, "create", desired, new_content=new_content) + + # Pair the markers by interleaving them in document order: they must strictly alternate + # begin, end, begin, end, … Anything else (an end before a begin, a begin immediately followed + # by another begin → a nested/overlapping block) is ambiguous and rig won't guess — conflict. + # Each valid pair fences ONE managed block region ``[begin_start, end_line_end]``; the spans + # between consecutive pairs are USER content (e.g. a hand-added ignore that landed between two + # duplicated rig blocks) and MUST be preserved. + markers = sorted( + [(b[0], b[1], "begin") for b in begins] + [(e[0], e[1], "end") for e in ends] + ) + pairs: list[tuple[int, int]] = [] # (region_start, region_end) per managed block + expect = "begin" + pending_start = -1 + for start, line_end, kind in markers: + if kind != expect: + return GlobalExcludesResolution( + path, "conflict", desired, + detail=f"{path} has misordered/nested rig-managed markers — reconcile by hand, then re-run", + ) + if kind == "begin": + pending_start = start + expect = "end" + else: + pairs.append((pending_start, line_end)) + expect = "begin" + + # Already exactly one correct block? (the common steady state — a true no-op). + if len(pairs) == 1 and content[pairs[0][0] : pairs[0][1]] == desired: + return GlobalExcludesResolution(path, "ok", desired) + + # Splice OUT every managed-block region (preserving all USER content outside the markers, + # including any text BETWEEN duplicated blocks), then re-insert ONE correct block where the + # FIRST block sat. Build the result left-to-right so each non-managed span is copied verbatim. + out_parts: list[str] = [] + cursor = 0 + for idx, (r_start, r_end) in enumerate(pairs): + out_parts.append(content[cursor:r_start]) # user content before this block, verbatim + if idx == 0: + out_parts.append(desired) # the single canonical block replaces the first one + else: + # A removed duplicate block leaves a seam: if both the text before and after it end/ + # start with a newline we'd otherwise create a doubled blank line. Drop one leading + # newline of the following span so collapsing N blocks doesn't accrete blank lines. + if content[r_end : r_end + 1] == "\n": + r_end += 1 + cursor = r_end + out_parts.append(content[cursor:]) # trailing user content, verbatim + new_content = "".join(out_parts) + return GlobalExcludesResolution(path, "update", desired, new_content=new_content) + + +def _find_marker_lines(content: str, marker: str) -> list[tuple[int, int]]: + """Return ``(line_start_offset, line_end_offset)`` for each line equal to ``marker``. + + ``line_end_offset`` is the offset of the newline that terminates the line (or ``len(content)`` + for an un-terminated final line) — so a splice on ``[start, end)`` drops the marker line's text + but not its line ending, letting the caller re-emit a clean block. A line MATCHES when its text + with surrounding whitespace stripped equals ``marker`` (tolerant of a marker line that picked up + trailing spaces), so such a line is still recognized and normalized on the next apply. + """ + out: list[tuple[int, int]] = [] + pos = 0 + n = len(content) + while pos <= n: + nl = content.find("\n", pos) + line_end = n if nl == -1 else nl + if content[pos:line_end].strip() == marker: + out.append((pos, line_end)) + if nl == -1: + break + pos = nl + 1 + return out + + +def _resolve_excludes_target(action: Action) -> tuple[Path, bool, str | None]: + """Resolve WHICH file holds the managed block, and whether ``core.excludesfile`` must be set. + + Honors the user's existing choice: if ``core.excludesfile`` is ALREADY set (the common case — + e.g. ``~/.gitignore``), the block goes in THAT file and git config is left alone. If it is NOT + set, rig points ``core.excludesfile`` at the XDG default (``~/.config/git/ignore``) and writes + the block there — so on a clean machine ``rig init`` does everything itself. An explicit + ``gitignore.excludesfile`` override in config forces a specific file (and rig sets + ``core.excludesfile`` to it when git's value doesn't already match). + + Returns ``(target_path, needs_set, set_value)``: + - ``target_path`` — the resolved, ``~``/``$XDG``-expanded file to reconcile the block in. + - ``needs_set`` — True when ``core.excludesfile`` must be written (unset, or override + differs from the current value). + - ``set_value`` — the value to write into ``core.excludesfile`` (the un-expanded, portable + form so git stores ``~/.config/git/ignore``, not a machine path); None + when ``needs_set`` is False. + + The git-config READ goes through ``_git_global`` (the same seam the dispatcher uses), so tests + monkeypatch one function and never run real ``git config --global``. + """ + current = _git_global("core.excludesfile") + override = action.options.get("excludesfile") + xdg_default = action.options.get("xdg_default") or "~/.config/git/ignore" + if isinstance(override, str) and override: + # explicit override: reconcile in this file; set core.excludesfile when git doesn't match. + target = _expand_user_path(override) + needs_set = current != override + return target, needs_set, (override if needs_set else None) + if current: + # respect the user's existing choice — manage the block in their file, touch no git config. + return _expand_user_path(current), False, None + # unset: point git at the XDG default AND write the block there (clean-machine path). + return _expand_user_path(xdg_default), True, xdg_default + + +def _expand_user_path(path_str: str) -> Path: + """Expand ``~`` and ``$XDG_CONFIG_HOME`` (for the ``~/.config`` prefix) to a concrete path. + + SYNC: this is the ``~/.config`` → ``$XDG_CONFIG_HOME`` mapping from ``plan._expand`` (keep the + two in step). It is duplicated rather than imported because ``plan._expand`` ALSO anchors a + relative remainder at the repo root — meaningless for a GLOBAL excludes path (which is always + absolute after ``~`` expansion) and would couple this global action to a repo root it does not + have. Matching git's XDG-aware read location matters when a test/machine points + ``$XDG_CONFIG_HOME`` somewhere non-default. + """ + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg and (path_str == "~/.config" or path_str.startswith("~/.config/")): + path_str = xdg + path_str[len("~/.config"):] + return Path(os.path.expanduser(os.path.expandvars(path_str))) + + +def _do_provision_global_excludes(action: Action, on_conflict: str) -> ActionResult: + """Provision/reconcile rig's managed block in the GLOBAL git ``core.excludesfile``. + + Two coupled steps, in order: + 1. Resolve the target file from ``core.excludesfile`` (honor an existing value; set it to the + XDG default when unset — so a clean machine is fully provisioned by ``rig init`` alone). + 2. Reconcile the marker block via the shared :func:`resolve_global_excludes` ``state`` — apply + and drift read the same classification, so ``status`` never misreports the on-disk state. + + Idempotent: a correct single block with ``core.excludesfile`` already set is a true no-op; a + missing block is appended (creating the file if absent); a drifted OR DUPLICATED managed region + is collapsed IN PLACE to one correct block, preserving every other line verbatim. An unbalanced + marker pair is a ``conflict`` rig leaves untouched (``skipped``), and an unreadable path is an + ``error`` (never a silent skip). There is no backup: rig only ever edits its OWN fenced lines + plus a git-config setting, so ``on_conflict`` is irrelevant here (consistent with the dispatcher + and the surgical hook-bridge upsert). + """ + entries = [str(e) for e in action.options.get("entries", [])] + target, needs_set, set_value = _resolve_excludes_target(action) + + notes: list[str] = [] + cfg_status = "skipped" + # Step 1: wire core.excludesfile when it is unset / doesn't match the override. + if needs_set and set_value is not None: + rc = _set_git_global("core.excludesfile", set_value) + if rc == 0: + notes.append(f"core.excludesfile → {set_value}") + cfg_status = "created" + else: + return ActionResult(action, "error", "gitignore: failed to set global core.excludesfile") + + # Step 2: reconcile the managed block in the resolved file. + r = resolve_global_excludes(target, entries) + if r.state == "ok": + if cfg_status == "created": + # rare: git config was unset but the file already had the exact block — config write is + # itself a change, so report it (not a silent no-op). + return ActionResult(action, "created", f"gitignore: {'; '.join(notes)} (block already correct in {target})") + return ActionResult(action, "skipped", f"gitignore: managed block already correct in {target}") + if r.state == "conflict": + return ActionResult(action, "skipped", f"gitignore: {r.detail}") + if r.state == "io_error": + return ActionResult(action, "error", f"gitignore: {r.detail}") + if r.new_content is None: # defensive — create/update always carry content + return ActionResult(action, "error", f"gitignore: unhandled state {r.state!r}") + existed = target.exists() + target.parent.mkdir(parents=True, exist_ok=True) + # newline="" so the bytes we computed (which may carry the user's CRLF outside the block) are + # written verbatim, with no platform newline translation. + with target.open("w", encoding="utf-8", newline="") as fh: + fh.write(r.new_content) + n = len(entries) + plural = "entry" if n == 1 else "entries" + if r.state == "create": + verb = "added block to" if existed else "created" + block_note = f"{verb} {target} ({n} {plural})" + else: + block_note = f"updated managed block in {target}" + notes.append(block_note) + return ActionResult(action, "created" if r.state == "create" else "updated", f"gitignore: {'; '.join(notes)}") + + _HANDLERS: dict[str, Callable[[Action, str], ActionResult]] = { "copy_skill": _do_copy_skill, "link_skill_harness": _do_link_skill_harness, @@ -2035,4 +2327,5 @@ def _do_provision_github_ruleset(action: Action, on_conflict: str) -> ActionResu "provision_agents_symlink": _do_provision_agents_symlink, "provision_github_ruleset": _do_provision_github_ruleset, "provision_tmux": _do_provision_tmux, + "provision_global_excludes": _do_provision_global_excludes, } diff --git a/riglib/cli.py b/riglib/cli.py index 65ed84e..7b4b069 100644 --- a/riglib/cli.py +++ b/riglib/cli.py @@ -521,6 +521,31 @@ def cmd_status(args: argparse.Namespace) -> int: from .drift import check_disabled_dispatcher check_disabled_dispatcher(loaded.repo_root, report) + # disabled-but-installed global-excludes block: config opted the gitignore category out, but a + # prior apply may have left the rig-managed block in the global excludes file. apply won't + # remove it, so surface it as disk→config drift (mirrors the disabled-dispatcher scan; this is + # a GLOBAL, machine-wide artifact, not repo-local). + gi_cfg = loaded.data.get("gitignore") + if isinstance(gi_cfg, dict) and gi_cfg.get("enabled") is False: + from .config import GITIGNORE_DEFAULT_EXCLUDESFILE + from .drift import check_disabled_global_excludes + from .plan import Action + + gi_opts: dict[str, object] = {"xdg_default": GITIGNORE_DEFAULT_EXCLUDESFILE} + override = gi_cfg.get("excludesfile") + if isinstance(override, str) and override: + gi_opts["excludesfile"] = override + check_disabled_global_excludes( + Action( + kind="provision_global_excludes", + category="gitignore", + item="block", + source=loaded.repo_root, + target=loaded.repo_root, + options=gi_opts, + ), + report, + ) # surface the model-freshness schedule explicitly (installed / drifted / not configured), # so `rig status` answers "is the daily checker cron there?" at a glance. _print_schedule_status(plan, report) diff --git a/riglib/config.py b/riglib/config.py index fcb7816..7bb32c9 100644 --- a/riglib/config.py +++ b/riglib/config.py @@ -39,6 +39,7 @@ "agents_md", "github", "tmux", + "gitignore", } _VALID_CATEGORIES = {"skills", "agent_hooks", "git_hooks", "ci", "mcp"} _VALID_ON_CONFLICT = {"skip", "overwrite", "backup"} @@ -261,6 +262,7 @@ def validate(data: dict[str, Any]) -> None: _validate_agents_md(data.get("agents_md", {})) _validate_github(data.get("github", {})) _validate_tmux(data.get("tmux", {})) + _validate_gitignore(data.get("gitignore", {})) def _validate_ci(ci: dict[str, Any]) -> None: @@ -436,6 +438,76 @@ def _validate_agents_md(am: dict[str, Any]) -> None: raise ConfigError(f"agents_md.{knob} must be a bool, got {value!r}") +# The default entries rig's managed block puts in the GLOBAL git excludes file. The harness +# (Claude Code) creates throwaway worktrees under each repo's ``.claude/worktrees/``; those must +# be gitignored MACHINE-WIDE (every repo, no per-repo commit) via git's global ``core.excludesfile`` +# — not by a per-repo committed ``.gitignore`` and not by a hand-edited global ignore. Listed once +# so the validator (default-fill), the plan builder, and the runner reference the SAME default — +# NB: ``.serena/`` is deliberately NOT here (Serena state is COMMITTED shared project memory). +GITIGNORE_DEFAULT_ENTRIES = ("**/.claude/worktrees/",) + +# The XDG default rig points ``core.excludesfile`` at when the user has NOT already set it. Git's +# documented global-ignore location is ``$XDG_CONFIG_HOME/git/ignore`` (``~/.config/git/ignore``). +# Defined here so the plan builder and runner agree on the fallback path; the runner expands ``~`` +# and ``$XDG_CONFIG_HOME`` at apply time so a committed config stays portable. +GITIGNORE_DEFAULT_EXCLUDESFILE = "~/.config/git/ignore" + +# The markers that fence rig's managed block in the global excludes file. Defined here (the schema +# layer, stdlib-only) so config validation can reject an entry that collides with a marker WITHOUT +# importing the actions runner (which would form a config→plan→config import cycle); the runner +# imports these from config so the two never drift. +GITIGNORE_BEGIN_MARKER = "# >>> rig-managed (do not edit) >>>" +GITIGNORE_END_MARKER = "# <<< rig-managed (do not edit) <<<" + +# A fixed explanatory comment rig writes as the FIRST line INSIDE the managed block, right after the +# begin marker, so a human reading the global excludes file knows what the block is and why it is +# there. It is part of the canonical block text (rendered byte-for-byte), so it must match what is +# ALREADY on a provisioned machine for a re-apply to be a true zero-churn no-op. Do not reword +# casually — a change here makes the next apply rewrite every provisioned machine's block. +GITIGNORE_BLOCK_COMMENT = ( + "# Claude Code creates throwaway worktrees under each repo's .claude/worktrees/; " + "rig ignores them globally." +) + + +def _validate_gitignore(gi: dict[str, Any]) -> None: + """Validate the ``gitignore`` block — rig's managed block in the GLOBAL git excludes file. + + This is GLOBAL (machine-wide) config: rig maintains a marker-delimited block in git's + ``core.excludesfile`` so harness artifacts (chiefly ``**/.claude/worktrees/``) are ignored in + EVERY repo on the machine, with zero per-repo commits — not by a per-repo committed + ``.gitignore`` and not by a hand-edited global ignore. Default **ON**: an absent/empty block + means "provision the default entries (and set core.excludesfile if it is unset)". Fail-closed, + consistent with every other block, on: a non-mapping block, a non-bool ``enabled``, an unknown + key (typo guard), a non-string ``excludesfile`` override, and a non-string-list ``entries``. + """ + if not isinstance(gi, dict): + raise ConfigError("gitignore must be a mapping") + if not gi: + return + unknown = set(gi) - {"enabled", "entries", "excludesfile"} + if unknown: + raise ConfigError(f"unknown gitignore key(s): {', '.join(sorted(unknown))}") + enabled = gi.get("enabled") + if enabled is not None and not isinstance(enabled, bool): + raise ConfigError(f"gitignore.enabled must be a bool, got {enabled!r}") + excludesfile = gi.get("excludesfile") + if excludesfile is not None and not isinstance(excludesfile, str): + raise ConfigError(f"gitignore.excludesfile must be a string, got {excludesfile!r}") + entries = gi.get("entries") + if entries is not None: + if not isinstance(entries, list) or not all(isinstance(e, str) for e in entries): + raise ConfigError(f"gitignore.entries must be a list of strings, got {entries!r}") + # Reject an entry that carries one of rig's block markers: writing it inside the managed + # block would make every later resolve see a duplicated marker and classify the file as a + # permanent conflict (apply could never re-converge). Fail closed on the footgun. + for e in entries: + if GITIGNORE_BEGIN_MARKER in e or GITIGNORE_END_MARKER in e: + raise ConfigError( + f"gitignore.entries may not contain a rig-managed marker line, got {e!r}" + ) + + # The ruleset knobs that are plain booleans (typo + type guard). Listed once so the # validator and the action builder reference the SAME knob set. _GITHUB_RULESET_BOOL_KNOBS = ( diff --git a/riglib/drift.py b/riglib/drift.py index d6ced13..f647b77 100644 --- a/riglib/drift.py +++ b/riglib/drift.py @@ -22,10 +22,12 @@ from .actions import fsutil from .actions.runner import ( _ci_companion_files, + _find_marker_lines, _git_global, _launchctl_loaded, _read_crontab, _tmux_dry_run, + _resolve_excludes_target, build_hook_descriptor, crontab_with_managed, descriptor_text, @@ -38,10 +40,12 @@ _is_rig_import_line, resolve_agents_md, resolve_ci_workflow, + resolve_global_excludes, schedule_plan_from_action, skill_harness_link_target, tmux_plan_from_action, ) +from .config import GITIGNORE_BEGIN_MARKER from .github_ruleset import DEFAULT_RULESET_NAME from .plan import Action, InstallPlan @@ -123,6 +127,8 @@ def detect( _check_github_ruleset(action, report) elif action.kind == "provision_tmux": _check_tmux(action, report) + elif action.kind == "provision_global_excludes": + _check_global_excludes(action, report) _extras_skills(declared_skill_dirs, report) _extras_ci(declared_ci_dirs, report) @@ -484,6 +490,80 @@ def _check_dispatcher(action: Action, report: DriftReport) -> None: ) +def _check_global_excludes(action: Action, report: DriftReport) -> None: + """Flag drift in the GLOBAL git-excludes provisioning (a GLOBAL-section drift item). + + Two coupled checks, mirroring apply's two steps: + 1. ``core.excludesfile`` — if it is unset (and no override pins a file), rig WOULD set it; + surface that as ``missing`` so ``status`` says "apply will wire core.excludesfile". The + resolution goes through the SAME :func:`_resolve_excludes_target` apply uses, so status and + apply agree on the target file and whether git config needs writing. + 2. The managed block — switch on the SAME :func:`resolve_global_excludes` ``state`` apply uses: + + - ``create`` → ``missing``: no excludes file or no managed block (apply adds it). + - ``update`` → ``modified``: a managed block exists but differs, OR the file has + duplicated rig-managed blocks (apply collapses to one correct block). + - ``ok`` → no block drift item (in sync). + - ``conflict`` → ``modified``: unbalanced markers rig won't rewrite — surfaced so the + operator reconciles by hand (apply leaves it untouched). + - ``io_error`` → ``modified``: the file couldn't be read. NOT silently in-sync — rig + couldn't even inspect it, so a green status would mask an un-provisioned + ignore. + """ + entries = [str(e) for e in action.options.get("entries", [])] + target, needs_set, set_value = _resolve_excludes_target(action) + if needs_set: + report.items.append( + DriftItem( + "missing", "gitignore", "core.excludesfile", target, + f"global core.excludesfile is unset (apply sets it → {set_value})", + ) + ) + r = resolve_global_excludes(target, entries) + if r.state == "ok": + return + if r.state == "create": + report.items.append( + DriftItem("missing", "gitignore", "block", target, + "rig-managed global-excludes block not present (apply adds it)") + ) + elif r.state == "update": + report.items.append( + DriftItem("modified", "gitignore", "block", target, + "rig-managed global-excludes block differs from config (apply reconciles it)") + ) + elif r.state in ("conflict", "io_error"): + report.items.append( + DriftItem("modified", "gitignore", "block", target, r.detail) + ) + + +def check_disabled_global_excludes(action: Action, report: DriftReport) -> None: + """Flag a still-installed managed block when the config disables the ``gitignore`` category. + + apply never deletes; so a machine that previously provisioned the global-excludes block keeps + it in ``core.excludesfile`` even after the config turns the category off. With the action gone + from the plan, ``_check_global_excludes`` never runs — so without this scan the leftover block + would report as "in sync". Resolve the target the SAME way apply does and report a present begin + marker as disk→config drift (mirrors :func:`check_disabled_dispatcher`). Marker detection reuses + :func:`_find_marker_lines` (the same offset-based scanner :func:`resolve_global_excludes` uses) + read with newline translation off, so detection never diverges from apply. + """ + target, _needs_set, _set_value = _resolve_excludes_target(action) + if not target.is_file(): + return + try: + with target.open(encoding="utf-8", newline="") as fh: + content = fh.read() + except OSError: + return + if _find_marker_lines(content, GITIGNORE_BEGIN_MARKER): + report.items.append( + DriftItem("extra", "gitignore", "block", target, + "gitignore disabled in config but the rig-managed block is still in the global excludes file") + ) + + def _check_harness(action: Action, report: DriftReport) -> None: """Flag drift between the configured harness auto/permission mode and the file on disk. diff --git a/riglib/plan.py b/riglib/plan.py index 62a1184..fde47c4 100644 --- a/riglib/plan.py +++ b/riglib/plan.py @@ -21,7 +21,11 @@ from typing import Any from .catalog import Catalog, Item -from .config import LoadedConfig +from .config import ( + GITIGNORE_DEFAULT_ENTRIES, + GITIGNORE_DEFAULT_EXCLUDESFILE, + LoadedConfig, +) from .github_ruleset import GITHUB_RULESET_DEFAULTS @@ -66,7 +70,7 @@ class PlanError(ValueError): class Action: """A single planned install step. ``kind`` selects the runner in ``actions/``.""" - kind: str # copy_skill | link_skill_harness | install_agent_hook | install_dispatcher | install_ci | register_mcp | apply_harness | register_hook_bridge | provision_schedule | provision_agents_symlink | provision_github_ruleset | provision_tmux + kind: str # copy_skill | link_skill_harness | install_agent_hook | install_dispatcher | install_ci | register_mcp | apply_harness | register_hook_bridge | provision_schedule | provision_agents_symlink | provision_github_ruleset | provision_tmux | provision_global_excludes category: str item: str source: Path # carrier path in the agent-tools checkout @@ -520,6 +524,9 @@ def build(config: LoadedConfig, catalog: Catalog, *, project_type: str = "unknow # ── tmux (rig-managed tmux configuration) ────────────────────────────────────── _build_tmux(config, plan) + # ── gitignore (rig-managed block in the GLOBAL git excludes file) ────────────── + _build_global_excludes(config, plan) + return plan @@ -680,6 +687,61 @@ def _build_agents_symlink(config: LoadedConfig, plan: InstallPlan) -> None: ) +def _build_global_excludes(config: LoadedConfig, plan: InstallPlan) -> None: + """Plan the rig-managed block in the GLOBAL git excludes file (``core.excludesfile``). + + This is GLOBAL (machine-wide) config, wired like the git-hooks ``dispatcher``: rig owns ONE + marker-delimited block in git's global ``core.excludesfile`` so harness artifacts — chiefly + Claude Code's throwaway ``**/.claude/worktrees/`` — are ignored in EVERY repo on the machine, + with zero per-repo commits and no per-repo ``rig apply``. Opt out with + ``gitignore: { enabled: false }``. Default **ON** (like the dispatcher), so on a clean machine + ``rig init``/``rig apply`` provisions it without any per-repo config. + + Target resolution is deferred to apply time (it depends on whether ``core.excludesfile`` is + already set on this machine — a thing the plan cannot read purely), so the plan emits ONE + idempotent action carrying the resolved ``entries`` and the XDG fallback path; the runner + reads ``core.excludesfile`` and EITHER reconciles the block in the user's existing excludes + file OR sets ``core.excludesfile`` to the XDG default and writes the block there. The + placeholder ``target`` is the XDG default for display; the runner re-resolves it. No carrier + in agent-tools. + + The ignored ``entries`` are configurable with a sensible default (``GITIGNORE_DEFAULT_ENTRIES``); + an empty/absent list uses that default. The ``excludesfile`` override (rare) forces a specific + file rather than honoring ``core.excludesfile``. + """ + gi = config.data.get("gitignore") + if gi is None: + gi = {} + if not isinstance(gi, dict): + return # validate() already fail-closed on a non-mapping block + if gi.get("enabled") is False: + return + raw_entries = gi.get("entries") + if not isinstance(raw_entries, list) or not raw_entries: + entries = list(GITIGNORE_DEFAULT_ENTRIES) + else: + entries = [str(e) for e in raw_entries] + override = gi.get("excludesfile") + options: dict[str, Any] = { + "entries": entries, + "xdg_default": GITIGNORE_DEFAULT_EXCLUDESFILE, + } + if isinstance(override, str) and override: + options["excludesfile"] = override + plan.actions.append( + Action( + kind="provision_global_excludes", + category="gitignore", + item="block", + source=config.repo_root, + # Placeholder for display only — the runner re-resolves the real target from + # core.excludesfile (or the XDG default) at apply time. Expanded for portability. + target=_expand(GITIGNORE_DEFAULT_EXCLUDESFILE, config.repo_root), + options=options, + ) + ) + + def _build_github_ruleset(config: LoadedConfig, plan: InstallPlan) -> None: """Plan the GitHub repository branch-ruleset provisioning for the repo. diff --git a/riglib/state.py b/riglib/state.py index eeb8334..40143b1 100644 --- a/riglib/state.py +++ b/riglib/state.py @@ -127,6 +127,12 @@ def default_state( # with ruleset.enabled: false. Add status checks with required_status_checks: [names]. # The knobs come straight from the action's GITHUB_RULESET_DEFAULTS (one source). "github": {"ruleset": github_ruleset}, + # NB: `gitignore` (the GLOBAL git-excludes block) is deliberately NOT scaffolded into this + # generated, COMMITTED repo `rig.yaml`. It is GLOBAL (machine-wide) config — it belongs in + # the global rig layer (~/.config/rig/config.yaml), with zero per-repo commits — and it is + # default-ON at PLAN level (an ABSENT `gitignore` key still provisions the block), so `rig + # init` on a clean machine provisions it WITHOUT baking a global-config block into every + # repo's committed file. Opt out with `gitignore: { enabled: false }` (in either layer). } diff --git a/tests/conftest.py b/tests/conftest.py index f375064..4819f0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,12 @@ def _isolate_home(monkeypatch, tmp_path): home = tmp_path / "isolated-home" home.mkdir(exist_ok=True) monkeypatch.setenv("HOME", str(home)) + # Also pin XDG_CONFIG_HOME under the throwaway home. The global-excludes default + # (``~/.config/git/ignore``) expands ``~/.config`` via ``$XDG_CONFIG_HOME`` when set — so a + # developer who exports XDG_CONFIG_HOME globally would otherwise have a full-plan e2e test + # write into their REAL ``$XDG_CONFIG_HOME/git/ignore``. Force it under the isolated home so no + # test can ever touch a real XDG config dir. Tests that need a specific XDG override it inline. + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) @pytest.fixture(autouse=True) @@ -78,6 +84,37 @@ def _fake_write_crontab(contents): monkeypatch.setattr(runner, "_write_crontab", _fake_write_crontab) +@pytest.fixture(autouse=True) +def _isolate_global_git_config(monkeypatch): + """Never let a test read or WRITE the real ``git config --global``. + + The global-excludes block (gitignore:) is in the DEFAULT scaffold, so any e2e test that runs + a full ``build`` + ``run_plan`` would otherwise shell out to real ``git config --global + core.excludesfile`` — and, on a machine where that is UNSET, would WRITE the real global + config. That is a hard-fail (a test must never mutate the user's git config). So this guard + stubs both git-config seams suite-wide on BOTH the runner and drift modules (drift imports + ``_git_global`` by name): + + - reads (``_git_global``) return ``None`` — i.e. ``core.excludesfile`` is UNSET — so e2e + apply takes the clean-machine path: set it to the XDG default and write the block. With + HOME isolated, the XDG default expands UNDER the throwaway home, never the real one. + - writes (``_set_git_global``) are captured in an in-memory store (so a subsequent read + could see them if a test wants), never touching real git config. + + The DEDICATED global-excludes tests (test_global_excludes.py) install their OWN seam mocks in + the test body — those run after this autouse fixture and win — so they can exercise both the + set-vs-unset target resolution explicitly. + """ + from riglib import drift as driftmod + from riglib.actions import runner + + store: dict[str, str] = {} + + for mod in (runner, driftmod): + monkeypatch.setattr(mod, "_git_global", lambda key: store.get(key), raising=False) + monkeypatch.setattr(runner, "_set_git_global", lambda key, value: store.__setitem__(key, value) or 0) + + @pytest.fixture(autouse=True) def _isolate_tmux_activation(monkeypatch): """Never let a test run the LIVE tmux activation (clone plugins / launchctl / first save). diff --git a/tests/smoke.sh b/tests/smoke.sh index 442229c..45166f0 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -74,6 +74,8 @@ tmux: anti_sprawl: { enabled: true, session: main } boot: { enabled: true } login_shell: { enabled: true } +gitignore: + enabled: true YAML # dry-run first (must write nothing) @@ -105,6 +107,18 @@ YAML || fail "tmux: login-shell 'set -g default-command' (DEFECT 3) not in generated config" pass "rig init --yes generated tmux v2 config + boot script (login-shell, new-session -d)" + # ── global git-excludes (core.excludesfile) — clean-machine path ──────────────── + # core.excludesfile was UNSET in this isolated HOME, so apply must (1) set it to the XDG + # default and (2) write the rig-managed block there. Everything stays under the throwaway + # HOME; the real global git config is never touched. + excludes_val="$(git config --global core.excludesfile || true)" + [[ "$excludes_val" == "~/.config/git/ignore" ]] || fail "core.excludesfile not set to XDG default (got '$excludes_val')" + excludes_file="$HOME/.config/git/ignore" + [[ -f "$excludes_file" ]] || fail "global excludes file not written at $excludes_file" + grep -q "rig-managed" "$excludes_file" || fail "rig-managed marker missing from global excludes file" + grep -q "\.claude/worktrees/" "$excludes_file" || fail "worktrees entry missing from global excludes file" + pass "rig init --yes set core.excludesfile + wrote the rig-managed global-excludes block" + # idempotency: a second apply changes nothing (no created/updated/backed_up in summary) out="$($RIG apply -C "$TMP" --config "$TMP/rig.yaml" 2>&1)" summary="$(echo "$out" | grep '^Summary:' || true)" @@ -113,6 +127,11 @@ YAML fi pass "rig apply is idempotent ($summary)" + # the second apply must not have duplicated the managed block: exactly one begin + one end. + marker_count="$(grep -c "rig-managed" "$excludes_file" || true)" + [[ "$marker_count" -eq 2 ]] || fail "global-excludes block churned/duplicated on re-apply (markers=$marker_count, want 2)" + pass "global-excludes block is byte-stable across re-apply (markers=2)" + # status reports in sync $RIG status -C "$TMP" --config "$TMP/rig.yaml" >/dev/null || fail "status nonzero when in sync" pass "rig status: in sync" @@ -155,6 +174,7 @@ ci: { enabled: false } git_hooks: { dispatcher: { enabled: false } } mcp: { enabled: false } agents_md: { enabled: false } +gitignore: { enabled: false } YAML CLEANREPO="$TMP/clean-repo"; mkdir -p "$CLEANREPO"; ( cd "$CLEANREPO" && git init -q ) cp "$CLEAN" "$CLEANREPO/rig.yaml" diff --git a/tests/test_catalog_plan.py b/tests/test_catalog_plan.py index d4c4200..b8ede20 100644 --- a/tests/test_catalog_plan.py +++ b/tests/test_catalog_plan.py @@ -204,8 +204,8 @@ def test_xdg_config_home_maps_dispatcher_dir(fake_agent_tools, tmp_path, monkeyp def test_plan_disabled_category(fake_agent_tools, tmp_path): cat = Catalog.scan(str(fake_agent_tools)) - # agents_md and the github ruleset are default-ON, so turn them off too to assert a truly - # empty plan. + # agents_md, the github ruleset, and the global-excludes block are default-ON, so turn them + # off too to assert a truly empty plan. cfg = _cfg( { "skills": {"enabled": False}, @@ -214,6 +214,7 @@ def test_plan_disabled_category(fake_agent_tools, tmp_path): "mcp": {"enabled": False}, "agents_md": {"enabled": False}, "github": {"ruleset": {"enabled": False}}, + "gitignore": {"enabled": False}, }, tmp_path, ) diff --git a/tests/test_global_excludes.py b/tests/test_global_excludes.py new file mode 100644 index 0000000..be10da5 --- /dev/null +++ b/tests/test_global_excludes.py @@ -0,0 +1,673 @@ +"""rig-managed block in the GLOBAL git excludes file — the ``gitignore`` block. + +This is the GLOBAL counterpart of the per-repo gitignore approach (superseded #23): rig owns ONE +marker-delimited block in git's global ``core.excludesfile`` so harness artifacts (chiefly Claude +Code's throwaway ``**/.claude/worktrees/``) are ignored in EVERY repo on the machine — with zero +per-repo commits and no per-repo ``rig apply``. + +Covers: target resolution BOTH ways (``core.excludesfile`` already set vs unset), every resolved +``state`` (create / update / ok / conflict / io_error), STRICT idempotent re-apply (byte-identical +after a 2nd apply), the dedup-of-managed-region collapse (a prior non-idempotent appender left +several blocks), the default-ON + opt-out plan gating, config validation, and drift parity. The +guiding invariant: apply and drift switch on the SAME ``resolve_global_excludes`` state, so they can +never disagree — and rig only ever edits its OWN marker-fenced lines plus the ``core.excludesfile`` +git-config setting, preserving every other line in the file verbatim (CRLF and trailing blanks +included). + +Every test INJECTS the git-config read/write seams (``_git_global`` / ``_set_git_global``) and the +target file path, so no test ever runs real ``git config --global`` or writes the real +``~/.gitignore``. ``.serena/`` is deliberately NOT ignored (Serena state is committed shared +project memory). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from riglib import drift as driftmod +from riglib.actions import runner as runnermod +from riglib.actions.runner import ( + _do_provision_global_excludes, + global_excludes_block_text, + resolve_global_excludes, + run_plan, +) +from riglib.config import ( + GITIGNORE_BEGIN_MARKER, + GITIGNORE_DEFAULT_ENTRIES, + GITIGNORE_END_MARKER, + ConfigError, + LoadedConfig, + validate, +) +from riglib.drift import ( + DriftReport, + check_disabled_global_excludes, + detect, +) +from riglib.plan import Action, InstallPlan, build + +DEFAULT_ENTRIES = list(GITIGNORE_DEFAULT_ENTRIES) + + +# ── seams: inject the git-config read/write so no test touches real `git config --global` ── +@pytest.fixture +def git_config(monkeypatch): + """A controllable in-memory ``git config --global`` for the global-excludes seams. + + Patches ``_git_global`` (read) and ``_set_git_global`` (write) on BOTH the runner and the + drift module (drift imports ``_git_global`` by name). Returns the backing dict so a test can + pre-seed ``core.excludesfile`` (the "already set" path) or assert what apply WROTE. + """ + store: dict[str, str] = {} + + def _read(key: str): + return store.get(key) + + def _write(key: str, value: str) -> int: + store[key] = value + return 0 + + for mod in (runnermod, driftmod): + monkeypatch.setattr(mod, "_git_global", _read, raising=False) + monkeypatch.setattr(runnermod, "_set_git_global", _write) + return store + + +def _action(target: Path | None = None, entries=None, *, override: str | None = None) -> Action: + options: dict = { + "entries": entries if entries is not None else DEFAULT_ENTRIES, + "xdg_default": "~/.config/git/ignore", + } + if override is not None: + options["excludesfile"] = override + return Action( + kind="provision_global_excludes", + category="gitignore", + item="block", + source=Path("/repo"), + target=target if target is not None else Path("~/.config/git/ignore"), + options=options, + ) + + +def _apply(target: Path | None = None, entries=None, *, override: str | None = None): + return _do_provision_global_excludes(_action(target, entries, override=override), "backup") + + +def _block(entries) -> str: + return global_excludes_block_text(entries) + + +# ── target resolution: core.excludesfile ALREADY set → manage THAT file, no git-config write ── +def test_target_resolution_honors_existing_excludesfile(git_config, tmp_path): + existing = tmp_path / "my-global-ignore" + git_config["core.excludesfile"] = str(existing) + res = _apply() + assert res.status == "created" + assert existing.is_file() # the block landed in the user's existing file + assert _block(DEFAULT_ENTRIES) in existing.read_text() + # rig did NOT move/rewrite core.excludesfile — the user's choice is respected. + assert git_config["core.excludesfile"] == str(existing) + + +# ── target resolution: core.excludesfile UNSET → set it to XDG default AND write the block ── +def test_target_resolution_sets_excludesfile_when_unset(git_config, monkeypatch, tmp_path): + # isolate HOME so the XDG default (~/.config/git/ignore) expands under the throwaway dir. + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + assert "core.excludesfile" not in git_config + res = _apply() + assert res.status == "created" + # clean-machine: rig wrote git config AND created the file at the XDG default. + assert git_config["core.excludesfile"] == "~/.config/git/ignore" + written = home / ".config" / "git" / "ignore" + assert written.is_file() + assert _block(DEFAULT_ENTRIES) in written.read_text() + + +def test_target_resolution_respects_xdg_config_home(git_config, monkeypatch, tmp_path): + xdg = tmp_path / "xdg" + xdg.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) + res = _apply() + assert res.status == "created" + written = xdg / "git" / "ignore" + assert written.is_file() # follows XDG_CONFIG_HOME, where git actually reads + assert _block(DEFAULT_ENTRIES) in written.read_text() + + +def test_explicit_excludesfile_override_sets_config_to_it(git_config, tmp_path): + forced = tmp_path / "forced-ignore" + git_config["core.excludesfile"] = "/some/other/file" # config points elsewhere + res = _apply(override=str(forced)) + assert res.status == "created" + assert forced.is_file() + # override wins: rig set core.excludesfile to the override since git's value didn't match. + assert git_config["core.excludesfile"] == str(forced) + + +# ── zero-churn no-op: the canonical block is byte-identical to a provisioned machine's ── +def test_canonical_block_text_is_byte_stable(): + """Pin the EXACT default block so a re-apply against an already-provisioned machine is a true + no-op (the CTO's hard requirement). A reword of the marker/comment/entry would rewrite every + provisioned machine's block — this regression test makes that an explicit, visible change. + """ + expected = ( + "# >>> rig-managed (do not edit) >>>\n" + "# Claude Code creates throwaway worktrees under each repo's .claude/worktrees/; " + "rig ignores them globally.\n" + "**/.claude/worktrees/\n" + "# <<< rig-managed (do not edit) <<<" + ) + assert global_excludes_block_text(DEFAULT_ENTRIES) == expected + + +def test_reapply_is_noop_against_already_provisioned_block(git_config, tmp_path): + """A file that already contains the exact canonical block (among other user lines) resolves to + ``ok`` and a re-apply leaves it byte-identical — the provisioned-machine steady state. + """ + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text( + "*.pyc\n**/.claude/settings.local.json\n\n" + _block(DEFAULT_ENTRIES) + "\n", + encoding="utf-8", + ) + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "ok" + before = gi.read_bytes() + assert _apply(gi).status == "skipped" + assert gi.read_bytes() == before # zero churn + + +# ── default entry is the harness worktrees dir, never .serena/ ──────────────────── +def test_default_entries_ignore_worktrees_not_serena(): + assert "**/.claude/worktrees/" in DEFAULT_ENTRIES + assert ".serena/" not in DEFAULT_ENTRIES # Serena state is committed, never ignored + block = global_excludes_block_text(DEFAULT_ENTRIES) + assert block.startswith(GITIGNORE_BEGIN_MARKER) + assert block.endswith(GITIGNORE_END_MARKER) + assert "**/.claude/worktrees/" in block + + +# ── create: fresh file ───────────────────────────────────────────────────────────── +def test_fresh_create_when_no_file(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "create" + res = _apply(gi) + assert res.status == "created" + assert gi.is_file() + text = gi.read_text() + assert _block(DEFAULT_ENTRIES) in text + assert text.endswith("\n") + + +def test_create_appends_to_existing_file_preserving_lines(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text("node_modules/\n*.log\n", encoding="utf-8") + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "create" + res = _apply(gi) + assert res.status == "created" + text = gi.read_text() + assert "node_modules/\n*.log\n" in text # user lines preserved verbatim + assert _block(DEFAULT_ENTRIES) in text + assert text.index("node_modules/") < text.index(GITIGNORE_BEGIN_MARKER) + assert "*.log\n\n# >>> rig-managed" in text # single blank-line separator + + +# ── ok: STRICT idempotent re-apply (byte-identical) ──────────────────────────────── +def test_idempotent_second_apply_byte_identical(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + assert _apply(gi).status == "created" + before = gi.read_bytes() + second = _apply(gi) + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "ok" + assert second.status == "skipped" and "already correct" in second.detail + assert gi.read_bytes() == before # byte-identical, no churn, no append + + +def test_idempotent_when_block_already_present_among_other_lines(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text(f"node_modules/\n\n{_block(DEFAULT_ENTRIES)}\n\n*.tmp\n", encoding="utf-8") + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "ok" + res = _apply(gi) + assert res.status == "skipped" + text = gi.read_text() + assert "node_modules/" in text and "*.tmp" in text # both sides preserved + + +# ── update: block differs, just the block is replaced ────────────────────────────── +def test_update_replaces_just_the_block_preserving_other_lines(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + stale = global_excludes_block_text([".claude/old-cruft/"]) + gi.write_text(f"# top user line\nbuild/\n\n{stale}\n\n# bottom user line\ndist/\n", encoding="utf-8") + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "update" + res = _apply(gi) + assert res.status == "updated" + text = gi.read_text() + assert "**/.claude/worktrees/" in text + assert ".claude/old-cruft/" not in text + assert "# top user line" in text and "build/" in text + assert "# bottom user line" in text and "dist/" in text + assert text.index("build/") < text.index(GITIGNORE_BEGIN_MARKER) < text.index("# bottom user line") + + +def test_update_then_idempotent(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text(f"x/\n{global_excludes_block_text(['.claude/old/'])}\ny/\n", encoding="utf-8") + assert _apply(gi).status == "updated" + before = gi.read_bytes() + assert _apply(gi).status == "skipped" + assert gi.read_bytes() == before # converged → strictly idempotent + + +# ── DEDUP: a prior non-idempotent appender left SEVERAL managed blocks → collapse to one ── +def test_dedup_collapses_multiple_managed_blocks(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + block = _block(DEFAULT_ENTRIES) + # user line, then the SAME managed block appended 3 times (the bug the CTO described). + gi.write_text(f"node_modules/\n{block}\n{block}\n{block}\n*.tmp\n", encoding="utf-8") + r = resolve_global_excludes(gi, DEFAULT_ENTRIES) + assert r.state == "update" # duplicates are a reconcile, not a conflict + res = _apply(gi) + assert res.status == "updated" + text = gi.read_text() + # collapsed to EXACTLY one begin/one end marker; user lines on both sides survive. + assert text.count(GITIGNORE_BEGIN_MARKER) == 1 + assert text.count(GITIGNORE_END_MARKER) == 1 + assert "node_modules/" in text and "*.tmp" in text + # and it is now strictly idempotent. + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "ok" + assert _apply(gi).status == "skipped" + + +def test_dedup_preserves_user_line_between_managed_blocks(git_config, tmp_path): + """A user-added ignore that landed BETWEEN two duplicated rig blocks must survive the collapse. + + The dedup splices out each marker-pair region individually (not first-begin..last-end), so any + content between blocks — which is the user's, outside every marker pair — is preserved. + """ + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + block = _block(DEFAULT_ENTRIES) + gi.write_text(f"head/\n{block}\nuser-between/\n{block}\ntail/\n", encoding="utf-8") + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "update" + _apply(gi) + text = gi.read_text() + assert text.count(GITIGNORE_BEGIN_MARKER) == 1 # collapsed to one block + assert "head/" in text and "tail/" in text + assert "user-between/" in text # the line BETWEEN the two blocks is NOT deleted + # and strictly idempotent after the collapse. + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "ok" + before = gi.read_bytes() + assert _apply(gi).status == "skipped" + assert gi.read_bytes() == before + + +def test_nested_or_misordered_markers_is_conflict(git_config, tmp_path): + """Two begin markers before any end (a nested/overlapping block) is ambiguous → conflict.""" + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + original = f"{GITIGNORE_BEGIN_MARKER}\n{GITIGNORE_BEGIN_MARKER}\nx/\n{GITIGNORE_END_MARKER}\n" + gi.write_text(original, encoding="utf-8") + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "conflict" + res = _apply(gi) + assert res.status == "skipped" + assert gi.read_text() == original # untouched + + +def test_dedup_collapses_duplicated_drifted_blocks(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + # two DIFFERENT stale blocks stacked — collapse the whole region to the one desired block. + a = global_excludes_block_text([".claude/old-a/"]) + b = global_excludes_block_text([".claude/old-b/"]) + gi.write_text(f"head/\n{a}\n{b}\ntail/\n", encoding="utf-8") + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "update" + _apply(gi) + text = gi.read_text() + assert text.count(GITIGNORE_BEGIN_MARKER) == 1 + assert ".claude/old-a/" not in text and ".claude/old-b/" not in text + assert "**/.claude/worktrees/" in text + assert "head/" in text and "tail/" in text + + +# ── custom entries (configurable) ────────────────────────────────────────────────── +def test_custom_entries_are_used(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + entries = ["**/.claude/worktrees/", ".cache/agent-tools/", "scratch/"] + res = _apply(gi, entries=entries) + assert res.status == "created" + text = gi.read_text() + for e in entries: + assert e in text + assert _block(entries) in text # in the given order, inside the markers + + +# ── conflict: unbalanced markers left untouched ──────────────────────────────────── +def test_unbalanced_markers_is_conflict_left_untouched(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + original = f"node_modules/\n{GITIGNORE_BEGIN_MARKER}\n**/.claude/worktrees/\n" + gi.write_text(original, encoding="utf-8") + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "conflict" + res = _apply(gi) + assert res.status == "skipped" and "unbalanced" in res.detail + assert gi.read_text() == original # untouched + + +def test_end_before_begin_is_conflict(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + original = f"{GITIGNORE_END_MARKER}\n**/.claude/worktrees/\n{GITIGNORE_BEGIN_MARKER}\n" + gi.write_text(original, encoding="utf-8") + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "conflict" + res = _apply(gi) + assert res.status == "skipped" + assert gi.read_text() == original # untouched + + +# ── plan gating: default ON, explicit opt-out ───────────────────────────────────── +def _cfg(data: dict, repo: Path) -> LoadedConfig: + return LoadedConfig(data={"version": 1, **data}, repo_root=repo) + + +def test_plan_includes_global_excludes_by_default(fake_agent_tools, tmp_path): + from riglib.catalog import Catalog + + cat = Catalog.scan(str(fake_agent_tools)) + plan = build(_cfg({"skills": {"enabled": False}}, tmp_path), cat, project_type="cli") + actions = [a for a in plan.actions if a.kind == "provision_global_excludes"] + assert len(actions) == 1 + assert actions[0].options["entries"] == DEFAULT_ENTRIES + assert actions[0].options["xdg_default"] == "~/.config/git/ignore" + + +def test_plan_opt_out(fake_agent_tools, tmp_path): + from riglib.catalog import Catalog + + cat = Catalog.scan(str(fake_agent_tools)) + plan = build(_cfg({"gitignore": {"enabled": False}}, tmp_path), cat, project_type="cli") + assert not any(a.kind == "provision_global_excludes" for a in plan.actions) + + +def test_plan_honors_custom_entries(fake_agent_tools, tmp_path): + from riglib.catalog import Catalog + + cat = Catalog.scan(str(fake_agent_tools)) + entries = ["**/.claude/worktrees/", "tmp/"] + plan = build(_cfg({"gitignore": {"entries": entries}}, tmp_path), cat, project_type="cli") + action = next(a for a in plan.actions if a.kind == "provision_global_excludes") + assert action.options["entries"] == entries + + +def test_plan_empty_entries_falls_back_to_default(fake_agent_tools, tmp_path): + from riglib.catalog import Catalog + + cat = Catalog.scan(str(fake_agent_tools)) + plan = build(_cfg({"gitignore": {"entries": []}}, tmp_path), cat, project_type="cli") + action = next(a for a in plan.actions if a.kind == "provision_global_excludes") + assert action.options["entries"] == DEFAULT_ENTRIES + + +def test_plan_carries_excludesfile_override(fake_agent_tools, tmp_path): + from riglib.catalog import Catalog + + cat = Catalog.scan(str(fake_agent_tools)) + plan = build(_cfg({"gitignore": {"excludesfile": "~/.gitignore"}}, tmp_path), cat, project_type="cli") + action = next(a for a in plan.actions if a.kind == "provision_global_excludes") + assert action.options["excludesfile"] == "~/.gitignore" + + +# ── config validation (fail-closed) ─────────────────────────────────────────────── +def test_validate_rejects_non_bool_enabled(): + with pytest.raises(ConfigError, match="gitignore.enabled must be a bool"): + validate({"version": 1, "gitignore": {"enabled": "yes"}}) + + +def test_validate_rejects_unknown_key(): + with pytest.raises(ConfigError, match="unknown gitignore key"): + validate({"version": 1, "gitignore": {"markers": "..."}}) + + +def test_validate_rejects_non_string_list_entries(): + with pytest.raises(ConfigError, match="gitignore.entries must be a list of strings"): + validate({"version": 1, "gitignore": {"entries": ["**/.claude/worktrees/", 5]}}) + with pytest.raises(ConfigError, match="gitignore.entries must be a list of strings"): + validate({"version": 1, "gitignore": {"entries": "**/.claude/worktrees/"}}) + + +def test_validate_rejects_non_string_excludesfile(): + with pytest.raises(ConfigError, match="gitignore.excludesfile must be a string"): + validate({"version": 1, "gitignore": {"excludesfile": 5}}) + + +def test_validate_rejects_non_mapping_block(): + with pytest.raises(ConfigError, match="gitignore must be a mapping"): + validate({"version": 1, "gitignore": ["x"]}) + + +def test_validate_accepts_empty_and_valid_block(): + validate({"version": 1, "gitignore": {}}) + validate({"version": 1, "gitignore": {"enabled": True, "entries": ["**/.claude/worktrees/"]}}) + validate({"version": 1, "gitignore": {"excludesfile": "~/.gitignore"}}) + + +def test_validate_rejects_entry_containing_marker(): + with pytest.raises(ConfigError, match="may not contain a rig-managed marker"): + validate({"version": 1, "gitignore": {"entries": [GITIGNORE_BEGIN_MARKER]}}) + with pytest.raises(ConfigError, match="may not contain a rig-managed marker"): + validate({"version": 1, "gitignore": {"entries": [f"x {GITIGNORE_END_MARKER}"]}}) + + +# ── drift parity (apply and drift switch on the same state) ─────────────────────── +def _plan_with_action(target: Path, entries=None) -> InstallPlan: + plan = InstallPlan() + plan.actions.append(_action(target, entries)) + return plan + + +def test_drift_missing_then_in_sync(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + plan = _plan_with_action(gi) + before = detect(plan) + assert any(i.category == "gitignore" and i.item == "block" and i.direction == "missing" for i in before.items) + run_plan(plan) + assert not any(i.category == "gitignore" for i in detect(plan).items) + + +def test_drift_flags_unset_excludesfile(git_config, monkeypatch, tmp_path): + # core.excludesfile unset → drift surfaces "apply will set it" (a GLOBAL drift item). + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + plan = _plan_with_action(home / ".config" / "git" / "ignore") + report = detect(plan) + assert any( + i.category == "gitignore" and i.item == "core.excludesfile" and i.direction == "missing" + for i in report.items + ) + + +def test_drift_flags_modified_block(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text(f"{global_excludes_block_text(['.claude/old/'])}\n", encoding="utf-8") + report = detect(_plan_with_action(gi)) + assert any(i.category == "gitignore" and i.item == "block" and i.direction == "modified" for i in report.items) + + +def test_drift_flags_duplicated_blocks_as_modified(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + block = _block(DEFAULT_ENTRIES) + gi.write_text(f"{block}\n{block}\n", encoding="utf-8") # duplicated, not yet collapsed + report = detect(_plan_with_action(gi)) + assert any(i.category == "gitignore" and i.item == "block" and i.direction == "modified" for i in report.items) + + +def test_drift_flags_conflict(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text(f"{GITIGNORE_BEGIN_MARKER}\n**/.claude/worktrees/\n", encoding="utf-8") + report = detect(_plan_with_action(gi)) + gi_items = [i for i in report.items if i.category == "gitignore" and i.item == "block"] + assert gi_items and "unbalanced" in gi_items[0].detail + + +def test_drift_clean_when_block_correct_among_other_lines(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text(f"node_modules/\n{_block(DEFAULT_ENTRIES)}\n*.log\n", encoding="utf-8") + report = detect(_plan_with_action(gi)) + assert not any(i.category == "gitignore" for i in report.items) + + +# ── verbatim preservation: CRLF, trailing blanks, no-final-newline ───────────────── +def test_update_preserves_crlf_and_trailing_blanks_outside_block(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + stale = global_excludes_block_text([".claude/old/"]).replace("\n", "\r\n") + raw = f"node_modules/\r\n{stale}\r\nbuild/\r\n\r\n" + gi.write_bytes(raw.encode("utf-8")) + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "update" + _apply(gi) + out = gi.read_bytes().decode("utf-8") + assert "node_modules/\r\n" in out + assert "build/\r\n\r\n" in out + assert "**/.claude/worktrees/" in out and ".claude/old/" not in out + + +def test_create_appends_to_crlf_file_preserving_user_crlf(git_config, tmp_path): + """Appending the block to a CRLF file with no managed block keeps the user's CRLF lines intact. + + rig's own block is canonically LF (documented), but the user's existing CRLF content before it + must survive byte-for-byte — no stripped \\r, no clobbered line. + """ + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_bytes(b"node_modules/\r\n*.log\r\n") # CRLF, no managed block + assert resolve_global_excludes(gi, DEFAULT_ENTRIES).state == "create" + _apply(gi) + out = gi.read_bytes().decode("utf-8") + assert "node_modules/\r\n*.log\r\n" in out # user CRLF lines preserved verbatim + assert _block(DEFAULT_ENTRIES) in out + + +def test_create_appends_without_clobbering_no_final_newline(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text("*.log", encoding="utf-8") # no trailing newline + res = _apply(gi) + assert res.status == "created" + text = gi.read_text() + assert text.startswith("*.log\n\n") + assert _block(DEFAULT_ENTRIES) in text + + +def test_empty_existing_file_creates_block_without_leading_blank(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text("", encoding="utf-8") + res = _apply(gi) + assert res.status == "created" and "added block to" in res.detail + assert gi.read_text() == _block(DEFAULT_ENTRIES) + "\n" # no spurious leading blank line + + +# ── io_error: unreadable path is an ERROR, not a silent skip ─────────────────────── +def test_directory_at_path_is_io_error(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.mkdir() # a directory sits where the excludes file should be + r = resolve_global_excludes(gi, DEFAULT_ENTRIES) + assert r.state == "io_error" + res = _apply(gi) + assert res.status == "error" and "cannot read" in res.detail + assert gi.is_dir() # untouched + + +def test_io_error_surfaces_in_drift_not_in_sync(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.mkdir() + report = detect(_plan_with_action(gi)) + assert any(i.category == "gitignore" and i.item == "block" and i.direction == "modified" for i in report.items) + + +# ── empty entries: an empty block round-trips as ok ──────────────────────────────── +def test_empty_entries_produce_empty_block_that_round_trips(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + res = _apply(gi, entries=[]) + assert res.status == "created" + text = gi.read_text() + # even with no entries the block still carries the fixed explanatory comment (it is part of the + # canonical block), so the on-disk text is exactly the rendered empty block + a trailing newline. + assert text == _block([]) + "\n" + assert GITIGNORE_BEGIN_MARKER in text and GITIGNORE_END_MARKER in text + assert resolve_global_excludes(gi, []).state == "ok" + assert _apply(gi, entries=[]).status == "skipped" + + +# ── disabled-but-installed block surfaces as drift ───────────────────────────────── +def test_disabled_category_still_flags_leftover_block(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text(f"node_modules/\n{_block(DEFAULT_ENTRIES)}\n", encoding="utf-8") + report = DriftReport() + check_disabled_global_excludes(_action(gi), report) + assert any(i.category == "gitignore" and i.direction == "extra" for i in report.items) + + +def test_disabled_category_no_block_is_clean(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) + gi.write_text("node_modules/\n", encoding="utf-8") + report = DriftReport() + check_disabled_global_excludes(_action(gi), report) + assert not any(i.category == "gitignore" for i in report.items) + + +def test_disabled_category_no_file_is_clean(git_config, tmp_path): + gi = tmp_path / "ignore" + git_config["core.excludesfile"] = str(gi) # file does not exist + report = DriftReport() + check_disabled_global_excludes(_action(gi), report) + assert not report.items + + +# ── full loop: build → apply → detect in sync, second apply a no-op ──────────────── +def test_end_to_end_build_apply_detect_in_sync(git_config, fake_agent_tools, monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + from riglib.catalog import Catalog + + repo = tmp_path / "repo" + repo.mkdir() + cat = Catalog.scan(str(fake_agent_tools)) + plan = build(_cfg({"skills": {"enabled": False}}, repo), cat, project_type="cli") + report = run_plan(plan) + assert not report.errors, [r.detail for r in report.errors] + written = home / ".config" / "git" / "ignore" + assert written.is_file() + assert "**/.claude/worktrees/" in written.read_text() + assert git_config["core.excludesfile"] == "~/.config/git/ignore" + # second apply is a no-op for the global-excludes action + second = run_plan(plan) + assert all(r.status == "skipped" for r in second.results if r.action.category == "gitignore") + assert not any(i.category == "gitignore" for i in detect(plan).items) diff --git a/tests/test_status_layers.py b/tests/test_status_layers.py index e8426a3..18371df 100644 --- a/tests/test_status_layers.py +++ b/tests/test_status_layers.py @@ -259,12 +259,13 @@ def test_status_clean_repo_exits_zero(tmp_path, capsys, fake_agent_tools, monkey monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "no-global")) repo = _git_repo(tmp_path / "repo") cfg = repo / "rig.yaml" - # everything OFF, including agents_md (on by default → would otherwise drift the symlink) + # everything OFF, including the default-on categories that would otherwise drift: agents_md + # (the symlink) and gitignore (the GLOBAL core.excludesfile block — on by default). cfg.write_text( f"version: 1\nagent_tools_source: {fake_agent_tools}\n" "skills: {enabled: false}\nagent_hooks: {enabled: false}\nmcp: {enabled: false}\n" "git_hooks: {dispatcher: {enabled: false}}\nci: {enabled: false}\n" - "agents_md: {enabled: false}\n", + "agents_md: {enabled: false}\ngitignore: {enabled: false}\n", encoding="utf-8", ) rc = main(["status", "-C", str(repo)])