From 355688c1340783d53c458e41fb854ef05d3a2ab8 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 14:54:20 -0400 Subject: [PATCH] feat: add gitignore-scrub to catch tracked files newly matched by .gitignore Standalone function, not gi-private: default mode prompts once for all tracked-but-ignored files and offers git rm --cached, remembering a decline per-path in the repo's local git config (gitignore-scrub.skip) so the same file isn't re-asked. -w/--warn is read-only (prints Warning lines, no prompt, no mutation) for non-interactive callers like a git hook. Skips silently above $GITIGNORE_SCRUB_LIMIT tracked files (default 5000) to avoid latency on huge repos. gi now calls gitignore-scrub at the end of any run that touched .gitignore. --- functions/gi.fish | 5 +- functions/gitignore-scrub.fish | 107 +++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 functions/gitignore-scrub.fish diff --git a/functions/gi.fish b/functions/gi.fish index 559b28c..3c5a663 100644 --- a/functions/gi.fish +++ b/functions/gi.fish @@ -5,7 +5,7 @@ # 04-git-and-version-control # # DEPENDENCIES -# curl, md5sum, md5 +# curl, md5sum, md5, gitignore-scrub # # CLASSIFICATION # self-limiting(grep,cat), network, blocking-prompt @@ -167,6 +167,7 @@ function gi --description 'Generate .gitignore files using the gitignore.io API' else echo (set_color brblack)"No patterns selected. Skipping API fetch."(set_color normal) end + test $needs_git -eq 1; and gitignore-scrub return 0 end @@ -196,6 +197,8 @@ function gi --description 'Generate .gitignore files using the gitignore.io API' end end end + + test $needs_git -eq 1; and gitignore-scrub end # SYNOPSIS diff --git a/functions/gitignore-scrub.fish b/functions/gitignore-scrub.fish new file mode 100644 index 0000000..2dbe06b --- /dev/null +++ b/functions/gitignore-scrub.fish @@ -0,0 +1,107 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# CATEGORY +# 04-git-and-version-control +# +# DEPENDENCIES +# git +# +# CLASSIFICATION +# blocking-prompt +# +# SYNOPSIS +# gitignore-scrub [-h] [-w] +# +# DESCRIPTION +# Finds files that are tracked by git but now match a .gitignore pattern +# (git ls-files -ci --exclude-standard) and offers to untrack them. In the +# default interactive mode, prompts once for all matches and runs +# git rm --cached on confirmation; a decline is remembered per-path in the +# repo's local git config (gitignore-scrub.skip) so the same file is not +# asked about again. With -w/--warn, only prints a Warning line per match +# and makes no changes — meant for non-interactive callers such as a git +# hook. Both modes honor the skip list. Silently does nothing on a repo +# with more tracked files than $GITIGNORE_SCRUB_LIMIT (default 5000), to +# avoid adding latency to huge repos. +# +# ARGUMENTS +# -h, --help Show help message +# -w, --warn Read-only: print warnings instead of prompting, make no changes +# +# EXIT STATUS +# 0 Clean, or (interactive) prompt handled, or not a git repository is +# never reached without erroring first +# 1 Not a git repository, or (-w only) unconfirmed matches were found +# +# EXAMPLE +# gitignore-scrub +# gitignore-scrub --warn +function gitignore-scrub --description 'Find and optionally untrack files newly matched by .gitignore' + argparse h/help w/warn -- $argv + or return 1 + + if set -q _flag_help + __fish_palette + echo "$c_head""Usage:$c_reset $c_cmd""gitignore-scrub$c_reset $c_arg""[FLAGS]$c_reset" + echo "" + echo "$c_head""Flags:$c_reset" + echo " $c_flag-h, --help$c_reset Show this help message" + echo " $c_flag-w, --warn$c_reset Read-only: print warnings instead of prompting" + echo "" + echo "$c_head""Examples:$c_reset" + echo " $c_cmd""gitignore-scrub$c_reset $c_dim""# Interactive: prompt to untrack matches$c_reset" + echo " $c_cmd""gitignore-scrub --warn$c_reset $c_dim""# Read-only: for use in a git hook$c_reset" + return 0 + end + + if not git rev-parse --is-inside-work-tree >/dev/null 2>&1 + set_color red --bold + echo "Error:" (set_color normal)"Not a git repository (or any parent directories)" >&2 + return 1 + end + + set -l limit 5000 + set -q GITIGNORE_SCRUB_LIMIT; and set limit $GITIGNORE_SCRUB_LIMIT + + set -l tracked_count (git ls-files | count) + if test $tracked_count -gt $limit + return 0 + end + + set -l offenders (git ls-files -ci --exclude-standard) + set -q offenders[1]; or return 0 + + set -l skip_list (git config --local --get-all gitignore-scrub.skip 2>/dev/null) + set -l pending + for f in $offenders + contains -- "$f" $skip_list; or set -a pending $f + end + set -q pending[1]; or return 0 + + if set -q _flag_warn + for f in $pending + set_color yellow --bold + echo -n "Warning: " + set_color normal + echo "$f is tracked but ignored" + end + return 1 + end + + set_color yellow + echo (count $pending)" tracked file(s) now match .gitignore:"(set_color normal) + for f in $pending + echo " $f" + end + read -P "Remove from git tracking? [y/N] " confirm + if string match -qir '^y' -- "$confirm" + git rm --cached -- $pending >/dev/null + echo (set_color green)"✔"(set_color normal)" Untracked "(count $pending)" file(s)." + else + for f in $pending + git config --local --add gitignore-scrub.skip "$f" + end + echo (set_color brblack)"Remembered — won't ask again for these files."(set_color normal) + end +end