Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ dist/
# uv writes a lockfile when run via `uv run`; the dev/CI path is pip, so the lock is a
# local artifact — never commit it.
uv.lock

# >>> rig-managed (do not edit) >>>
.claude/worktrees/
# <<< rig-managed (do not edit) <<<
70 changes: 69 additions & 1 deletion docs/config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ harness: { ... } # agent harness auto/permission provisioning (auto
models: { ... } # daily model-freshness checker schedule (launchd/crontab cron)
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)
gitignore: { ... } # rig-managed .gitignore block (ignores .claude/worktrees/), default ON
```

If `agent_tools_source` is omitted, rig resolves it from `$RIG_AGENT_TOOLS_SOURCE`, then
Expand Down Expand Up @@ -451,6 +452,71 @@ Set `RIG_GH_DRY_RUN=1` to compute what *would* change (the create/update is repo

---

## `gitignore`

Maintains a **rig-managed block** in the repo's `.gitignore` (at the repo root) so harness
artifacts are ignored **declaratively by the tool** — reconciled like every other category, not
by a hand-edited global `~/.gitignore` ("fix tooling, not manual"). The motivating case: Claude
Code creates throwaway worktrees under each repo's `.claude/worktrees/`; those must be gitignored,
and rig owns that ignore. 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
```

| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `enabled` | bool | `true` | provision the managed block (set `false` to leave `.gitignore` untouched) |
| `entries` | list[str] | `[.claude/worktrees/]` | the paths ignored inside the managed block; an empty/absent list uses the default |

