diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-10 01:14:46 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-10 01:14:46 -0500 |
| commit | cc72aa635f733da36010567c8718b1ede7622c52 (patch) | |
| tree | 10d6d6bf0c86fd284349e1c7dac83cb6cbc07c70 /scripts | |
| parent | c401d6d3807a2ffb76a9ec4af8dc783b2c918bfd (diff) | |
| download | rulesets-cc72aa635f733da36010567c8718b1ede7622c52.tar.gz rulesets-cc72aa635f733da36010567c8718b1ede7622c52.zip | |
feat(install-ai): gitignore the full personal-tooling set, add backfill sweep
A gitignore-mode project only ignored .ai/. CLAUDE.md was left untracked but not ignored, so an accidental git add or a codify run could still commit a personal CLAUDE.md, the private rule copies under .claude/, or an AGENTS.md. install-ai now ignores the whole set (.ai/, .claude/, CLAUDE.md, AGENTS.md) at bootstrap, line-idempotent so an existing .gitignore isn't duplicated.
.claude/ goes in the set because it's rulesets-owned (copies of claude-rules/*.md plus the language bundle's rules, hooks, and settings), re-synced from rulesets every startup, so git isn't how it travels. Ignoring it also keeps those private rule copies out of the repo, which ignoring CLAUDE.md alone would miss. The gate is unchanged: track-mode projects (personal/doc repos, team repos sharing config) keep tracking the set.
sweep-gitignore-tooling.sh backfills the set across existing gitignore-mode projects, idempotent and skipping track-mode by design. It warns when a now-ignored path is already tracked, since the ignore won't untrack it. protocols.org states the policy once.
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/install-ai.sh | 29 | ||||
| -rwxr-xr-x | scripts/sweep-gitignore-tooling.sh | 118 | ||||
| -rw-r--r-- | scripts/tests/install-ai.bats | 21 | ||||
| -rw-r--r-- | scripts/tests/sweep-gitignore-tooling.bats | 111 |
4 files changed, 271 insertions, 8 deletions
diff --git a/scripts/install-ai.sh b/scripts/install-ai.sh index a5b38a8..7007eed 100755 --- a/scripts/install-ai.sh +++ b/scripts/install-ai.sh @@ -30,8 +30,11 @@ Bootstrap .ai/ in PROJECT_PATH from canonical content at $CANONICAL. --track Track .ai/ in the project's git history (with .gitkeep files inside otherwise-empty sessions/, references/, retrospectives/). - --gitignore Append .ai/ to the project's .gitignore so session - records stay local. + --gitignore Gitignore the project's personal tooling (.ai/, .claude/, + CLAUDE.md, AGENTS.md) so it stays local. .claude/ is + rulesets-owned and re-synced each startup, so git isn't how + it travels; ignoring it also keeps private rule copies out + of the repo. If neither flag is given, the script prompts interactively. If PROJECT_PATH is omitted, fzf-picks from ~/code/* + ~/projects/* @@ -147,14 +150,24 @@ case "$track_mode" in touch "$project/.ai/retrospectives/.gitkeep" ;; gitignore) - if [ -f "$project/.gitignore" ] && grep -qFx '.ai/' "$project/.gitignore"; then - : # already present - else + # Personal-tooling files that stay out of git in gitignore mode. .ai/ + # holds session records; .claude/ is rulesets-owned bundle content + # (re-synced each startup, and it carries private rule copies); CLAUDE.md + # and AGENTS.md are personal tooling, project-owned but never committed. + # Append only the lines not already present, so a project that already + # ignores some of them isn't duplicated. + gi="$project/.gitignore" + needed=() + for pat in '.ai/' '.claude/' 'CLAUDE.md' 'AGENTS.md'; do + if [ -f "$gi" ] && grep -qFx "$pat" "$gi"; then continue; fi + needed+=("$pat") + done + if [ "${#needed[@]}" -gt 0 ]; then { - [ -s "$project/.gitignore" ] && echo "" + [ -s "$gi" ] && echo "" echo "# Claude Code per-project tooling" - echo ".ai/" - } >> "$project/.gitignore" + printf '%s\n' "${needed[@]}" + } >> "$gi" fi ;; esac diff --git a/scripts/sweep-gitignore-tooling.sh b/scripts/sweep-gitignore-tooling.sh new file mode 100755 index 0000000..63fc066 --- /dev/null +++ b/scripts/sweep-gitignore-tooling.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# sweep-gitignore-tooling.sh — backfill the personal-tooling gitignore set +# across existing gitignore-mode AI projects. +# +# install-ai.sh sets up the ignore set at bootstrap, but a project bootstrapped +# before the set grew (or one that only ever ignored .ai/) is left exposed — an +# accidental `git add` or a /codify run can commit a personal CLAUDE.md, the +# private rule copies under .claude/, or an AGENTS.md. This sweep backfills the +# full set into every gitignore-mode project that's behind. +# +# For each AI project (a directory with .ai/protocols.org) under the search +# roots, if it's a git checkout in gitignore mode (.ai/ already appears in its +# .gitignore), ensure .ai/, .claude/, CLAUDE.md, and AGENTS.md are all ignored. +# Append only the missing lines, so a re-run is a no-op. +# +# Track-mode projects (.ai/ NOT in .gitignore) are skipped by design: they +# track their tooling on purpose — team repos sharing config with teammates who +# don't run rulesets, or private-remote personal repos where the history IS the +# project. +# +# A line added here only stops *future* commits. If a target path is already +# tracked, the ignore has no effect until it's untracked; the sweep warns so +# the path can be `git rm --cached`-ed by hand. +# +# Usage: sweep-gitignore-tooling.sh [--dry-run] [SEARCH_ROOT ...] +# --dry-run Report what would change without writing. +# SEARCH_ROOT ... Directories to scan (default: ~/code ~/projects ~/.emacs.d). + +set -euo pipefail + +IGNORE_SET=('.ai/' '.claude/' 'CLAUDE.md' 'AGENTS.md') + +dry_run=0 +roots=() +for arg in "$@"; do + case "$arg" in + --dry-run) dry_run=1 ;; + -h|--help) + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -*) + echo "ERROR: unknown flag: $arg (use --help for usage)" >&2 + exit 2 + ;; + *) roots+=("$arg") ;; + esac +done + +if [ "${#roots[@]}" -eq 0 ]; then + roots=("$HOME/code" "$HOME/projects" "$HOME/.emacs.d") +fi + +# Discover AI projects: a directory holding .ai/protocols.org. +mapfile -t projects < <( + for root in "${roots[@]}"; do + [ -d "$root" ] || continue + find "$root" -maxdepth 3 -type f -path '*/.ai/protocols.org' 2>/dev/null \ + | sed 's|/\.ai/protocols\.org$||' + done | sort -u +) + +swept=0 skipped=0 complete=0 + +for project in "${projects[@]}"; do + name="$(basename "$project")" + gi="$project/.gitignore" + + if [ ! -d "$project/.git" ]; then + echo "skip $name — not a git checkout" + skipped=$((skipped + 1)) + continue + fi + + # Gitignore mode iff .ai/ is already ignored. Otherwise track-mode: leave it. + if [ ! -f "$gi" ] || ! grep -qFx '.ai/' "$gi"; then + echo "skip $name — track-mode (.ai/ not gitignored)" + skipped=$((skipped + 1)) + continue + fi + + needed=() + for pat in "${IGNORE_SET[@]}"; do + grep -qFx "$pat" "$gi" || needed+=("$pat") + done + + if [ "${#needed[@]}" -eq 0 ]; then + echo "ok $name — already complete" + complete=$((complete + 1)) + continue + fi + + if [ "$dry_run" -eq 1 ]; then + echo "DRY $name — would add: ${needed[*]}" + else + { + echo "" + echo "# Claude Code per-project tooling (swept $(date +%Y-%m-%d))" + printf '%s\n' "${needed[@]}" + } >> "$gi" + echo "swept $name — added: ${needed[*]}" + fi + swept=$((swept + 1)) + + # Warn on any newly-ignored path that's already tracked — the ignore won't + # untrack it. + for pat in "${needed[@]}"; do + path="${pat%/}" + if git -C "$project" ls-files --error-unmatch "$path" >/dev/null 2>&1; then + echo " WARN $name: $path is currently tracked — 'git -C $project rm --cached -r $path' to untrack" + fi + done +done + +echo +echo "Summary: $swept swept, $complete already complete, $skipped skipped (of ${#projects[@]} projects)." +[ "$dry_run" -eq 1 ] && echo "(dry-run — no files written)" +exit 0 diff --git a/scripts/tests/install-ai.bats b/scripts/tests/install-ai.bats index dca70ea..8e91770 100644 --- a/scripts/tests/install-ai.bats +++ b/scripts/tests/install-ai.bats @@ -36,12 +36,33 @@ teardown() { [ -d "$TEST_HOME/code/fresh/.ai/retrospectives" ] [ -f "$TEST_HOME/code/fresh/.ai/protocols.org" ] [ -f "$TEST_HOME/code/fresh/.ai/notes.org" ] + # Gitignore mode ignores the whole personal-tooling set, not just .ai/. grep -qFx ".ai/" "$TEST_HOME/code/fresh/.gitignore" + grep -qFx ".claude/" "$TEST_HOME/code/fresh/.gitignore" + grep -qFx "CLAUDE.md" "$TEST_HOME/code/fresh/.gitignore" + grep -qFx "AGENTS.md" "$TEST_HOME/code/fresh/.gitignore" # Top-level inbox/ is created so the project is an inbox-send target. [ -d "$TEST_HOME/code/fresh/inbox" ] [ -f "$TEST_HOME/code/fresh/inbox/.gitkeep" ] } +@test "install-ai --gitignore: appends only the missing tooling lines" { + mkdir -p "$TEST_HOME/code/partial" + (cd "$TEST_HOME/code/partial" && git init -q) + # Project already ignores .ai/ and CLAUDE.md from a prior convention. + printf '# Personal tooling\n.ai/\nCLAUDE.md\n' > "$TEST_HOME/code/partial/.gitignore" + + run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/partial" + + [ "$status" -eq 0 ] + # The two already-present lines are not duplicated. + [ "$(grep -cFx '.ai/' "$TEST_HOME/code/partial/.gitignore")" -eq 1 ] + [ "$(grep -cFx 'CLAUDE.md' "$TEST_HOME/code/partial/.gitignore")" -eq 1 ] + # The two missing lines were added. + grep -qFx ".claude/" "$TEST_HOME/code/partial/.gitignore" + grep -qFx "AGENTS.md" "$TEST_HOME/code/partial/.gitignore" +} + @test "install-ai: creates top-level inbox/ in --track mode too" { mkdir -p "$TEST_HOME/code/inbox-track" (cd "$TEST_HOME/code/inbox-track" && git init -q) diff --git a/scripts/tests/sweep-gitignore-tooling.bats b/scripts/tests/sweep-gitignore-tooling.bats new file mode 100644 index 0000000..a28087e --- /dev/null +++ b/scripts/tests/sweep-gitignore-tooling.bats @@ -0,0 +1,111 @@ +#!/usr/bin/env bats +# +# Tests for scripts/sweep-gitignore-tooling.sh — backfill the personal-tooling +# gitignore set across existing gitignore-mode AI projects. +# +# Strategy: scaffold fake AI projects (a .ai/protocols.org marker + a git repo) +# under a temp root, run the sweep against that root, and assert on each +# project's .gitignore. + +REAL_REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" +SWEEP="$REAL_REPO/scripts/sweep-gitignore-tooling.sh" + +setup() { + ROOT="$(mktemp -d -t sweep-bats.XXXXXX)" +} + +teardown() { + rm -rf "$ROOT" +} + +# Scaffold a project: $1 = name, $2 = initial .gitignore content (may be empty), +# $3 = "git" to init a repo (default), "nogit" to skip. +make_project() { + local name="$1" gitignore="${2-}" git="${3-git}" + mkdir -p "$ROOT/$name/.ai" + echo "marker" > "$ROOT/$name/.ai/protocols.org" + [ -n "$gitignore" ] && printf '%s' "$gitignore" > "$ROOT/$name/.gitignore" + [ "$git" = "git" ] && (cd "$ROOT/$name" && git init -q) + return 0 +} + +@test "sweep: gitignore-mode project missing all three gets them appended" { + make_project gimode $'# tooling\n.ai/\n' + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + grep -qFx ".claude/" "$ROOT/gimode/.gitignore" + grep -qFx "CLAUDE.md" "$ROOT/gimode/.gitignore" + grep -qFx "AGENTS.md" "$ROOT/gimode/.gitignore" +} + +@test "sweep: is idempotent — a second run adds nothing" { + make_project gimode $'.ai/\n' + bash "$SWEEP" "$ROOT" >/dev/null + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"already complete"* ]] + # No line is duplicated. + [ "$(grep -cFx '.claude/' "$ROOT/gimode/.gitignore")" -eq 1 ] + [ "$(grep -cFx 'CLAUDE.md' "$ROOT/gimode/.gitignore")" -eq 1 ] + [ "$(grep -cFx 'AGENTS.md' "$ROOT/gimode/.gitignore")" -eq 1 ] +} + +@test "sweep: track-mode project (.ai/ not ignored) is skipped untouched" { + make_project trackmode $'# build\nout/\n' + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"track-mode"* ]] + # .gitignore is untouched — none of the tooling lines were added. + ! grep -qFx ".claude/" "$ROOT/trackmode/.gitignore" + ! grep -qFx "CLAUDE.md" "$ROOT/trackmode/.gitignore" +} + +@test "sweep: partial project gets only the missing lines" { + make_project partial $'.ai/\n.claude/\n' + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + [ "$(grep -cFx '.claude/' "$ROOT/partial/.gitignore")" -eq 1 ] + grep -qFx "CLAUDE.md" "$ROOT/partial/.gitignore" + grep -qFx "AGENTS.md" "$ROOT/partial/.gitignore" +} + +@test "sweep: --dry-run reports without writing" { + make_project gimode $'.ai/\n' + + run bash "$SWEEP" --dry-run "$ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"would add"* ]] + [[ "$output" == *"no files written"* ]] + # Nothing was actually written. + ! grep -qFx ".claude/" "$ROOT/gimode/.gitignore" +} + +@test "sweep: warns when a now-ignored path is already tracked" { + make_project tracked $'.ai/\n' + echo "# project rules" > "$ROOT/tracked/CLAUDE.md" + (cd "$ROOT/tracked" && git add CLAUDE.md && git -c user.email=t@t -c user.name=t commit -qm seed) + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"WARN"* ]] + [[ "$output" == *"CLAUDE.md is currently tracked"* ]] +} + +@test "sweep: non-git project with .ai/ marker is skipped" { + make_project nogit $'.ai/\n' "" nogit + + run bash "$SWEEP" "$ROOT" + + [ "$status" -eq 0 ] + [[ "$output" == *"not a git checkout"* ]] +} |
