#!/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