1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
|
#!/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 <path>' 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
|