#!/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, in either the unanchored `.ai/` or anchored `/.ai/` form), ensure # .ai/, .claude/, CLAUDE.md, and AGENTS.md are all ignored. Append only the # missing lines, in whichever style the file already uses, 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. But a track-mode project whose tracked tooling is reachable from a # non-cjennings.net remote gets a loud WARN: per convention the tooling set is # gitignored anywhere the repo can reach a public host, and a server-side # mirror hook can publish even a "private" remote (the 2026-06-30 .emacs.d # exposure rode exactly that). # # 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') # A pattern counts as present in either the unanchored (`.ai/`) or anchored # (`/.ai/`) form — both ignore the root-level path; treating them as different # is what silently skipped anchored-style projects. has_ignore() { local pat="$1" gi="$2" grep -qFx "$pat" "$gi" || grep -qFx "/$pat" "$gi" } 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 (either style). Otherwise # track-mode: leave the .gitignore alone, but warn when tracked tooling can # reach a non-cjennings.net remote — a track-mode repo on a public host (or # behind an invisible server-side mirror) is the exposure the convention # exists to prevent. if [ ! -f "$gi" ] || ! has_ignore '.ai/' "$gi"; then tracked_tooling=() for pat in "${IGNORE_SET[@]}"; do path="${pat%/}" if git -C "$project" ls-files --error-unmatch "$path" >/dev/null 2>&1; then tracked_tooling+=("$path") fi done # Private = the cjennings.net server, whether addressed by FQDN or by the # bare `cjennings` ssh-config alias (git@cjennings:repo.git). public_remote="$(git -C "$project" remote -v 2>/dev/null \ | awk '{print $2}' | grep -vE '(@|://)cjennings(\.net)?[:/]' | sort -u | head -1 || true)" if [ "${#tracked_tooling[@]}" -gt 0 ] && [ -n "$public_remote" ]; then echo "skip $name — track-mode (.ai/ not gitignored)" echo " WARN $name: tracked tooling (${tracked_tooling[*]}) is publicly reachable via $public_remote — gitignore the set and 'git -C $project rm --cached -r ' unless this is a deliberate team-shared config" else echo "skip $name — track-mode (.ai/ not gitignored)" fi skipped=$((skipped + 1)) continue fi # Append in the style the file already uses: anchored if its .ai/ marker # line is the anchored form. prefix="" grep -qFx '/.ai/' "$gi" && prefix="/" needed=() for pat in "${IGNORE_SET[@]}"; do has_ignore "$pat" "$gi" || needed+=("${prefix}${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. Strip the anchored prefix before asking git: the pattern # `/CLAUDE.md` is the repo-relative path `CLAUDE.md`. for pat in "${needed[@]}"; do path="${pat#/}" path="${path%/}" 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