diff --git a/scripts/ensure_gitignore_claude_rules.py b/scripts/ensure_gitignore_claude_rules.py new file mode 100644 index 0000000..27e27db --- /dev/null +++ b/scripts/ensure_gitignore_claude_rules.py @@ -0,0 +1,63 @@ +"""Ensure repos track .claude/rules/ while ignoring other Claude Code local state.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SNIPPET_PATH = ROOT / "scripts" / "project_rules" / "gitignore-claude.template" +MARKER = "# state-hub: track .claude/rules" +TRACK_RULE = "!.claude/rules/" +BLANKET_IGNORE_RE = re.compile(r"^\.claude/?\s*$", re.MULTILINE) + + +def load_snippet() -> str: + text = SNIPPET_PATH.read_text(encoding="utf-8").strip() + return f"{MARKER}\n{text}\n" + + +def snippet_present(text: str) -> bool: + return TRACK_RULE in text + + +def ensure_claude_gitignore(gitignore_path: Path) -> bool: + """Return True when the file was created or modified.""" + snippet = load_snippet() + if gitignore_path.exists(): + text = gitignore_path.read_text(encoding="utf-8") + if snippet_present(text): + return False + if BLANKET_IGNORE_RE.search(text): + updated = BLANKET_IGNORE_RE.sub(snippet.rstrip(), text, count=1) + if updated != text: + gitignore_path.write_text(_ensure_trailing_newline(updated), encoding="utf-8") + return True + separator = "" if text.endswith("\n") or not text else "\n" + gitignore_path.write_text(f"{text}{separator}\n{snippet}", encoding="utf-8") + return True + + gitignore_path.write_text(snippet, encoding="utf-8") + return True + + +def _ensure_trailing_newline(text: str) -> str: + return text if text.endswith("\n") else f"{text}\n" + + +def main() -> int: + import argparse + + parser = argparse.ArgumentParser(description="Ensure .gitignore tracks .claude/rules/.") + parser.add_argument("path", type=Path, help="Repo root or .gitignore file path") + args = parser.parse_args() + + target = args.path + gitignore = target if target.name == ".gitignore" else target / ".gitignore" + changed = ensure_claude_gitignore(gitignore) + print(f"{'updated' if changed else 'ok'}: {gitignore}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/project_rules/gitignore-claude.template b/scripts/project_rules/gitignore-claude.template new file mode 100644 index 0000000..6022b83 --- /dev/null +++ b/scripts/project_rules/gitignore-claude.template @@ -0,0 +1,4 @@ +# Claude Code local state (track shared rules; ignore machine-specific files) +.claude/* +!.claude/rules/ +!.claude/rules/*.md \ No newline at end of file diff --git a/scripts/register_project.sh b/scripts/register_project.sh index e6f1dee..a9652d0 100755 --- a/scripts/register_project.sh +++ b/scripts/register_project.sh @@ -147,6 +147,9 @@ if [[ "$ADDITIONAL" != "true" ]]; then echo "==> SCOPE.md already exists — skipping." fi + echo "==> Ensuring .gitignore tracks .claude/rules/ ..." + python3 "$SCRIPT_DIR/ensure_gitignore_claude_rules.py" "$PROJECT_PATH" + if [[ "$CODEX_MODE" == "true" ]]; then # ── 5b: Codex — write AGENTS.md from HTTP-API template ──────────────── AGENTS_MD="$PROJECT_PATH/AGENTS.md" diff --git a/scripts/update_agent_instruction_files.py b/scripts/update_agent_instruction_files.py index cf1ec57..8f84a71 100644 --- a/scripts/update_agent_instruction_files.py +++ b/scripts/update_agent_instruction_files.py @@ -12,6 +12,13 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent TEMPLATE_DIR = ROOT / "scripts" / "project_rules" + +import sys + +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.ensure_gitignore_claude_rules import ensure_claude_gitignore # noqa: E402 API_BASE = "http://127.0.0.1:8000" HOME_ROOT = Path("/home/worsch") WP_FILE_RE = re.compile(r"^([A-Za-z][A-Za-z0-9-]*-WP)-\d+", re.IGNORECASE) @@ -189,6 +196,8 @@ def update_repo( if not scope_path.exists(): scope_path.write_text(render(scope_template, values), encoding="utf-8") + ensure_claude_gitignore(path / ".gitignore") + rules_dir = path / ".claude" / "rules" rules_dir.mkdir(parents=True, exist_ok=True) for name, template in rule_templates.items(): diff --git a/statehub_register.py b/statehub_register.py index 323853a..76778f4 100644 --- a/statehub_register.py +++ b/statehub_register.py @@ -20,6 +20,8 @@ STATE_HUB_DIR = Path(__file__).resolve().parent API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000") RULES_TEMPLATES_DIR = STATE_HUB_DIR / "scripts" / "project_rules" +from scripts.ensure_gitignore_claude_rules import ensure_claude_gitignore # noqa: E402 + KEY_CONTEXT_FILES = [ "INTENT.md", "README.md", @@ -291,6 +293,10 @@ def write_registration_files( ) written.append(brief_path) + gitignore_path = project_path / ".gitignore" + if ensure_claude_gitignore(gitignore_path): + written.append(gitignore_path) + return written diff --git a/tests/test_ensure_gitignore_claude_rules.py b/tests/test_ensure_gitignore_claude_rules.py new file mode 100644 index 0000000..b99f105 --- /dev/null +++ b/tests/test_ensure_gitignore_claude_rules.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path + +from scripts.ensure_gitignore_claude_rules import ensure_claude_gitignore, snippet_present + + +def test_ensure_claude_gitignore_creates_file(tmp_path: Path): + gitignore = tmp_path / ".gitignore" + + assert ensure_claude_gitignore(gitignore) is True + text = gitignore.read_text(encoding="utf-8") + assert snippet_present(text) + assert "!.claude/rules/" in text + + +def test_ensure_claude_gitignore_replaces_blanket_ignore(tmp_path: Path): + gitignore = tmp_path / ".gitignore" + gitignore.write_text("# Claude Code local state\n.claude/\n", encoding="utf-8") + + assert ensure_claude_gitignore(gitignore) is True + text = gitignore.read_text(encoding="utf-8") + assert ".claude/" not in text or "!.claude/rules/" in text + assert "!.claude/rules/*.md" in text + + +def test_ensure_claude_gitignore_is_idempotent(tmp_path: Path): + gitignore = tmp_path / ".gitignore" + + assert ensure_claude_gitignore(gitignore) is True + assert ensure_claude_gitignore(gitignore) is False + + +def test_ensure_claude_gitignore_appends_when_other_rules_present(tmp_path: Path): + gitignore = tmp_path / ".gitignore" + gitignore.write_text(".venv/\n", encoding="utf-8") + + assert ensure_claude_gitignore(gitignore) is True + text = gitignore.read_text(encoding="utf-8") + assert text.startswith(".venv/\n") + assert "!.claude/rules/" in text \ No newline at end of file diff --git a/tests/test_statehub_register_cli.py b/tests/test_statehub_register_cli.py index 20f9862..a5f7b05 100644 --- a/tests/test_statehub_register_cli.py +++ b/tests/test_statehub_register_cli.py @@ -89,7 +89,9 @@ def test_write_registration_files_primes_codex_repo(tmp_path: Path): "AGENTS.md", ".custodian-brief.md", "DEMO-WP-0001-statehub-bootstrap.md", + ".gitignore", } + assert "!.claude/rules/" in (tmp_path / ".gitignore").read_text() assert (tmp_path / "INTENT.md").read_text() == "# INTENT\n\nDemo service intent.\n" assert "**Repo slug:** demo-service" in (tmp_path / "AGENTS.md").read_text() assert "Run the demo service." in (tmp_path / "SCOPE.md").read_text()