Add gitignore-claude template and wire into registration flows
Introduce scripts/project_rules/gitignore-claude.template with the fleet pattern that tracks .claude/rules/ while ignoring machine-local Claude state. ensure_gitignore_claude_rules.py applies it during statehub register, register_project.sh, and update_agent_instruction_files fleet regen.
This commit is contained in:
parent
0d2fca5711
commit
638cdc4ee2
7 changed files with 128 additions and 0 deletions
63
scripts/ensure_gitignore_claude_rules.py
Normal file
63
scripts/ensure_gitignore_claude_rules.py
Normal file
|
|
@ -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())
|
||||
4
scripts/project_rules/gitignore-claude.template
Normal file
4
scripts/project_rules/gitignore-claude.template
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Claude Code local state (track shared rules; ignore machine-specific files)
|
||||
.claude/*
|
||||
!.claude/rules/
|
||||
!.claude/rules/*.md
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
41
tests/test_ensure_gitignore_claude_rules.py
Normal file
41
tests/test_ensure_gitignore_claude_rules.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue