#!/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: "\t" 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