Split the two shell scripts and the gate's own config

aur-publish/publish.sh   439 -> 148 + lib/aur_registration 107
                                      + lib/aur_verify 122 + lib/aur_publish_one 107
  guard-lib/lib/file_guard.sh  350 -> 140 + file_guard_unlock 214
  file_length/config.py        252 -> 45 + _tables 202 + exemptions 77

The libs are sourced, never executed: they read the readonly config the entry
script declares, and a `readonly` re-run inside a twice-sourced lib aborts
under `set -e`. Verified by function parity rather than by eye — publish.sh
resolves the same 17 functions as before the split, guardctl the same 15 —
plus `shellcheck -x` clean on every file and a real `--help` / bad-option run.

file_length is the gate's own code, so a broken import there turns the gate
into a no-op. Split three ways to avoid an import cycle without a `# noqa`:
_tables holds the data, exemptions the rules that read it, config re-exports
both so `from file_length.config import is_generated` still resolves. The
first attempt was wrong — EXCLUDED_DIRS was left behind and every invocation
raised — caught because the gate was re-run in both modes rather than
assumed. Now verified: exit 0 on a clean file, exit 1 on an over-cap one,
--explain intact, and the same 61 exemption decisions as HEAD.

Also deletes baseline-2026-08-14.json and SURVEY-2026-08-14.txt: the task
forbids a baseline file, and nothing in the tree read either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
Krzysztof Rudnicki 2026-08-22 14:59:27 +02:00
parent 200432a2b4
commit 085d08b5c9
12 changed files with 879 additions and 4549 deletions

View File

@ -0,0 +1,107 @@
#!/bin/bash
# ============================================================================
# Building, verifying and pushing one package to the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
publish_one() {
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
local dir="$AUR_ROOT/$pkg"
local version
log "$pkg"
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
version="$(newest_tag "$repo")"
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
info "newest release tag: v$version"
cd "$dir"
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
# computed after the tag exists — never committed ahead of time.
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
info "building"
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
warn "$pkg: makepkg failed"
return 1
fi
local built
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
# for /opt-installed apps, so only genuinely new errors are worth failing on.
local findings
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
if [[ -n "$findings" ]]; then
warn "$pkg: namcap errors:"
printf ' %s\n' "$findings"
return 1
fi
info "installing and launching"
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|| { warn "$pkg: pacman -U failed"; return 1; }
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
# Generated before the rule check, which verifies it matches the PKGBUILD.
makepkg --printsrcinfo > .SRCINFO
check_aur_rules "$pkg" || return 1
# The AUR repo is a separate remote living alongside the build dir; adding
# it here (rather than cloning into a fresh path) keeps the verified
# PKGBUILD and the thing being pushed byte-identical.
if [[ ! -d .git ]]; then
git init -q -b master .
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
fi
git remote get-url origin >/dev/null 2>&1 \
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
# Fetch first: an existing AUR package must be built on, not overwritten.
if git fetch -q origin master 2>/dev/null; then
git reset -q --soft FETCH_HEAD 2>/dev/null || true
fi
cat > .gitignore <<'EOF'
# Build products; the AUR tracks only the recipe.
*.pkg.tar.zst
*.tar.gz
src/
pkg/
EOF
git add PKGBUILD .SRCINFO .gitignore
if git diff --cached --quiet; then
info "already up to date on the AUR; nothing to push"
PUBLISHED+=("$pkg v$version (unchanged)")
return 0
fi
git -c user.name='Krzysztof Rudnicki' \
-c user.email='krzysztofrudnicki0@gmail.com' \
commit -q -m "$pkg $version-1"
if ((DRY_RUN)); then
info "DRY RUN — not pushing"
PUBLISHED+=("$pkg v$version (dry run)")
return 0
fi
info "pushing to the AUR"
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
PUBLISHED+=("$pkg v$version")
return 0
}

View File

