Skip to content

Instantly share code, notes, and snippets.

@jamespo
Created August 30, 2026 08:53
Show Gist options
  • Select an option

  • Save jamespo/3bdc25b13c1710352f3e0a748b008731 to your computer and use it in GitHub Desktop.

Select an option

Save jamespo/3bdc25b13c1710352f3e0a748b008731 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
#
# Clean the pacman and yay package caches.
# Based on https://herbort.me/posts/automatically-cleaning-pacman-and-yay-cache-in-arch-linux/
#
# Intended to run as root (needed to prune /var/cache/pacman/pkg), typically
# from a systemd timer or pacman hook. Everything that touches the user's yay
# cache is re-executed as that user, so root never walks, deletes, or follows
# symlinks under a directory an unprivileged account controls.
#
# Override the target account with YAYCACHE_UID, or the cache location with
# YAYCACHE_DIR.
set -euo pipefail
shopt -s nullglob
export LC_ALL=C
readonly PACCACHE=/usr/bin/paccache
readonly ADMIN_UID="${YAYCACHE_UID:-1000}"
warn() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
die() { warn "$*"; exit 1; }
## pkgbase, pkgname and pkgver for every locally installed package.
## yay names its cache directories after pkgbase, which for split packages is
## not an installed pkgname, so pkgname alone is not enough to match them up.
pkg_base_name_ver() {
if command -v expac >/dev/null 2>&1; then
expac -Q '%e %n %v'
else
awk '
/^%NAME%$/ { getline; n = $0 }
/^%VERSION%$/ { getline; v = $0 }
/^%BASE%$/ { getline; b = $0 }
ENDFILE { if (n != "") print (b == "" ? n : b), n, v; n = v = b = "" }
' /var/lib/pacman/local/*/desc
fi
}
clean_user_cache() {
local cachedir="$1"
[[ -d $cachedir ]] || { warn "no yay cache at $cachedir"; return 0; }
## Foreign (AUR) packages that are still installed, keyed by both pkgbase
## and pkgname, mapped to the plain pkgver (no epoch, no pkgrel).
local -A foreign=() keep=()
local name base ver
while IFS= read -r name; do
foreign["$name"]=1
done < <(pacman -Qqm || true)
while read -r base name ver; do
[[ -n ${foreign[$name]-} ]] || continue
[[ -n $base && $base != '(null)' ]] || base="$name"
ver="${ver%-*}"
ver="${ver#*:}"
keep["$base"]="$ver"
keep["$name"]="$ver"
done < <(pkg_base_name_ver || true)
local -a stale=()
local pkgdir pkgname pkgver f
while IFS= read -r -d '' pkgdir; do
pkgname="${pkgdir##*/}"
## Foreign package is gone: drop its cache entirely.
if [[ -z ${keep[$pkgname]+set} ]]; then
stale+=("$pkgdir")
continue
fi
pkgver="${keep[$pkgname]}"
## Remove untracked files (e. g. source/build files) excepting package
## files and main source files for the installed version. Skipped for
## -git packages, whose checkout has no version to match against.
if [[ $pkgname != *-git && -n $pkgver && -d $pkgdir/.git ]]; then
while IFS= read -r -d '' f; do
[[ $f == */ ]] && continue # untracked directory
[[ $f == *.pkg.tar* ]] && continue # built package
[[ $f == *"$pkgver"* ]] && continue # sources for installed version
rm -f -- "$pkgdir/$f"
## --directory stops the walk at the top of an untracked directory,
## so makepkg's mode 0111 pkg/ is reported (and skipped) rather than
## descended into, which would warn about being unreadable.
done < <(git -C "$pkgdir" ls-files --others --directory -z || true)
fi
rm -rf -- "$pkgdir/src"
done < <(find "$cachedir" -mindepth 1 -maxdepth 1 -type d -print0)
if (( ${#stale[@]} )); then
rm -rf -- "${stale[@]}"
fi
## Remove everything for uninstalled foreign packages, keep two latest
## versions for installed ones.
local -a cargs=()
while IFS= read -r -d '' pkgdir; do
cargs+=(-c "$pkgdir")
done < <(find "$cachedir" -mindepth 1 -maxdepth 1 -type d -print0)
if (( ${#cargs[@]} )); then
"$PACCACHE" -qruk0 "${cargs[@]}" || warn "paccache -k0 failed for $cachedir"
"$PACCACHE" -qrk2 "${cargs[@]}" || warn "paccache -k2 failed for $cachedir"
fi
}
main() {
[[ -x $PACCACHE ]] || die "$PACCACHE not found; install pacman-contrib"
local entry admin admin_home cachedir
entry="$(getent passwd "$ADMIN_UID")" || die "no passwd entry for uid $ADMIN_UID"
IFS=: read -r admin _ _ _ _ admin_home _ <<<"$entry"
[[ -n $admin && -n $admin_home ]] || die "malformed passwd entry for uid $ADMIN_UID"
cachedir="${YAYCACHE_DIR:-$admin_home/.cache/yay}"
if (( EUID == ADMIN_UID )); then
clean_user_cache "$cachedir"
elif (( EUID == 0 )); then
## Drop to $admin for the cache work. The function bodies are piped in
## rather than re-execing $0, which $admin may not be able to read.
{
declare -p PACCACHE
declare -f warn pkg_base_name_ver clean_user_cache
## cd / because $admin usually cannot read root's cwd (/root is
## 0700), which makes find fail to restore its initial directory.
printf 'set -euo pipefail\nshopt -s nullglob\nexport LC_ALL=C\ncd /\nclean_user_cache "$1"\n'
} | runuser -u "$admin" -- bash -s -- "$cachedir" \
|| warn "cleaning $cachedir as $admin failed"
else
die "must run as root or as $admin (uid $ADMIN_UID)"
fi
if (( EUID != 0 )); then
warn "not root; skipping /var/cache/pacman/pkg"
return 0
fi
## Keep latest version for uninstalled native packages, two latest for
## installed ones.
"$PACCACHE" -qruk1 || warn "paccache -k1 failed"
"$PACCACHE" -qrk2 -c /var/cache/pacman/pkg || warn "paccache -k2 failed"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment