diff options
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"* ]] +} |