@ -0,0 +1,107 @@
#!/bin/bash
# ============================================================================
# Waiting for AUR registration and SSH access to come up.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
register_status() {
python3 "$GATE" /register 2>/dev/null | head -1
}
open_browser() {
local url="$1"
local browser
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
if command -v "$browser" >/dev/null 2>&1; then
"$browser" "$url" >/dev/null 2>&1 &
info "Opened $url in $browser"
return 0
fi
done
warn "No browser found; open this yourself: $url"
}
ensure_key() {
if [[ ! -f "$SSH_KEY" ]]; then
log "Generating an AUR SSH key (none at $SSH_KEY)"
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
fi
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
log "Adding $AUR_HOST to ~/.ssh/config"
mkdir -p "$HOME/.ssh"
cat >> "$HOME/.ssh/config" <<EOF
# AUR uses a dedicated key so it stays separate from the GitHub identity.
Host $AUR_HOST
User aur
IdentityFile $SSH_KEY
IdentitiesOnly yes
EOF
chmod 600 "$HOME/.ssh/config"
fi
}
ssh_works() {
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
# successful key auth is the success signal, not an error.
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
grep -qiE 'interactive shell is disabled|Welcome to AUR'
}
wait_for_registration() {
local status
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "AUR registration is OPEN"
else
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
info "Ctrl-C to stop; re-run any time, nothing is lost."
while :; do
sleep "$POLL_SECONDS"
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "Registration is BACK (HTTP 200)"
break
fi
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
done
fi
open_browser "https://$AUR_HOST/register"
cat <<EOF
------------------------------------------------------------------
Sign up, and paste this into the "SSH Public Key" field:
$(sed 's/^/ /' "$SSH_KEY.pub")
You will need to confirm the address from your email before the
account works. This script waits for that automatically.
------------------------------------------------------------------
EOF
}
wait_for_ssh() {
log "Waiting for the SSH key to authenticate against the AUR"
local waited=0
until ssh_works; do
sleep 30
waited=$((waited + 30))
if ((waited % 300 == 0)); then
info "still waiting ($((waited / 60)) min) — key not accepted yet"
fi
done
log "SSH authentication OK"
}
# ---------------------------------------------------------------------------
# Phase 2: build, verify and publish one package
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,122 @@
#!/bin/bash
# ============================================================================
# Gates a package must pass before it is allowed near the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_ROOT, DRY_RUN) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
newest_tag() {
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
git -C "$1" ls-remote --tags origin 2>/dev/null |
awk '{print $2}' |
sed 's|refs/tags/||' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
sed 's/^v//' |
sort -V |
tail -1
}
verify_installed() {
local binary="$1" expected="$2" wrapper="$3" output
# An instance left over from a previous run would make the wrapper print
# "already running" and hand off, which passes trivially and turns the
# strongest gate in this script into the weakest. Clear it first so the
# check always exercises a real cold start.
#
# Resolved through /proc/<pid>/exe rather than any form of name or
# command-line matching. Every argv-based approach selects the wrong
# processes here:
# * `pkill -x` compares against the kernel's 15-character comm field, and
# two of these wrappers are longer (habit_stack_desktop is 19), so a
# name match would silently never fire.
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
# any process whose command line merely *mentions* the path — this
# script, the shell running it, and the matching tool itself all
# qualify, so the run kills its own shell instead of the app. A
# $$/$PPID guard does not cover it, because the offending shell is
# often a more distant ancestor.
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
# being executed, so it cannot collide with an incidental mention.
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
# a glob instead of threading the package name through another parameter.
#
# The link is read without `-f`: it is already fully resolved, and by the
# time this runs `pacman -U` has replaced the binary, so a leftover process
# points at an unlinked inode that the kernel reports with a " (deleted)"
# suffix. That suffix has to be stripped or the match silently misses the
# one process this exists to kill.
local pid exe killed=()
for pid in /proc/[0-9]*; do
pid="${pid#/proc/}"
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
exe="${exe% (deleted)}"
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
done
if ((${#killed[@]})); then
info "stopped a running instance so the launch check is meaningful"
# `kill` only reports that the signal was sent. Waiting for the process
# to actually go keeps a slow exit from failing the cold start below on
# a port that is still bound.
local waited=0
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
sleep 0.1
((waited++))
done
fi
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
if grep -qF "$expected" <<<"$output"; then
return 0
fi
warn "unexpected startup output: $output"
return 1
}
# The AUR does not review uploads, but "packages that violate the rules may be
# deleted without warning" (AUR submission guidelines). These are the rules that
# are mechanically checkable, enforced here so a violation blocks the push
# rather than surfacing later as a deletion request.
check_aur_rules() {
local pkg="$1" problems=()
grep -q '^# Maintainer:' PKGBUILD \
|| problems+=("missing '# Maintainer:' comment on line 1")
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
# `replaces` makes pacman rip out the named package on the next -Sy, which
# is only correct for a genuine rename. `conflicts` is the right tool.
grep -q '^replaces=' PKGBUILD \
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
# A package already in an official repo must never be duplicated.
if pacman -Si "$pkg" >/dev/null 2>&1; then
problems+=("$pkg exists in an official repo — must not be submitted")
fi
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|| problems+=("missing pkgdesc/url/license")
# A stale .SRCINFO is the single most common upload rejection.
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
problems+=(".SRCINFO does not match PKGBUILD")
fi
if ((${#problems[@]})); then
warn "$pkg: AUR rule violations:"
printf ' - %s\n' "${problems[@]}"
return 1
fi
info "AUR rule checks passed"
return 0
}

View File

@ -86,305 +86,14 @@ require_tools() {
# Phase 1: wait for registration, then for the key to work # Phase 1: wait for registration, then for the key to work
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
register_status() { # The rest lives in lib/, sourced (not executed) so every function sees the
python3 "$GATE" /register 2>/dev/null | head -1 # readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
} # shellcheck source=lib/aur_registration.sh
source "$SCRIPT_DIR/lib/aur_registration.sh"
open_browser() { # shellcheck source=lib/aur_verify.sh
local url="$1" source "$SCRIPT_DIR/lib/aur_verify.sh"
local browser # shellcheck source=lib/aur_publish_one.sh
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do source "$SCRIPT_DIR/lib/aur_publish_one.sh"
if command -v "$browser" >/dev/null 2>&1; then
"$browser" "$url" >/dev/null 2>&1 &
info "Opened $url in $browser"
return 0
fi
done
warn "No browser found; open this yourself: $url"
}
ensure_key() {
if [[ ! -f "$SSH_KEY" ]]; then
log "Generating an AUR SSH key (none at $SSH_KEY)"
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
fi
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
log "Adding $AUR_HOST to ~/.ssh/config"
mkdir -p "$HOME/.ssh"
cat >> "$HOME/.ssh/config" <<EOF
# AUR uses a dedicated key so it stays separate from the GitHub identity.
Host $AUR_HOST
User aur
IdentityFile $SSH_KEY
IdentitiesOnly yes
EOF
chmod 600 "$HOME/.ssh/config"
fi
}
ssh_works() {
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
# successful key auth is the success signal, not an error.
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
grep -qiE 'interactive shell is disabled|Welcome to AUR'
}
wait_for_registration() {
local status
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "AUR registration is OPEN"
else
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
info "Ctrl-C to stop; re-run any time, nothing is lost."
while :; do
sleep "$POLL_SECONDS"
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "Registration is BACK (HTTP 200)"
break
fi
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
done
fi
open_browser "https://$AUR_HOST/register"
cat <<EOF
------------------------------------------------------------------
Sign up, and paste this into the "SSH Public Key" field:
$(sed 's/^/ /' "$SSH_KEY.pub")
You will need to confirm the address from your email before the
account works. This script waits for that automatically.
------------------------------------------------------------------
EOF
}
wait_for_ssh() {
log "Waiting for the SSH key to authenticate against the AUR"
local waited=0
until ssh_works; do
sleep 30
waited=$((waited + 30))
if ((waited % 300 == 0)); then
info "still waiting ($((waited / 60)) min) — key not accepted yet"
fi
done
log "SSH authentication OK"
}
# ---------------------------------------------------------------------------
# Phase 2: build, verify and publish one package
# ---------------------------------------------------------------------------
newest_tag() {
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
git -C "$1" ls-remote --tags origin 2>/dev/null |
awk '{print $2}' |
sed 's|refs/tags/||' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
sed 's/^v//' |
sort -V |
tail -1
}
verify_installed() {
local binary="$1" expected="$2" wrapper="$3" output
# An instance left over from a previous run would make the wrapper print
# "already running" and hand off, which passes trivially and turns the
# strongest gate in this script into the weakest. Clear it first so the
# check always exercises a real cold start.
#
# Resolved through /proc/<pid>/exe rather than any form of name or
# command-line matching. Every argv-based approach selects the wrong
# processes here:
# * `pkill -x` compares against the kernel's 15-character comm field, and
# two of these wrappers are longer (habit_stack_desktop is 19), so a
# name match would silently never fire.
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
# any process whose command line merely *mentions* the path — this
# script, the shell running it, and the matching tool itself all
# qualify, so the run kills its own shell instead of the app. A
# $$/$PPID guard does not cover it, because the offending shell is
# often a more distant ancestor.
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
# being executed, so it cannot collide with an incidental mention.
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
# a glob instead of threading the package name through another parameter.
#
# The link is read without `-f`: it is already fully resolved, and by the
# time this runs `pacman -U` has replaced the binary, so a leftover process
# points at an unlinked inode that the kernel reports with a " (deleted)"
# suffix. That suffix has to be stripped or the match silently misses the
# one process this exists to kill.
local pid exe killed=()
for pid in /proc/[0-9]*; do
pid="${pid#/proc/}"
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
exe="${exe% (deleted)}"
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
done
if ((${#killed[@]})); then
info "stopped a running instance so the launch check is meaningful"
# `kill` only reports that the signal was sent. Waiting for the process
# to actually go keeps a slow exit from failing the cold start below on
# a port that is still bound.
local waited=0
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
sleep 0.1
((waited++))
done
fi
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
if grep -qF "$expected" <<<"$output"; then
return 0
fi
warn "unexpected startup output: $output"
return 1
}
# The AUR does not review uploads, but "packages that violate the rules may be
# deleted without warning" (AUR submission guidelines). These are the rules that
# are mechanically checkable, enforced here so a violation blocks the push
# rather than surfacing later as a deletion request.
check_aur_rules() {
local pkg="$1" problems=()
grep -q '^# Maintainer:' PKGBUILD \
|| problems+=("missing '# Maintainer:' comment on line 1")
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
# `replaces` makes pacman rip out the named package on the next -Sy, which
# is only correct for a genuine rename. `conflicts` is the right tool.
grep -q '^replaces=' PKGBUILD \
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
# A package already in an official repo must never be duplicated.
if pacman -Si "$pkg" >/dev/null 2>&1; then
problems+=("$pkg exists in an official repo — must not be submitted")
fi
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|| problems+=("missing pkgdesc/url/license")
# A stale .SRCINFO is the single most common upload rejection.
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
problems+=(".SRCINFO does not match PKGBUILD")
fi
if ((${#problems[@]})); then
warn "$pkg: AUR rule violations:"
printf ' - %s\n' "${problems[@]}"
return 1
fi
info "AUR rule checks passed"
return 0
}
publish_one() {
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
local dir="$AUR_ROOT/$pkg"
local version
log "$pkg"
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
version="$(newest_tag "$repo")"
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
info "newest release tag: v$version"
cd "$dir"
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
# computed after the tag exists — never committed ahead of time.
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
info "building"
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
warn "$pkg: makepkg failed"
return 1
fi
local built
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
# for /opt-installed apps, so only genuinely new errors are worth failing on.
local findings
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
if [[ -n "$findings" ]]; then
warn "$pkg: namcap errors:"
printf ' %s\n' "$findings"
return 1
fi
info "installing and launching"
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|| { warn "$pkg: pacman -U failed"; return 1; }
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
# Generated before the rule check, which verifies it matches the PKGBUILD.
makepkg --printsrcinfo > .SRCINFO
check_aur_rules "$pkg" || return 1
# The AUR repo is a separate remote living alongside the build dir; adding
# it here (rather than cloning into a fresh path) keeps the verified
# PKGBUILD and the thing being pushed byte-identical.
if [[ ! -d .git ]]; then
git init -q -b master .
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
fi
git remote get-url origin >/dev/null 2>&1 \
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
# Fetch first: an existing AUR package must be built on, not overwritten.
if git fetch -q origin master 2>/dev/null; then
git reset -q --soft FETCH_HEAD 2>/dev/null || true
fi
cat > .gitignore <<'EOF'
# Build products; the AUR tracks only the recipe.
*.pkg.tar.zst
*.tar.gz
src/
pkg/
EOF
git add PKGBUILD .SRCINFO .gitignore
if git diff --cached --quiet; then
info "already up to date on the AUR; nothing to push"
PUBLISHED+=("$pkg v$version (unchanged)")
return 0
fi
git -c user.name='Krzysztof Rudnicki' \
-c user.email='krzysztofrudnicki0@gmail.com' \
commit -q -m "$pkg $version-1"
if ((DRY_RUN)); then
info "DRY RUN — not pushing"
PUBLISHED+=("$pkg v$version (dry run)")
return 0
fi
info "pushing to the AUR"
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
PUBLISHED+=("$pkg v$version")
return 0
}
main() { main() {
require_tools require_tools

View File

@ -1,208 +0,0 @@
==============================================================================
TOTALS: 4045 readable files (code 2700, prose 580, data 765)
code lines total: 471,749
==============================================================================
### LONGEST FILES OVERALL (any readable kind), top 30
lines kind lang path
------ ----- ---------- ---------------------------------------------------------------------------------------------------------
174800 prose Text testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/words.txt
2053 prose Markdown testsAndMisc/docs/superpowers/plans/2026-05-01-phone-focus-recovery.md
1838 prose Markdown todo/BACKLOG.md
1734 code Shell testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh
1570 data JSON testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/leechblock_defaults.json
1475 data JSON macro-cam/assets/testdata/meals.json
1437 code TypeScript konbini-67/src/core/shift.test.ts
1417 code Dart epopeja_karta/lib/l10n/generated/app_localizations.dart
1383 code Shell testsAndMisc/linux_configuration/scripts/single_use/features/setup_nextcloud_raspberry.sh
1337 code Shell testsAndMisc/linux_configuration/scripts/periodic_background/check_and_enable_services.sh
1325 code Dart home_inventory/test/data/item_repository_test.dart
1276 code Shell testsAndMisc/linux_configuration/scripts/single_use/features/raspberry_pi_nextcloud.sh
1274 data JSON macro-cam/assets/testdata/model_areas.json
1120 code Dart home_inventory/lib/data/item_repository.dart
1116 code Shell testsAndMisc/linux_configuration/scripts/single_use/utils/root_bl9000.sh
1101 prose Markdown diet-guard/CLAUDE.md
1097 code TypeScript konbini-67/src/core/shift.ts
1091 data JSON macro-cam/assets/testdata/claude_meal_estimates.json
1091 code Shell testsAndMisc/phone_focus_mode/focus_ctl.sh
1089 code Dart dufs-cloud/app/test/browser_screen_test.dart
1076 code Python steam-backlog-enforcer/steam_backlog_enforcer/main.py
1072 code Python steam-backlog-enforcer/steam_backlog_enforcer/tests/test_stats.py
1052 code Dart diet-guard/app/lib/screens/history_screen.dart
1047 code TypeScript dufs-cloud/web/src/components/gallery.test.tsx
1042 code Python steam-backlog-enforcer/steam_backlog_enforcer/tests/test_total_block.py
1017 code Dart diet-guard/app/test/screens/history_screen_test.dart
1017 code Shell testsAndMisc/linux_configuration/scripts/single_use/utils/generate_study_materials.sh
942 code Shell testsAndMisc/linux_configuration/scripts/single_use/utils/lookup_docs.sh
929 code Shell testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh
925 prose Markdown todo/CLAUDE.md
### LONGEST CODE FILES, top 30
lines lang commits/yr path
----- ---------- ---------- ---------------------------------------------------------------------------------------------------------
1734 Shell 9 testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh
1437 TypeScript 12 konbini-67/src/core/shift.test.ts
1417 Dart 2 epopeja_karta/lib/l10n/generated/app_localizations.dart
1383 Shell 1 testsAndMisc/linux_configuration/scripts/single_use/features/setup_nextcloud_raspberry.sh
1337 Shell 8 testsAndMisc/linux_configuration/scripts/periodic_background/check_and_enable_services.sh
1325 Dart 6 home_inventory/test/data/item_repository_test.dart
1276 Shell 1 testsAndMisc/linux_configuration/scripts/single_use/features/raspberry_pi_nextcloud.sh
1120 Dart 7 home_inventory/lib/data/item_repository.dart
1116 Shell 1 testsAndMisc/linux_configuration/scripts/single_use/utils/root_bl9000.sh
1097 TypeScript 13 konbini-67/src/core/shift.ts
1091 Shell 9 testsAndMisc/phone_focus_mode/focus_ctl.sh
1089 Dart 12 dufs-cloud/app/test/browser_screen_test.dart
1076 Python 26 steam-backlog-enforcer/steam_backlog_enforcer/main.py
1072 Python 4 steam-backlog-enforcer/steam_backlog_enforcer/tests/test_stats.py
1052 Dart 6 diet-guard/app/lib/screens/history_screen.dart
1047 TypeScript 9 dufs-cloud/web/src/components/gallery.test.tsx
1042 Python 2 steam-backlog-enforcer/steam_backlog_enforcer/tests/test_total_block.py
1017 Dart 6 diet-guard/app/test/screens/history_screen_test.dart
1017 Shell 2 testsAndMisc/linux_configuration/scripts/single_use/utils/generate_study_materials.sh
942 Shell 2 testsAndMisc/linux_configuration/scripts/single_use/utils/lookup_docs.sh
929 Shell 6 testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh
918 Shell 5 testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_night_lockdown.sh
912 Shell 5 testsAndMisc/linux_configuration/scripts/periodic_background/hosts/install.sh
912 Shell 3 testsAndMisc/linux_configuration/scripts/single_use/features/setup_searxng.sh
909 Dart 15 dufs-cloud/app/lib/screens/browser_screen.dart
897 Dart 8 screen-locker/stronglift_replacement/workout_app/lib/screens/history_screen.dart
896 Dart 2 epopeja_karta/test/widget_test.dart
870 Shell 2 testsAndMisc/linux_configuration/scripts/single_use/utils/update_android_hosts.sh
869 Shell 3 testsAndMisc/linux_configuration/scripts/single_use/utils/analyze_repo.sh
850 Python 3 europe-county-map/scripts/extract_ck3_counties.py
### LONGEST PROSE (md/txt/rst/tex), top 30
lines lang path
------ -------- -----------------------------------------------------------------------------------------------
174800 Text testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/pacman/words.txt
2053 Markdown testsAndMisc/docs/superpowers/plans/2026-05-01-phone-focus-recovery.md
1838 Markdown todo/BACKLOG.md
1101 Markdown diet-guard/CLAUDE.md
925 Markdown todo/CLAUDE.md
761 Markdown testsAndMisc/linux_configuration/docs/SECURITY_HARDENING_ANALYSIS.md
709 Markdown testsAndMisc/docs/superpowers/specs/2026-05-01-phone-focus-recovery-design.md
648 Markdown .claude/plans/i-dont-want-to-resilient-teapot.md
621 Markdown konbini-67/SPEC-physicality.md
592 Markdown .claude/plans/i-added-google-anonymous-elegant-stallman.md
578 Markdown .claude/plans/i-am-trying-to-starry-emerson.md
564 Markdown .claude/plans/inventory-app-improvements-1-cheeky-mountain.md
548 LaTeX CV/magisterskie/cv.tex
528 Markdown diet-guard/app/.agents/skills/dart-migrate-to-checks-package/SKILL.md
528 Markdown todo/.agents/skills/dart-migrate-to-checks-package/SKILL.md
521 Markdown .claude/plans/build-prompt-melodic-mango.md
478 Markdown .claude/plans/abundant-cooking-hippo.md
461 Markdown .claude/plans/where-the-guard-lives-fluffy-shell.md
459 Markdown .claude/plans/changes-to-diet-guard-splendid-spindle.md
440 Markdown .claude/plans/japanese-convenience-store-clerk-witty-cocoa.md
421 Markdown .claude/plans/all-repos-that-build-cozy-pike.md
419 Markdown diet-guard/app/.agents/skills/dart-setup-ffi-assets/SKILL.md
419 Markdown todo/.agents/skills/dart-setup-ffi-assets/SKILL.md
406 Markdown .claude/plans/grind-leetcode-guard-velvet-engelbart.md
402 Markdown .claude/skills/wiki-to-mcp-kb/references/pitfalls.md
402 Markdown .claude/plans/inventory-management-for-spicy-cray.md
390 Markdown .claude/plans/read-and-fix-incident-2026-08-05-no-way-wiggly-karp.md
386 Markdown .claude/plans/take-https-github-com-punkpeye-awesome-m-gentle-swing.md
384 Markdown testsAndMisc/.claude/skills/test-driven-development/SKILL.md
384 Markdown testsAndMisc/.github/skills/test-driven-development/SKILL.md
### TOP REFACTOR ROI (lines x commits-last-year), top 30
roi lines commits/yr lang path
----- ----- ---------- ---------- ---------------------------------------------------------------------------------------------------------
27976 1076 26 Python steam-backlog-enforcer/steam_backlog_enforcer/main.py
22796 556 41 Shell testsAndMisc/phone_focus_mode/config.sh
18720 390 48 Python screen-locker/screen_locker/screen_lock.py
17244 1437 12 TypeScript konbini-67/src/core/shift.test.ts
15606 1734 9 Shell testsAndMisc/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh
14261 1097 13 TypeScript konbini-67/src/core/shift.ts
14041 739 19 Dart todo/lib/ui/capture_screen.dart
13635 909 15 Dart dufs-cloud/app/lib/screens/browser_screen.dart
13068 1089 12 Dart dufs-cloud/app/test/browser_screen_test.dart
12495 833 15 Shell testsAndMisc/phone_focus_mode/deploy.sh
11220 660 17 Dart todo/test/capture_screen_test.dart
10696 1337 8 Shell testsAndMisc/linux_configuration/scripts/periodic_background/check_and_enable_services.sh
9954 553 18 Python steam-backlog-enforcer/steam_backlog_enforcer/game_install.py
9819 1091 9 Shell testsAndMisc/phone_focus_mode/focus_ctl.sh
9423 1047 9 TypeScript dufs-cloud/web/src/components/gallery.test.tsx
9252 771 12 Dart screen-locker/stronglift_replacement/workout_app/lib/screens/settings_screen.dart
7950 1325 6 Dart home_inventory/test/data/item_repository_test.dart
7840 1120 7 Dart home_inventory/lib/data/item_repository.dart
7215 555 13 Python steam-backlog-enforcer/steam_backlog_enforcer/library_hider.py
7176 897 8 Dart screen-locker/stronglift_replacement/workout_app/lib/screens/history_screen.dart
7170 478 15 Python wake-alarm/wake_alarm/_alarm.py
7020 702 10 Dart todo/lib/ui/note_editor.dart
6994 538 13 TypeScript konbini-67/src/app.test.tsx
6773 521 13 Python steam-backlog-enforcer/steam_backlog_enforcer/scanning.py
6768 752 9 Dart screen-locker/stronglift_replacement/workout_app/lib/services/storage_service.dart
6699 319 21 Python screen-locker/screen_locker/tests/conftest.py
6612 551 12 CSS europe-county-map/src/App.css
6600 440 15 Dart diet-guard/app/lib/screens/log_meal_screen.dart
6516 543 12 Shell testsAndMisc/phone_focus_mode/focus_daemon.sh
6312 1052 6 Dart diet-guard/app/lib/screens/history_screen.dart
### DISTRIBUTION BY LANGUAGE (code only)
lang n median mean p75 p90 p95 p99 max
---------- --- ------ ----- --- --- --- ---- ----
Python 895 179 206.6 294 399 466 652 1076
Dart 758 105 160.5 203 351 480 897 1417
TypeScript 459 85 125.4 162 266 361 698 1437
Shell 363 120 201.1 243 457 663 1276 1734
Kotlin 68 26 71.0 69 223 277 564 564
HTML 60 402 333.8 548 716 738 843 843
C 22 21 70.5 88 229 249 478 478
CSS 19 169 261.1 535 768 774 774 774
Swift 17 12 12 15 16 16 16 16
C++ 16 70 97.8 151 288 288 288 288
JavaScript 15 47 65.7 97 134 179 179 179
Java 8 40 69.2 101 247 247 247 247
### DISTRIBUTION BY PROJECT (code only, >=10 files)
project n median MEAN p90 max total
---------------------- --- ------ ----- --- ---- -------
steam-backlog-enforcer 114 269 301.3 588 1076 34,352
epopeja_karta 37 192 289.4 759 1417 10,708
kuhytrack 12 215 240 392 652 2,880
screen-locker 211 169 209.8 390 897 44,261
steam-game-installer 18 144 198.2 473 765 3,567
utils 127 167 193.3 412 525 24,552
mcp-servers 27 136 189.1 467 487 5,106
testsAndMisc 745 127 187.7 421 1734 139,813
europe-county-map 75 138 181.8 374 850 13,637
manga_translate 40 144 181.2 417 720 7,250
leetcode-guard 117 181 180.4 296 400 21,111
wake-alarm 68 150 178.2 405 764 12,118
diet-guard 217 128 175.6 379 1052 38,101
yay-mcp 16 109 168.3 478 554 2,693
todo 99 109 165.9 405 739 16,421
i3wm-mcp 12 130 165.8 275 615 1,989
home_inventory 113 96 152.6 328 1325 17,249
konbini-67 73 96 149.6 209 1437 10,919
dufs-cloud 126 99 146.8 257 1089 18,497
hotline3d 38 82 136.4 353 736 5,184
macro-cam 55 103 133.5 279 574 7,341
wiki-kb 31 95 121.3 215 463 3,760
atomichabits 15 88 109.8 188 251 1,647
octoforge 76 80 104.3 209 510 7,929
kuhylog 92 89 94.9 166 301 8,727
.claude 17 64 74.5 177 311 1,267
iron-and-anvil 40 55 71.7 159 220 2,869
habit_stack 54 42 71.7 169 288 3,870
personal-website 25 48 65.3 165 303 1,633
### COUNT OVER CANDIDATE THRESHOLDS (code only)
threshold files over % of code of those, edited last yr
--------- ---------- --------- ------------------------
300 474 17.6% 452
400 243 9.0% 229
500 118 4.4% 114
600 82 3.0% 80
800 33 1.2% 33
1000 19 0.7% 19
1500 1 0.0% 1
2000 0 0.0% 0

201
file_length/_tables.py Normal file
View File

@ -0,0 +1,201 @@
"""Exemption tables for the 250-line file-length cap.
The data half: which extensions are capped, and which paths are generated,
vendored, third-party or session artifacts. :mod:`file_length.exemptions`
holds the rules that read these, and :mod:`file_length.config` re-exports
both as the public surface.
Duplicating these lists in shell is what lets a gate and a survey silently
disagree, so they live here once.
"""
from __future__ import annotations
import re
#: Hard cap. Files above this fail the gate. Code and prose alike.
MAX_LINES = 250
#: Extensions the cap applies to: real programming languages plus prose.
#: Markup (.html/.css/.scss) is deliberately absent -- templates split into
#: partials, not functions, so a line cap does not express anything useful.
CAPPED_EXTENSIONS = {
# code
".py",
".dart",
".ts",
".tsx",
".js",
".jsx",
".sh",
".bash",
".zsh",
".c",
".h",
".cpp",
".cc",
".hpp",
".go",
".rs",
".java",
".kt",
".kts",
".swift",
".rb",
".lua",
".sql",
".vue",
".cs",
".php",
# prose
".md",
".markdown",
".txt",
".rst",
".tex",
".org",
}
#: Generated files: regenerated by a tool, so splitting them is meaningless
#: (the tool would just rewrite the original next run).
GENERATED_PATTERN = re.compile(
r"\.g\.dart$"
r"|\.freezed\.dart$"
r"|\.gr\.dart$"
r"|\.config\.dart$"
r"|\.mocks\.dart$"
r"|\.min\.(js|css)$"
r"|\.pb\.(go|py)$"
r"|_pb2(_grpc)?\.py$"
r"|\.generated\.ts$"
r"|\.d\.ts$"
r"|/l10n/generated/"
r"|/generated/"
)
#: A marker in the first few lines also marks a file generated. Covers tools
#: that do not use a distinguishing extension.
GENERATED_MARKERS = (
"GENERATED CODE - DO NOT MODIFY",
"@generated",
"DO NOT EDIT",
"Autogenerated",
"AUTOGENERATED",
"generated by",
)
#: Directory names never descended into: dependencies, build output, caches.
EXCLUDED_DIRS = frozenset(
{
".git",
"node_modules",
".dart_tool",
"build",
".venv",
"venv",
"env",
"site-packages",
"target",
"ephemeral",
"__pycache__",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"dist",
".next",
"vendor",
"third_party",
"Pods",
".gradle",
".idea",
".cxx",
"coverage",
"htmlcov",
".tox",
"Library",
"Temp",
"obj",
".terraform",
".fvm",
".angular",
".cache",
".svelte-kit",
"migrations",
".ci-mirror-venv",
".hippo",
".build-deps",
# Where CI checks out the shared gate itself (kuhyx/utils). Without
# this, a repo's lint job fails on utils' own long files -- a gate
# that fails for reasons the repo being gated cannot fix.
".utils",
}
)
#: Third-party content living *inside* a repo you own, keyed by repo dir name.
#: Verified by git remote / vendor manifest, not guessed.
VENDORED_SUBPATHS = {
"mcp-servers": ("servers",),
"dufs-cloud": ("app/assets/pdfjs", "web/assets/pdfjs"),
".claude": (
"projects",
"shell-snapshots",
"cache",
"backups",
"todos",
"statsig",
"history",
"chrome",
"downloads",
"plugins",
"ide",
),
# Agent skill bundles vendored from Anthropic (todo: commit f91311f).
# A SKILL.md is a fixed-name file with frontmatter; detail belongs in a
# sibling references/ dir, so splitting one into SKILL.part2.md would
# break the format rather than improve readability.
"todo": (".agents/skills",),
}
#: Directory names that mark a vendored agent-skill tree wherever they appear.
#: Keyed by name rather than repo because these bundles get dropped into any
#: repo that wants them, not just the one they were first seen in.
VENDORED_ANYWHERE = ("/.agents/skills/", "/.claude/skills/")
#: Frozen records of past agent sessions: an implementation plan or an
#: append-only session log describes what was done at one moment. Splitting one
#: rewrites a historical record for no reading benefit -- nobody navigates them,
#: they are read whole or not at all. Same category as generated files.
#:
#: Deliberately NOT the whole of docs/superpowers/: `specs/` and `contracts/`
#: stay capped, because those are living documents that get re-read and edited.
SESSION_ARTIFACTS_ANYWHERE = (
"/docs/superpowers/plans/",
"/docs/superpowers/sessions/",
)
#: Whole repos under ~ that are third-party clones, not kuhy's work.
#: Verified 2026-08-14 by `git remote get-url origin` + commit authors.
THIRD_PARTY_REPOS = frozenset(
{
"StringTheory", # github.com/AnthonySf/TheStringTheory
"reaper-mcp", # github.com/TwelveTake-Studios/reaper-mcp
"freesound-mcp", # github.com/timjrobinson/FreesoundMCPServer
"llama_cpp_dart_local", # generated FFI bindings
"aseprite-mcp",
"Mapperatorinator", # github.com/OliBomby/Mapperatorinator
"aur",
"build-your-own-x",
"build_your_x",
"byox-ladder",
"warriorjs",
"untools",
"awesome-mcp-explorer",
"runnerup",
"depotdownloader",
"actions-runner",
}
)
#: A `.txt` whose mean line is shorter than this is data (a wordlist, a
#: coordinate dump), not prose, so the cap does not apply.
DATA_TXT_MAX_MEAN_LINE = 25

File diff suppressed because it is too large Load Diff

View File

@ -1,252 +1,45 @@
"""Shared configuration for the 250-line file-length cap. """Shared configuration for the 250-line file-length cap.
Single source of truth for the cap and its exemptions, imported by both the Single source of truth for the cap and its exemptions, imported by both the
pre-commit gate (`check.py`) and the survey. Duplicating these lists in shell pre-commit gate (`check.py`) and the survey. This module is the public
is what lets a gate and a survey silently disagree. surface: :mod:`file_length._tables` holds the data, and
:mod:`file_length.exemptions` the rules that read it.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from file_length._tables import (
import re CAPPED_EXTENSIONS,
DATA_TXT_MAX_MEAN_LINE,
#: Hard cap. Files above this fail the gate. Code and prose alike. EXCLUDED_DIRS,
MAX_LINES = 250 GENERATED_MARKERS,
GENERATED_PATTERN,
#: Extensions the cap applies to: real programming languages plus prose. MAX_LINES,
#: Markup (.html/.css/.scss) is deliberately absent -- templates split into SESSION_ARTIFACTS_ANYWHERE,
#: partials, not functions, so a line cap does not express anything useful. THIRD_PARTY_REPOS,
CAPPED_EXTENSIONS = { VENDORED_ANYWHERE,
# code VENDORED_SUBPATHS,
".py", )
".dart", from file_length.exemptions import (
".ts", is_data_text,
".tsx", is_generated,
".js", is_session_artifact,
".jsx", is_vendored,
".sh",
".bash",
".zsh",
".c",
".h",
".cpp",
".cc",
".hpp",
".go",
".rs",
".java",
".kt",
".kts",
".swift",
".rb",
".lua",
".sql",
".vue",
".cs",
".php",
# prose
".md",
".markdown",
".txt",
".rst",
".tex",
".org",
}
#: Generated files: regenerated by a tool, so splitting them is meaningless
#: (the tool would just rewrite the original next run).
GENERATED_PATTERN = re.compile(
r"\.g\.dart$"
r"|\.freezed\.dart$"
r"|\.gr\.dart$"
r"|\.config\.dart$"
r"|\.mocks\.dart$"
r"|\.min\.(js|css)$"
r"|\.pb\.(go|py)$"
r"|_pb2(_grpc)?\.py$"
r"|\.generated\.ts$"
r"|\.d\.ts$"
r"|/l10n/generated/"
r"|/generated/"
) )
#: A marker in the first few lines also marks a file generated. Covers tools __all__ = [
#: that do not use a distinguishing extension. "CAPPED_EXTENSIONS",
GENERATED_MARKERS = ( "DATA_TXT_MAX_MEAN_LINE",
"GENERATED CODE - DO NOT MODIFY", "EXCLUDED_DIRS",
"@generated", "GENERATED_MARKERS",
"DO NOT EDIT", "GENERATED_PATTERN",
"Autogenerated", "MAX_LINES",
"AUTOGENERATED", "SESSION_ARTIFACTS_ANYWHERE",
"generated by", "THIRD_PARTY_REPOS",
) "VENDORED_ANYWHERE",
"VENDORED_SUBPATHS",
#: Directory names never descended into: dependencies, build output, caches. "is_data_text",
EXCLUDED_DIRS = frozenset( "is_generated",
{ "is_session_artifact",
".git", "is_vendored",
"node_modules", ]
".dart_tool",
"build",
".venv",
"venv",
"env",
"site-packages",
"target",
"ephemeral",
"__pycache__",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"dist",
".next",
"vendor",
"third_party",
"Pods",
".gradle",
".idea",
".cxx",
"coverage",
"htmlcov",
".tox",
"Library",
"Temp",
"obj",
".terraform",
".fvm",
".angular",
".cache",
".svelte-kit",
"migrations",
".ci-mirror-venv",
".hippo",
".build-deps",
# Where CI checks out the shared gate itself (kuhyx/utils). Without
# this, a repo's lint job fails on utils' own long files -- a gate
# that fails for reasons the repo being gated cannot fix.
".utils",
}
)
#: Third-party content living *inside* a repo you own, keyed by repo dir name.
#: Verified by git remote / vendor manifest, not guessed.
VENDORED_SUBPATHS = {
"mcp-servers": ("servers",),
"dufs-cloud": ("app/assets/pdfjs", "web/assets/pdfjs"),
".claude": (
"projects",
"shell-snapshots",
"cache",
"backups",
"todos",
"statsig",
"history",
"chrome",
"downloads",
"plugins",
"ide",
),
# Agent skill bundles vendored from Anthropic (todo: commit f91311f).
# A SKILL.md is a fixed-name file with frontmatter; detail belongs in a
# sibling references/ dir, so splitting one into SKILL.part2.md would
# break the format rather than improve readability.
"todo": (".agents/skills",),
}
#: Directory names that mark a vendored agent-skill tree wherever they appear.
#: Keyed by name rather than repo because these bundles get dropped into any
#: repo that wants them, not just the one they were first seen in.
VENDORED_ANYWHERE = ("/.agents/skills/", "/.claude/skills/")
#: Frozen records of past agent sessions: an implementation plan or an
#: append-only session log describes what was done at one moment. Splitting one
#: rewrites a historical record for no reading benefit -- nobody navigates them,
#: they are read whole or not at all. Same category as generated files.
#:
#: Deliberately NOT the whole of docs/superpowers/: `specs/` and `contracts/`
#: stay capped, because those are living documents that get re-read and edited.
SESSION_ARTIFACTS_ANYWHERE = (
"/docs/superpowers/plans/",
"/docs/superpowers/sessions/",
)
#: Whole repos under ~ that are third-party clones, not kuhy's work.
#: Verified 2026-08-14 by `git remote get-url origin` + commit authors.
THIRD_PARTY_REPOS = frozenset(
{
"StringTheory", # github.com/AnthonySf/TheStringTheory
"reaper-mcp", # github.com/TwelveTake-Studios/reaper-mcp
"freesound-mcp", # github.com/timjrobinson/FreesoundMCPServer
"llama_cpp_dart_local", # generated FFI bindings
"aseprite-mcp",
"Mapperatorinator", # github.com/OliBomby/Mapperatorinator
"aur",
"build-your-own-x",
"build_your_x",
"byox-ladder",
"warriorjs",
"untools",
"awesome-mcp-explorer",
"runnerup",
"depotdownloader",
"actions-runner",
}
)
#: A `.txt` whose mean line is shorter than this is data (a wordlist, a
#: coordinate dump), not prose, so the cap does not apply.
DATA_TXT_MAX_MEAN_LINE = 25
def is_generated(path: Path) -> bool:
"""True if the file is tool-generated and must not be split by hand."""
if GENERATED_PATTERN.search(path.as_posix()):
return True
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
head = "".join(next(handle, "") for _ in range(5))
except OSError:
return False
return any(marker in head for marker in GENERATED_MARKERS)
def is_data_text(path: Path, lines: int, size: int) -> bool:
"""True for wordlist-style .txt: many lines, very short ones."""
if path.suffix.lower() != ".txt" or lines <= 0:
return False
return (size / lines) < DATA_TXT_MAX_MEAN_LINE
def is_vendored(path: Path) -> bool:
"""True if the path sits under a known vendored subtree or excluded dir.
Callers must pass an absolute path (see `check.absolutize`). Every rule
below needs the repo-name context that a repo-relative path has already
thrown away: '/.claude/skills/' cannot match 'skills/x.md', so a relative
path would quietly skip the exemption instead of applying it.
"""
parts = path.parts
if any(part in EXCLUDED_DIRS for part in parts):
return True
posix = path.as_posix()
if any(marker in posix for marker in VENDORED_ANYWHERE):
return True
for repo, subs in VENDORED_SUBPATHS.items():
for sub in subs:
if f"/{repo}/{sub}/" in posix:
return True
return any(f"/{repo}/" in posix for repo in THIRD_PARTY_REPOS)
def is_session_artifact(path: Path) -> bool:
"""True for a frozen agent-session record (a plan or a session log).
Kept separate from `is_vendored` so the gate reports why a file is exempt:
these are kuhy's own files, not third-party code.
Callers must pass an absolute path (see `check.absolutize`), for the same
reason `is_vendored` does -- a repo-relative path has already dropped the
leading directory these markers match on.
"""
posix = path.as_posix()
return any(marker in posix for marker in SESSION_ARTIFACTS_ANYWHERE)

82
file_length/exemptions.py Normal file
View File

@ -0,0 +1,82 @@
"""Predicates deciding whether a file is exempt from the 250-line cap.
Split from :mod:`file_length.config`, which holds the tables these read.
Keeping the rules separate from the data they consult means a new exemption
is a table entry, not a new branch, and the gate and the survey keep sharing
one definition of "exempt".
Re-exported from :mod:`file_length.config`, so existing
``from file_length.config import is_generated`` imports keep working.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from file_length._tables import (
DATA_TXT_MAX_MEAN_LINE,
EXCLUDED_DIRS,
GENERATED_MARKERS,
GENERATED_PATTERN,
SESSION_ARTIFACTS_ANYWHERE,
THIRD_PARTY_REPOS,
VENDORED_ANYWHERE,
VENDORED_SUBPATHS,
)
if TYPE_CHECKING:
from pathlib import Path
def is_generated(path: Path) -> bool:
"""True if the file is tool-generated and must not be split by hand."""
if GENERATED_PATTERN.search(path.as_posix()):
return True
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
head = "".join(next(handle, "") for _ in range(5))
except OSError:
return False
return any(marker in head for marker in GENERATED_MARKERS)
def is_data_text(path: Path, lines: int, size: int) -> bool:
"""True for wordlist-style .txt: many lines, very short ones."""
if path.suffix.lower() != ".txt" or lines <= 0:
return False
return (size / lines) < DATA_TXT_MAX_MEAN_LINE
def is_vendored(path: Path) -> bool:
"""True if the path sits under a known vendored subtree or excluded dir.
Callers must pass an absolute path (see `check.absolutize`). Every rule
below needs the repo-name context that a repo-relative path has already
thrown away: '/.claude/skills/' cannot match 'skills/x.md', so a relative
path would quietly skip the exemption instead of applying it.
"""
parts = path.parts
if any(part in EXCLUDED_DIRS for part in parts):
return True
posix = path.as_posix()
if any(marker in posix for marker in VENDORED_ANYWHERE):
return True
for repo, subs in VENDORED_SUBPATHS.items():
for sub in subs:
if f"/{repo}/{sub}/" in posix:
return True
return any(f"/{repo}/" in posix for repo in THIRD_PARTY_REPOS)
def is_session_artifact(path: Path) -> bool:
"""True for a frozen agent-session record (a plan or a session log).
Kept separate from `is_vendored` so the gate reports why a file is exempt:
these are kuhy's own files, not third-party code.
Callers must pass an absolute path (see `check.absolutize`), for the same
reason `is_vendored` does -- a repo-relative path has already dropped the
leading directory these markers match on.
"""
posix = path.as_posix()
return any(marker in posix for marker in SESSION_ARTIFACTS_ANYWHERE)

View File

@ -18,6 +18,8 @@ fi
source "$LIB_DIR/common.sh" source "$LIB_DIR/common.sh"
# shellcheck source=lib/file_guard.sh # shellcheck source=lib/file_guard.sh
source "$LIB_DIR/file_guard.sh" source "$LIB_DIR/file_guard.sh"
# shellcheck source=lib/file_guard_unlock.sh
source "$LIB_DIR/file_guard_unlock.sh"
# shellcheck source=lib/package_block.sh # shellcheck source=lib/package_block.sh
source "$LIB_DIR/package_block.sh" source "$LIB_DIR/package_block.sh"

View File

@ -138,213 +138,3 @@ fg_enforce() {
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name" [[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
log_hook "file-guard $name: enforce ok" log_hook "file-guard $name: enforce ok"
} }
fg_unlock() {
local name="$1"
require_root
load_target_conf "$name"
# shellcheck disable=SC2153 # PLUGIN set by load_target_conf, not a typo
load_plugin "$PLUGIN"
echo "About to unlock file-guard '$name' ($TARGET) for editing."
read -rp "Reason for unlocking: " reason
[[ -n "$reason" ]] || die "a reason is required"
log_hook "file-guard $name: UNLOCK requested by ${SUDO_USER:-$(whoami)}: $reason"
echo "Unlocking in 45s (Ctrl-C to abort)..."
sleep 45
# Stop the watcher before unlocking: chattr alone (no content write)
# fires PathModified, and the resulting enforce pass unconditionally
# re-locks the target even with no drift - which would silently
# re-lock this file out from under the editor. Confirmed live during
# the shutdown-schedule migration (see guard-lib project notes).
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
unlock_file "$TARGET"
log_hook "file-guard $name: unlocked for editing"
local before after
before="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
# Deliberately unquoted: $EDITOR commonly carries arguments (e.g.
# "code --wait", "emacsclient -t") that must be word-split.
${EDITOR:-vi} "$TARGET"
after="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
if [[ "$before" != "$after" ]]; then
if declare -f validate >/dev/null 2>&1 && ! validate "$TARGET"; then
echo "New content fails this instance's validate() check - reverting your edit; canonical unchanged." >&2
log_hook "file-guard $name: edit REJECTED (failed validate), reverting to canonical"
[[ -e "$CANONICAL" ]] && cp --preserve=mode,ownership,timestamps "$CANONICAL" "$TARGET"
else
# Update canonical to match: without this, re-locking below
# fires the watcher, which would see the new content as
# "drift" against the stale canonical and silently revert
# this legitimate edit.
log_hook "file-guard $name: content changed, updating canonical"
unlock_file "$CANONICAL"
cp --preserve=mode,ownership,timestamps "$TARGET" "$CANONICAL"
lock_file "$CANONICAL"
fi
else
log_hook "file-guard $name: no content change"
fi
lock_file "$TARGET"
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
systemctl start "guard-file@${name}.path" 2>/dev/null || true
log_hook "file-guard $name: re-locked after edit"
echo "Re-locked '$name'."
}
fg_uninstall() {
local name="$1"
shift
local keep_canonical=0
while [[ $# -gt 0 ]]; do
case "$1" in
--keep-canonical) keep_canonical=1; shift ;;
*) die "file-guard uninstall: unknown argument '$1'" ;;
esac
done
require_root
load_target_conf "$name"
systemctl disable --now "guard-file@$name.path" 2>/dev/null || true
systemctl disable --now "guard-bind-mount@$name.service" 2>/dev/null || true
rm -rf "${GUARD_LIB_SYSTEMD_DIR:?}/guard-file@$name.path.d"
systemctl daemon-reload
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
unlock_file "$TARGET"
if [[ "$keep_canonical" -eq 0 ]]; then
unlock_file "$CANONICAL"
rm -f "$CANONICAL"
fi
rm -f "$(target_conf_path "$name")"
log_hook "file-guard $name: uninstalled (keep_canonical=$keep_canonical)"
echo "uninstalled file-guard '$name'"
}
fg_status() {
local name="$1"
load_target_conf "$name"
echo "name: $name"
echo "target: $TARGET"
echo "canonical: $CANONICAL"
# shellcheck disable=SC2153 # BIND_MOUNT/PLUGIN/ALSO_WATCH set by load_target_conf, not typos
echo "bind_mount: $BIND_MOUNT"
echo "plugin: ${PLUGIN:-<none>}"
echo "also_watch: ${ALSO_WATCH:-<none>}"
if [[ -e "$TARGET" ]]; then
local attrs
attrs="$(lsattr "$TARGET" 2>/dev/null | awk '{print $1}')"
echo "target attrs: ${attrs:-unknown}"
else
echo "target: MISSING"
fi
local unit_state
unit_state="$(systemctl is-active "guard-file@$name.path" 2>/dev/null || true)"
echo "path unit: ${unit_state:-unknown}"
}
fg_canonical_path() {
# Machine-readable single-value output: the canonical path for an
# instance, so consumers with bespoke unlock flows (that can't go
# through `unlock`/`enforce` directly) don't have to hardcode or
# duplicate guard-lib's canonical-path convention.
local name="$1"
load_target_conf "$name"
echo "$CANONICAL"
}
fg_pacman_unlock() {
# Quiet, non-interactive unlock used only by the generic pacman
# PreTransaction hook - no typed reason, no editor. The pacman
# transaction itself is the sanctioned reason.
#
# Stops the path unit, not just chattr -i's the target: chattr alone
# fires PathModified, and if the watcher's enforce pass wins the race
# against pacman's own write, it silently re-locks the file before
# pacman gets to it, breaking the very transaction this hook exists
# to allow. Matches the pre-existing hosts-guard pacman hooks, which
# stop hosts-guard.path/nsswitch-guard.path/resolved-guard.path for
# the same reason.
local name="$1"
load_target_conf "$name"
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
unlock_file "$TARGET"
log_hook "file-guard $name: pacman-unlock (pre-transaction)"
}
fg_pacman_relock() {
# Counterpart to fg_pacman_unlock, used only by the generic pacman
# PostTransaction hook: re-enforce (drift-restore + chattr +i, and -
# if this instance is bind-mounted - re-establish the read-only
# bind mount, since fg_enforce does that itself now) then restart
# the path unit that pacman-unlock stopped.
local name="$1"
fg_enforce "$name"
systemctl start "guard-file@${name}.path" 2>/dev/null || true
log_hook "file-guard $name: pacman-relock (post-transaction)"
}
fg_sync_canonical() {
# Counterpart to fg_pacman_unlock for callers that legitimately EDITED
# the target (not pacman's own hook flow, which wants drift reverted).
# Adopts the target's current content as the new canonical, instead of
# fg_enforce's drift-check-and-revert - which would otherwise silently
# undo the very edit the caller just made. Assumes the watcher was
# already stopped by a prior fg_pacman_unlock; does not stop it itself.
local name="$1"
load_target_conf "$name"
snapshot_canonical "$name"
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
unlock_file "$TARGET"
lock_file "$TARGET"
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
systemctl start "guard-file@${name}.path" 2>/dev/null || true
log_hook "file-guard $name: synced canonical to current content"
}
# Self-bind-mounts TARGET over itself, then remounts that layer
# read-only. This is a mount-level lock independent of chattr: even a
# successful `chattr -i` bypass still can't write through a read-only
# mount. Deliberately does NOT bind CANONICAL over TARGET - that would
# merge them onto the same inode, so chattr/writes to either would hit
# both and "restore from canonical" would become meaningless.
_reapply_bind_mount() {
local name="$1"
if mountpoint -q "$TARGET" 2>/dev/null; then
return 0
fi
mount --bind "$TARGET" "$TARGET"
mount -o remount,ro,bind "$TARGET"
log_hook "file-guard $name: bind-mounted $TARGET read-only"
}
fg_bind_mount() {
local name="$1"
require_root
load_target_conf "$name"
_reapply_bind_mount "$name"
}
file_guard_main() {
local sub="$1"
shift || true
case "$sub" in
install) fg_install "$@" ;;
enforce) fg_enforce "$@" ;;
unlock) fg_unlock "$@" ;;
pacman-unlock) fg_pacman_unlock "$@" ;;
uninstall) fg_uninstall "$@" ;;
status) fg_status "$@" ;;
canonical-path) fg_canonical_path "$@" ;;
bind-mount) fg_bind_mount "$@" ;;
pacman-relock) fg_pacman_relock "$@" ;;
sync) fg_sync_canonical "$@" ;;
*) die "file-guard: unknown subcommand '$sub' (expected install|enforce|unlock|pacman-unlock|pacman-relock|sync|uninstall|status|canonical-path|bind-mount)" ;;
esac
}

View File

@ -0,0 +1,214 @@
#!/bin/bash
# guardctl file-guard unlocking, pacman coordination and dispatch.
# Split from file_guard.sh for the shared 250-line cap; sourced by
# guardctl right after it, never executed directly.
fg_unlock() {
local name="$1"
require_root
load_target_conf "$name"
# shellcheck disable=SC2153 # PLUGIN set by load_target_conf, not a typo
load_plugin "$PLUGIN"
echo "About to unlock file-guard '$name' ($TARGET) for editing."
read -rp "Reason for unlocking: " reason
[[ -n "$reason" ]] || die "a reason is required"
log_hook "file-guard $name: UNLOCK requested by ${SUDO_USER:-$(whoami)}: $reason"
echo "Unlocking in 45s (Ctrl-C to abort)..."
sleep 45
# Stop the watcher before unlocking: chattr alone (no content write)
# fires PathModified, and the resulting enforce pass unconditionally
# re-locks the target even with no drift - which would silently
# re-lock this file out from under the editor. Confirmed live during
# the shutdown-schedule migration (see guard-lib project notes).
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
unlock_file "$TARGET"
log_hook "file-guard $name: unlocked for editing"
local before after
before="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
# Deliberately unquoted: $EDITOR commonly carries arguments (e.g.
# "code --wait", "emacsclient -t") that must be word-split.
${EDITOR:-vi} "$TARGET"
after="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
if [[ "$before" != "$after" ]]; then
if declare -f validate >/dev/null 2>&1 && ! validate "$TARGET"; then
echo "New content fails this instance's validate() check - reverting your edit; canonical unchanged." >&2
log_hook "file-guard $name: edit REJECTED (failed validate), reverting to canonical"
[[ -e "$CANONICAL" ]] && cp --preserve=mode,ownership,timestamps "$CANONICAL" "$TARGET"
else
# Update canonical to match: without this, re-locking below
# fires the watcher, which would see the new content as
# "drift" against the stale canonical and silently revert
# this legitimate edit.
log_hook "file-guard $name: content changed, updating canonical"
unlock_file "$CANONICAL"
cp --preserve=mode,ownership,timestamps "$TARGET" "$CANONICAL"
lock_file "$CANONICAL"
fi
else
log_hook "file-guard $name: no content change"
fi
lock_file "$TARGET"
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
systemctl start "guard-file@${name}.path" 2>/dev/null || true
log_hook "file-guard $name: re-locked after edit"
echo "Re-locked '$name'."
}
fg_uninstall() {
local name="$1"
shift
local keep_canonical=0
while [[ $# -gt 0 ]]; do
case "$1" in
--keep-canonical) keep_canonical=1; shift ;;
*) die "file-guard uninstall: unknown argument '$1'" ;;
esac
done
require_root
load_target_conf "$name"
systemctl disable --now "guard-file@$name.path" 2>/dev/null || true
systemctl disable --now "guard-bind-mount@$name.service" 2>/dev/null || true
rm -rf "${GUARD_LIB_SYSTEMD_DIR:?}/guard-file@$name.path.d"
systemctl daemon-reload
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
unlock_file "$TARGET"
if [[ "$keep_canonical" -eq 0 ]]; then
unlock_file "$CANONICAL"
rm -f "$CANONICAL"
fi
rm -f "$(target_conf_path "$name")"
log_hook "file-guard $name: uninstalled (keep_canonical=$keep_canonical)"
echo "uninstalled file-guard '$name'"
}
fg_status() {
local name="$1"
load_target_conf "$name"
echo "name: $name"
echo "target: $TARGET"
echo "canonical: $CANONICAL"
# shellcheck disable=SC2153 # BIND_MOUNT/PLUGIN/ALSO_WATCH set by load_target_conf, not typos
echo "bind_mount: $BIND_MOUNT"
echo "plugin: ${PLUGIN:-<none>}"
echo "also_watch: ${ALSO_WATCH:-<none>}"
if [[ -e "$TARGET" ]]; then
local attrs
attrs="$(lsattr "$TARGET" 2>/dev/null | awk '{print $1}')"
echo "target attrs: ${attrs:-unknown}"
else
echo "target: MISSING"
fi
local unit_state
unit_state="$(systemctl is-active "guard-file@$name.path" 2>/dev/null || true)"
echo "path unit: ${unit_state:-unknown}"
}
fg_canonical_path() {
# Machine-readable single-value output: the canonical path for an
# instance, so consumers with bespoke unlock flows (that can't go
# through `unlock`/`enforce` directly) don't have to hardcode or
# duplicate guard-lib's canonical-path convention.
local name="$1"
load_target_conf "$name"
echo "$CANONICAL"
}
fg_pacman_unlock() {
# Quiet, non-interactive unlock used only by the generic pacman
# PreTransaction hook - no typed reason, no editor. The pacman
# transaction itself is the sanctioned reason.
#
# Stops the path unit, not just chattr -i's the target: chattr alone
# fires PathModified, and if the watcher's enforce pass wins the race
# against pacman's own write, it silently re-locks the file before
# pacman gets to it, breaking the very transaction this hook exists
# to allow. Matches the pre-existing hosts-guard pacman hooks, which
# stop hosts-guard.path/nsswitch-guard.path/resolved-guard.path for
# the same reason.
local name="$1"
load_target_conf "$name"
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
unlock_file "$TARGET"
log_hook "file-guard $name: pacman-unlock (pre-transaction)"
}
fg_pacman_relock() {
# Counterpart to fg_pacman_unlock, used only by the generic pacman
# PostTransaction hook: re-enforce (drift-restore + chattr +i, and -
# if this instance is bind-mounted - re-establish the read-only
# bind mount, since fg_enforce does that itself now) then restart
# the path unit that pacman-unlock stopped.
local name="$1"
fg_enforce "$name"
systemctl start "guard-file@${name}.path" 2>/dev/null || true
log_hook "file-guard $name: pacman-relock (post-transaction)"
}
fg_sync_canonical() {
# Counterpart to fg_pacman_unlock for callers that legitimately EDITED
# the target (not pacman's own hook flow, which wants drift reverted).
# Adopts the target's current content as the new canonical, instead of
# fg_enforce's drift-check-and-revert - which would otherwise silently
# undo the very edit the caller just made. Assumes the watcher was
# already stopped by a prior fg_pacman_unlock; does not stop it itself.
local name="$1"
load_target_conf "$name"
snapshot_canonical "$name"
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
unlock_file "$TARGET"
lock_file "$TARGET"
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
systemctl start "guard-file@${name}.path" 2>/dev/null || true
log_hook "file-guard $name: synced canonical to current content"
}
# Self-bind-mounts TARGET over itself, then remounts that layer
# read-only. This is a mount-level lock independent of chattr: even a
# successful `chattr -i` bypass still can't write through a read-only
# mount. Deliberately does NOT bind CANONICAL over TARGET - that would
# merge them onto the same inode, so chattr/writes to either would hit
# both and "restore from canonical" would become meaningless.
_reapply_bind_mount() {
local name="$1"
if mountpoint -q "$TARGET" 2>/dev/null; then
return 0
fi
mount --bind "$TARGET" "$TARGET"
mount -o remount,ro,bind "$TARGET"
log_hook "file-guard $name: bind-mounted $TARGET read-only"
}
fg_bind_mount() {
local name="$1"
require_root
load_target_conf "$name"
_reapply_bind_mount "$name"
}
file_guard_main() {
local sub="$1"
shift || true
case "$sub" in
install) fg_install "$@" ;;
enforce) fg_enforce "$@" ;;
unlock) fg_unlock "$@" ;;
pacman-unlock) fg_pacman_unlock "$@" ;;
uninstall) fg_uninstall "$@" ;;
status) fg_status "$@" ;;
canonical-path) fg_canonical_path "$@" ;;
bind-mount) fg_bind_mount "$@" ;;
pacman-relock) fg_pacman_relock "$@" ;;
sync) fg_sync_canonical "$@" ;;
*) die "file-guard: unknown subcommand '$sub' (expected install|enforce|unlock|pacman-unlock|pacman-relock|sync|uninstall|status|canonical-path|bind-mount)" ;;
esac
}