aboutsummaryrefslogtreecommitdiffstats
path: root/bin/managed-files.sh
blob: 47b2e3ad7422957eb1c0c8fa8ec8a695c4596ed2 (plain)
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
#!/usr/bin/env bash
# managed-files.sh -- audit how every file under the cwd is managed.
# Run by hand from the claude-config folder (~/.config/claude) to check that the
# .gitignore and .stignore splits agree and nothing falls through. Classifies
# each file under the current directory as:
#   - synced  := NOT ignored by .stignore (its #include is resolved to real patterns).
#   - tracked := listed by `git ls-files`.
#   - local   := neither (ignored by .stignore AND untracked).
#   - both    := tracked AND synced (double-managed; should be empty).
# Prints the four counts then the full lists. .git is pruned and shown as a single
# synthetic entry; the local/both lists are built with comm(1).
# Exits non-zero if .stignore is missing or an #include resolves to no patterns.

set -euo pipefail

ROOT=$(pwd)
TMP=$(mktemp --directory)
trap 'rm -rf "$TMP"' EXIT

die() {
	printf 'error: %s\n' "$1" >&2
	exit 1
}

# - Translate one .stignore glob to a fully-anchored ERE.
# - Handles the !/(?i)/(?d) prefixes, leading-/ anchoring, *, **, ?, and the
#   trailing-/ directory form; a match also covers everything under a directory.
# - Prints one line: "<kind>\t<ere>" where kind is "ignore" or "include".
glob_to_ere() {
	local p=$1 kind=ignore anchored=0 out="" i=0 ch
	[[ $p == '!'* ]] && {
		kind=include
		p=${p#!}
	}
	while :; do
		case $p in
		'(?i)'*) p=${p#'(?i)'} ;; # case-insensitive prefix: ignored (patterns here are exact-case)
		'(?d)'*) p=${p#'(?d)'} ;; # delete-on-conflict: irrelevant to matching
		*) break ;;
		esac
	done
	[[ $p == /* ]] && {
		anchored=1
		p=${p#/}
	}
	p=${p%/}
	while ((i < ${#p})); do
		ch=${p:i:1}
		case $ch in
		'*')
			if [[ ${p:i+1:1} == '*' ]]; then
				out+='.*'
				((i += 2))
			else
				out+='[^/]*'
				((i++))
			fi
			;;
		'?')
			out+='[^/]'
			((i++))
			;;
		'[')
			local j=$((i + 1)) cls
			while ((j < ${#p})) && [[ ${p:j:1} != ']' ]]; do ((j++)); done
			cls=${p:i+1:j-i-1}
			[[ $cls == '!'* ]] && cls="^${cls#!}"
			out+="[$cls]"
			i=$((j + 1))
			;;
		'.' | '^' | '$' | '+' | '|' | '(' | ')' | '\')
			out+="\\$ch"
			((i++))
			;;
		*)
			out+=$ch
			((i++))
			;;
		esac
	done
	local ere
	if ((anchored)); then ere="^${out}(/.*)?\$"; else ere="^(.*/)?${out}(/.*)?\$"; fi
	printf '%s\t%s\n' "$kind" "$ere"
}

# - Load an ignore file, resolving #include relative to the folder ROOT.
# - Appends each pattern's ERE to $TMP/ere.txt (ignore) or $TMP/inc.txt (include).
# - Returns the number of patterns it contributed; dies on missing file.
load_patterns() {
	local file=$1 added=0 line s target inc ere kind
	[[ -f $file ]] || die "ignore file missing: $file"
	while IFS= read -r line || [[ -n $line ]]; do
		s=${line#"${line%%[![:space:]]*}"} # ltrim
		s=${s%"${s##*[![:space:]]}"}       # rtrim
		[[ -z $s || $s == //* ]] && continue
		if [[ $s == '#include'* ]]; then
			target=${s#'#include'}
			target=${target#"${target%%[![:space:]]*}"}
			inc=$(realpath --canonicalize-missing "$ROOT/$target")
			local sub
			sub=$(load_patterns "$inc")
			((sub == 0)) && die "#include '$target' ($inc) resolved to no patterns"
			((added += sub))
			continue
		fi
		IFS=$'\t' read -r kind ere < <(glob_to_ere "$s")
		if [[ $kind == include ]]; then printf '%s\n' "$ere" >>"$TMP/inc.txt"; else printf '%s\n' "$ere" >>"$TMP/ere.txt"; fi
		((added++)) || true
	done <"$file"
	printf '%s\n' "$added"
}

[[ -f $ROOT/.stignore ]] || die ".stignore is missing"
: >"$TMP/ere.txt"
: >"$TMP/inc.txt"
total=$(load_patterns "$ROOT/.stignore")
((total == 0)) && die ".stignore resolved to no patterns"

# all files + symlinks, pruning .git into a single synthetic entry
find . -path ./.git -prune -o \( -type f -o -type l \) -print |
	sed 's,^\./,,' >"$TMP/all.tmp"
printf '.git\n' >>"$TMP/all.tmp"
sort -u "$TMP/all.tmp" >"$TMP/all.txt"

git ls-files | sort -u >"$TMP/tracked.txt"

# ignored := matches an ignore ERE but not a re-include ERE; synced := the rest
grep -E -f "$TMP/ere.txt" "$TMP/all.txt" | sort -u >"$TMP/ign_raw.txt" || true
if [[ -s $TMP/inc.txt ]]; then
	grep -E -f "$TMP/inc.txt" "$TMP/all.txt" | sort -u >"$TMP/inc_match.txt" || true
else : >"$TMP/inc_match.txt"; fi
comm -23 "$TMP/ign_raw.txt" "$TMP/inc_match.txt" >"$TMP/ignored.txt"
comm -23 "$TMP/all.txt" "$TMP/ignored.txt" >"$TMP/synced.txt"

sort -u "$TMP/tracked.txt" "$TMP/synced.txt" >"$TMP/union.txt"
comm -23 "$TMP/all.txt" "$TMP/union.txt" >"$TMP/local.txt"     # in all, not tracked nor synced
comm -12 "$TMP/tracked.txt" "$TMP/synced.txt" >"$TMP/both.txt" # tracked AND synced

printf 'root:    %s\n' "$ROOT"
printf 'tracked: %s\n' "$(wc -l <"$TMP/tracked.txt")"
printf 'synced:  %s\n' "$(wc -l <"$TMP/synced.txt")"
printf 'local:   %s  (ignored by .stignore AND untracked)\n' "$(wc -l <"$TMP/local.txt")"
printf 'both:    %s  (tracked AND synced -- should be 0)\n' "$(wc -l <"$TMP/both.txt")"

for name in tracked synced local both; do
	printf '\n===== %s (%s) =====\n' "${name^^}" "$(wc -l <"$TMP/$name.txt")"
	cat "$TMP/$name.txt"
done