**`.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 touches **only** what is
between them:

```
# >>> rig-managed (do not edit) >>>
.claude/worktrees/
# <<< rig-managed (do not edit) <<<
```

**On-disk cases (idempotent, surgical, never destructive):**

- **no `.gitignore`** → create it with the managed block.
- **`.gitignore` without the block** → append the block after the existing content (one blank-line
separator); every existing line is preserved verbatim.
- **block present and correct** → no-op (in sync).
- **block present but its entries differ** → replace **just the block** in place; every line
**outside** the markers (above and below) is preserved verbatim, and the block stays where it sits.
- **unbalanced / duplicated markers** (a begin with no end, an end before a begin, two blocks) →
**conflict**: rig won't guess the block's extent, so it leaves the file completely untouched and
surfaces it via `rig status`. Reconcile by hand, then re-apply.
- **unreadable `.gitignore`** (no read permission, or a directory at the path) → **io_error**:
`rig apply` returns an `error` (it could not even inspect the file — never a silent exit 0), and
`rig status` surfaces a could-not-verify drift item rather than reporting in sync.

Lines OUTSIDE the markers are preserved **byte-for-byte**: the block is located and spliced by raw
offset (the file is read with newline translation off), so a CRLF file, a file with no trailing
newline, and trailing blank lines all survive untouched. `entries` may not contain a marker line
(validation rejects it — it would make the file a permanent conflict).

`resolve_gitignore` is the single classification `rig apply` and `rig status` share (so they can
never disagree): `create` (missing block → append/create), `update` (block differs → replace just
the block), `ok` (matches → no-op), `conflict` (unbalanced markers → untouched, surfaced),
`io_error` (unreadable → apply errors, status surfaces). There is no backup file — rig only ever
edits its own fenced lines, so there is no user data to preserve (`on_conflict` does not apply
here). Opting the category out (`enabled: false`) after a block was installed does NOT delete it
(apply never deletes); `rig status` then reports the leftover block as a disk→config extra so you
can remove it deliberately.

---

## Validation

`apply`/`status`/`init` validate before touching disk and **fail closed** on:
Expand All @@ -461,5 +527,7 @@ a malformed/out-of-range `models.schedule.time` or unknown `models` key, a non-b
`agents_md.enabled`/`agents_md.symlink` or unknown `agents_md` key, an unknown
`github`/`github.ruleset` key, a non-bool `github.ruleset` boolean knob, a
`github.ruleset.required_reviews` that is not an int ≥ 0, a `github.ruleset.required_status_checks`
that is not a list of strings, and an `agent_tools_source` that is not an agent-tools checkout.
that is not a list of strings, a non-mapping `gitignore` block / non-bool `gitignore.enabled` /
unknown `gitignore` key / 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.
10 changes: 10 additions & 0 deletions rig.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,13 @@ harness:
enabled: true
kind: claude-code
auto_mode: true

# ── Gitignore — rig owns a marker-delimited block in this repo's .gitignore ────────
# Claude Code creates throwaway worktrees under .claude/worktrees/; rig ignores them
# DECLARATIVELY (reconciled like every other category) rather than via a hand-edited global
# ignore. NB: .serena/ is intentionally NOT ignored — Serena state is committed shared memory.
# Default ON; this block makes the dogfooded config explicit. Opt out with enabled: false.
gitignore:
enabled: true
entries:
- .claude/worktrees/
166 changes: 166 additions & 0 deletions riglib/actions/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from pathlib import Path
from typing import Callable

from ..config import GITIGNORE_BEGIN_MARKER, GITIGNORE_END_MARKER
from ..github_ruleset import (
build_ruleset_body,
find_managed_ruleset,
Expand Down Expand Up @@ -1282,6 +1283,170 @@ def _do_provision_agents_symlink(action: Action, on_conflict: str) -> ActionResu
return ActionResult(action, "error", f"agents-md: unhandled state {r.state!r}")


# ── rig-managed .gitignore block ───────────────────────────────────────────────────
# rig maintains a marker-delimited block in the repo's `.gitignore` so harness artifacts
# (chiefly Claude Code's throwaway `.claude/worktrees/`) are ignored DECLARATIVELY by the tool,
# reconciled like every other category — not by a hand-edited global ignore ("fix tooling, not
# manual"). The markers fence ONLY rig's lines; every other line the user has in `.gitignore`
# is preserved verbatim. apply and drift BOTH go through `resolve_gitignore` so they can never
# disagree on what the desired block is 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 gitignore_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, one line per entry
(in the order given), then the end marker.
"""
return "\n".join([GITIGNORE_BEGIN_MARKER, *entries, GITIGNORE_END_MARKER])


@dataclass(frozen=True)
class GitignoreResolution:
"""The desired ``.gitignore`` outcome for a repo — 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 ``.gitignore`` 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: replace JUST the block,
preserving every line outside the markers (verbatim — CRLF and trailing
blanks included).
- ``conflict`` — the file has unbalanced/duplicated managed markers (a begin with no end, an
end before a begin, two blocks): 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_gitignore(path: Path, entries: list[str]) -> GitignoreResolution:
"""Classify the on-disk ``.gitignore`` 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) replaces JUST the existing block in place, or (c) no-ops a
correct block. An unbalanced marker pair is a ``conflict`` rig never rewrites. Every line
OUTSIDE the markers is preserved byte-for-byte: the block 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 = gitignore_block_text(entries)
if not path.exists():
return GitignoreResolution(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 block — the documented verbatim guarantee.
with path.open(encoding="utf-8", newline="") as fh:
content = fh.read()
except OSError as exc:
Comment on lines +1353 to +1355

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle non-UTF-8 .gitignore contents

When a repo's .gitignore contains any byte that is not valid UTF-8, this strict text read raises UnicodeDecodeError, but the handler only catches OSError. rig status reaches this through drift._check_gitignore without run_plan's broad exception wrapper, so those repos crash with a traceback instead of surfacing the documented io_error drift item; catch decode errors here or read with a lossless error strategy.

Useful? React with 👍 / 👎.

# unreadable, or a directory at the path — a failure to inspect, not a marker conflict.
return GitignoreResolution(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)
if len(begins) != len(ends) or len(begins) > 1:
return GitignoreResolution(
path, "conflict", desired,
detail=f"{path} has unbalanced/duplicated 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 GitignoreResolution(path, "create", desired, new_content=new_content)

(b_start, _b_line_end), (e_start, e_line_end) = begins[0], ends[0]
if e_start < b_start:
return GitignoreResolution(
path, "conflict", desired,
detail=f"{path} has a rig-managed end marker before its begin marker — reconcile by hand, then re-run",
)
# the block spans from the begin marker's start to the end marker's end-of-line. Compare the
# raw on-disk block to the desired text; if identical it's in sync, else splice in the new block
# and keep everything before b_start and after e_line_end byte-for-byte.
current_block = content[b_start : e_line_end]
if current_block == desired:
return GitignoreResolution(path, "ok", desired)
new_content = content[:b_start] + desired + content[e_line_end:]
return GitignoreResolution(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 _do_provision_gitignore(action: Action, on_conflict: str) -> ActionResult:
"""Provision/reconcile rig's managed block in the repo's ``.gitignore``.

Switches on the shared :func:`resolve_gitignore` ``state`` — apply and drift read the same
classification, so ``status`` never misreports the on-disk state. Idempotent: a correct block
is a no-op; a missing block is appended (creating ``.gitignore`` if absent); a drifted block is
replaced IN PLACE, 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 — apply must not exit 0 having failed to even inspect the file). There is no backup:
rig only ever edits its OWN fenced lines, so there is no user data to preserve — ``on_conflict``
is irrelevant here (consistent with the surgical hook-bridge upsert, which likewise rewrites
only its own marked entries).
"""
entries = [str(e) for e in action.options.get("entries", [])]
r = resolve_gitignore(action.target, entries)
if r.state == "ok":
return ActionResult(action, "skipped", f"gitignore: managed block already correct in {action.target.name}")
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 = action.target.exists()
action.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 action.target.open("w", encoding="utf-8", newline="") as fh:
fh.write(r.new_content)
if r.state == "create":
verb = "added block to" if existed else "created"
return ActionResult(action, "created", f"gitignore: {verb} {action.target.name} ({len(entries)} entr{'y' if len(entries) == 1 else 'ies'})")
return ActionResult(action, "updated", f"gitignore: updated managed block in {action.target.name}")


# ── GitHub repository ruleset (gh api) ─────────────────────────────────────────────
# rig reconciles a branch ruleset on the repo's DEFAULT branch — the modern replacement for
# branch protection — declaratively, the same way every other category is reconciled. The
Expand Down Expand Up @@ -1471,4 +1636,5 @@ def _do_provision_github_ruleset(action: Action, on_conflict: str) -> ActionResu
"provision_schedule": _do_provision_schedule,
"provision_agents_symlink": _do_provision_agents_symlink,
"provision_github_ruleset": _do_provision_github_ruleset,
"provision_gitignore": _do_provision_gitignore,
}
8 changes: 8 additions & 0 deletions riglib/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,14 @@ def cmd_status(args: argparse.Namespace) -> int:
from .drift import check_disabled_dispatcher

check_disabled_dispatcher(loaded.repo_root, report)
# disabled-but-installed gitignore block: config opted the category out, but a prior apply may
# have left the rig-managed block in .gitignore. apply won't remove it, so surface it as
# disk→config drift (mirrors the disabled-dispatcher scan above; the block is repo-local).
gi_cfg = loaded.data.get("gitignore")
if isinstance(gi_cfg, dict) and gi_cfg.get("enabled") is False:
from .drift import check_disabled_gitignore

check_disabled_gitignore(loaded.repo_root, 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)
Expand Down
Loading
Loading