first commit

This commit is contained in:
bryanpedini committed 2026-10-10 10:37:53 +02:00
commit 1354db6c39
90 files changed
+13123

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Executable proof for discovery/pacman.md. Run inside archlinux:latest:
# docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/checks.sh
# Needs network (real repos + pacman-contrib for the pactree comparison).
export LC_ALL=C
fails=0
ok() { printf 'PASS %s\n' "$*"; }
bad() { printf 'FAIL %s\n' "$*"; fails=$((fails+1)); }
eq() { # eq <desc> <expected> <actual>
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1"; printf ' expected: %q\n actual: %q\n' "$2" "$3"; fi
}
d=$(dirname "$(readlink -f "$0")")
bash "$d/fixture.sh" 2>&1 | grep -v 'database file for'
. "$d/lib.sh"
echo "### 1/2 refresh + upgradable"
pm_refresh >/dev/null 2>&1; eq "pm_refresh exit" 0 $?
pm_upgradable | grep '^su-dummy' > /tmp/up.tsv
eq "upgradable su-dummy rows" $'su-dummy-a\t1.0-1\t2.0-1\tsu-dummy\t0\nsu-dummy-b\t1.0-1\t2.0-1\tsu-dummy\t0\nsu-dummy-c\t1.0-1\t2.0-1\tsu-dummy\t0' "$(cat /tmp/up.tsv)"
pm_upgradable | head -3 | sed 's/^/ /'
echo 'IgnorePkg = su-dummy-c' > /etc/pacman.d/ignorepkg
eq "ignored flag" $'su-dummy-c\t1.0-1\t2.0-1\tsu-dummy\t1' "$(pm_upgradable | grep '^su-dummy-c')"
eq "pacman-conf IgnorePkg" su-dummy-c "$(pm_ignored_patterns)"
: > /etc/pacman.d/ignorepkg
echo "### 3 descriptions"
eq "local desc" $'bash\tThe GNU Bourne Again shell' "$(pm_desc_local bash)"
eq "cand desc" $'su-dummy-a\tSkeptical-updater dummy package A' "$(pm_desc_cand su-dummy/su-dummy-a)"
eq "desc wrapped input is unwrapped" $'bash\tThe GNU Bourne Again shell' "$(COLUMNS=20 pm_desc_local bash)"
eq "desc via raw COLUMNS=20 wraps" yes "$( [ "$(COLUMNS=20 pacman -Qi bash | grep -c '^ \{10,\}')" -gt 0 ] && echo yes || echo no )"
echo "### 4 reverse deps"
eq "required-by su-dummy-b" su-dummy-a "$(pm_required_by su-dummy-b)"
eq "required-by su-dummy-a (None => empty)" "" "$(pm_required_by su-dummy-a)"
eq "required-by readline (soname dep)" "bash" "$(pm_required_by readline | grep -x bash)"
eq "optional-for bash" "ncurses pcre2 tzdata" "$(pm_optional_for bash | tr '\n' ' ' | sed 's/ $//')"
echo "### 5 closure vs pactree"
pacman -S --noconfirm --needed pacman-contrib fakeroot >/dev/null 2>&1 # fakeroot only for checkupdates
for p in bash pacman su-dummy-a; do
mine=$(pm_closure "$p" | tr '\n' ' ')
ref=$(pactree -l -u "$p" | xargs -r pacman -Qq -- 2>/dev/null | sort -u | grep -vx "$p" | tr '\n' ' ')
eq "closure($p) == pactree|resolve" "$ref" "$mine"
printf ' %s -> %s packages\n' "$p" "$(echo $mine | wc -w)"
done
eq "closure(sh) resolves provider" "$(pm_closure bash)" "$(pm_closure sh)"
eq "reverse closure(su-dummy-b)" su-dummy-a "$(pm_closure -r su-dummy-b)"
for p in glibc bash; do
mine=$(pm_closure -r "$p" | tr '\n' ' ')
ref=$(pactree -r -l -u "$p" | xargs -r pacman -Qq -- 2>/dev/null | sort -u | grep -vx "$p" | tr '\n' ' ')
eq "reverse closure($p) == pactree -r" "$ref" "$mine"
done
echo "### 6 plan / ignore / apply"
eq "plan all" $'su-dummy/su-dummy-b 2.0-1\nsu-dummy/su-dummy-a 2.0-1\nsu-dummy/su-dummy-c 2.0-1' "$(pm_plan | grep su-dummy)"
eq "plan skip a,c" $'su-dummy/su-dummy-b 2.0-1' "$(pm_plan su-dummy-a su-dummy-c | grep su-dummy)"
pm_apply su-dummy-a,su-dummy-c >/dev/null 2>&1; eq "apply (comma list passed as one arg) exit" 0 $?
eq "after partial apply" $'su-dummy-a 1.0-1\nsu-dummy-b 2.0-1\nsu-dummy-c 1.0-1' "$(pacman -Q su-dummy-a su-dummy-b su-dummy-c)"
pm_apply su-dummy-a >/dev/null 2>&1; pm_apply >/dev/null 2>&1
eq "after full apply" $'su-dummy-a 2.0-1\nsu-dummy-b 2.0-1\nsu-dummy-c 2.0-1' "$(pacman -Q su-dummy-a su-dummy-b su-dummy-c)"
echo "### 7 nothing to do / exit codes"
pm_apply >/dev/null 2>&1 # real repos: upgrade everything so nothing is left
pacman -Qu >/dev/null; eq "pacman -Qu exit when fully upgraded" 1 $?
out=$(pacman -Su --noconfirm 2>&1); rc=$?
eq "pacman -Su nothing to do: exit" 0 "$rc"
eq "pacman -Su nothing to do: message" $':: Starting full system upgrade...\n there is nothing to do' "$out"
checkupdates >/dev/null 2>&1; eq "checkupdates exit when nothing to do" 2 $?
echo "### 8 purge"
pm_purge su-dummy-b >/dev/null 2>&1; eq "purge b (needed by a) exit" 1 $?
pm_purge su-dummy-a >/dev/null 2>&1; eq "purge a exit" 0 $?
pacman -Q su-dummy-b >/dev/null 2>&1; eq "b removed with a (was --asdeps)" 1 $?
echo
[ "$fails" -eq 0 ] && echo "ALL CHECKS PASSED" || echo "$fails CHECK(S) FAILED"
exit "$fails"
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Deterministic pacman fixture for skeptical-updater E2E tests.
#
# Creates three dummy packages installed at 1.0 and a local pacman repo
# ([su-dummy], file:///srv/su-repo) that offers them at 2.0:
#
# su-dummy-a depends=(su-dummy-b) installed explicitly
# su-dummy-b installed as a dependency (--asdeps)
# su-dummy-c installed explicitly
#
# It also pre-wires /etc/pacman.d/ignorepkg (empty) and the matching
# "Include" line inside the [options] section of /etc/pacman.conf, i.e. the
# same layout that pman expects (IgnorePkg = <pkg> lines).
#
# Run as root inside an Arch container:
# docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/fixture.sh
#
# Environment (all optional):
# FIXTURE_BUILDER tar (default) | makepkg
# tar : hand-built .pkg.tar.zst (.PKGINFO + bsdtar). No network, no
# fakeroot, no build user. ~0.1 s.
# makepkg : real makepkg run as the unprivileged user "su-builder" via
# runuser. Needs fakeroot (installed with `pacman -Sy fakeroot`
# if missing, so it needs network). Slower.
# FIXTURE_ISOLATE 0 (default) | 1
# 1 comments out every repo section except [su-dummy], so `pacman -Qu`
# reports EXACTLY the three dummy packages (no real upgrades, no network
# needed after the fixture is built).
# FIXTURE_STRICT_DEP 0 (default) | 1
# 1 makes su-dummy-a 2.0 depend on "su-dummy-b>=2.0" (instead of plain
# "su-dummy-b"), so excluding su-dummy-b from an upgrade (--ignore /
# IgnorePkg) while upgrading su-dummy-a fails with unsatisfied deps.
# FIXTURE_REPO repo directory (default /srv/su-repo)
# FIXTURE_SYNC 0 (default) | 1 -> run `pacman -Sy` at the end
#
# The fixture deliberately does NOT sync by default: the tool under test is
# expected to perform the refresh itself (pacman -Sy), after which
# `pacman -Qu` lists su-dummy-{a,b,c} (1.0-1 -> 2.0-1).
export LC_ALL=C
FIXTURE_BUILDER=${FIXTURE_BUILDER:-tar}
FIXTURE_ISOLATE=${FIXTURE_ISOLATE:-0}
FIXTURE_STRICT_DEP=${FIXTURE_STRICT_DEP:-0}
FIXTURE_REPO=${FIXTURE_REPO:-/srv/su-repo}
FIXTURE_SYNC=${FIXTURE_SYNC:-0}
FIXTURE_REPO_NAME=su-dummy
FIXTURE_EPOCH=1700000000 # fixed build date => reproducible tarballs
fx_log() { printf 'fixture: %s\n' "$*" >&2; }
fx_die() { fx_log "ERROR: $*"; exit 1; }
# pacman wrapper: stdout dropped, the harmless "database file for X does not
# exist" warnings (fresh image, no sync yet) filtered, real errors kept, exit
# status preserved.
fx_pacman() {
local rc=0
pacman "$@" 2>&1 >/dev/null | { grep -v "^warning: database file for" >&2 || true; } || rc=$?
return $rc
}
# fx_build_tar <outdir> <name> <pkgver> <pkgdesc> [depend...]
# Hand-built package: only a .PKGINFO and the payload are required by pacman -U
# and repo-add (no .MTREE / .BUILDINFO needed; `pacman -Qk` is happy too).
fx_build_tar() {
local out=$1 name=$2 ver=$3 desc=$4; shift 4
local w payload
w=$(mktemp -d)
payload="usr/share/su-dummy/$name"
mkdir -p "$w/$payload"
printf '%s\n' "$ver" >"$w/$payload/VERSION"
{
printf 'pkgname = %s\n' "$name"
printf 'pkgbase = %s\n' "$name"
printf 'pkgver = %s-1\n' "$ver"
printf 'pkgdesc = %s\n' "$desc"
printf 'url = https://example.invalid/su-dummy\n'
printf 'builddate = %s\n' "$FIXTURE_EPOCH"
printf 'packager = su-fixture <su-fixture@example.invalid>\n'
printf 'size = %s\n' "$(wc -c <"$w/$payload/VERSION")"
printf 'arch = any\n'
printf 'license = custom\n'
local d
for d in "$@"; do printf 'depend = %s\n' "$d"; done
} >"$w/.PKGINFO"
(cd "$w" && bsdtar --zstd --uid 0 --gid 0 --uname root --gname root \
-cf "$out/$name-$ver-1-any.pkg.tar.zst" .PKGINFO usr)
rm -rf "$w"
}
# fx_build_makepkg <outdir> <name> <pkgver> <pkgdesc> [depend...]
# Real makepkg. makepkg refuses to run as root, so run it as an unprivileged
# user with `runuser -u` (no sudo needed; `su -s /bin/bash -c` also works).
fx_build_makepkg() {
local out=$1 name=$2 ver=$3 desc=$4; shift 4
local w depends="" d
for d in "$@"; do depends+="'$d' "; done
w=/home/su-builder/build/$name-$ver
mkdir -p "$w"
cat >"$w/PKGBUILD" <<EOF
pkgname=$name
pkgver=$ver
pkgrel=1
pkgdesc='$desc'
arch=('any')
url='https://example.invalid/su-dummy'
license=('custom')
options=('!debug' '!strip') # arch=any payload: no debugedit/binutils needed
depends=($depends)
package() {
install -Dm644 /dev/stdin "\$pkgdir/usr/share/su-dummy/\$pkgname/VERSION" <<<"\$pkgver"
}
EOF
chown -R su-builder: "$w"
runuser -u su-builder -- env \
PKGDEST="$out" SOURCE_DATE_EPOCH="$FIXTURE_EPOCH" \
PACKAGER='su-fixture <su-fixture@example.invalid>' \
bash -c "cd '$w' && makepkg --force --nodeps --noconfirm --nocolor >/dev/null 2>&1" \
|| { runuser -u su-builder -- bash -c "cd '$w' && makepkg --force --nodeps --noconfirm --nocolor" >&2; fx_die "makepkg failed for $name"; }
}
fx_prepare_makepkg() {
if ! command -v fakeroot >/dev/null; then
fx_log "makepkg builder: installing fakeroot (needs network)"
fx_pacman -Sy --noconfirm --needed fakeroot
fi
id su-builder >/dev/null 2>&1 || useradd --create-home su-builder
}
fx_build() { # fx_build <outdir> <name> <ver> <desc> [dep...]
case $FIXTURE_BUILDER in
tar) fx_build_tar "$@" ;;
makepkg) fx_build_makepkg "$@" ;;
*) fx_die "unknown FIXTURE_BUILDER=$FIXTURE_BUILDER" ;;
esac
}
fx_main() {
set -euo pipefail
[ "$(id -u)" -eq 0 ] || fx_die "must run as root"
local old new
old=$(mktemp -d); new=$FIXTURE_REPO
rm -rf "$FIXTURE_REPO"; mkdir -p "$FIXTURE_REPO"
chmod 755 "$old"
if [ "$FIXTURE_BUILDER" = makepkg ]; then
fx_prepare_makepkg
chown su-builder: "$old" "$new" # PKGDEST dirs must be writable by the build user
fi
# 1.0 packages (installed) and 2.0 packages (offered by the repo)
fx_build "$old" su-dummy-a 1.0 'Skeptical-updater dummy package A' su-dummy-b
fx_build "$old" su-dummy-b 1.0 'Skeptical-updater dummy package B (dependency of A)'
fx_build "$old" su-dummy-c 1.0 'Skeptical-updater dummy package C (independent)'
local a2_dep=su-dummy-b
[ "$FIXTURE_STRICT_DEP" = 1 ] && a2_dep='su-dummy-b>=2.0'
fx_build "$new" su-dummy-a 2.0 'Skeptical-updater dummy package A' "$a2_dep"
fx_build "$new" su-dummy-b 2.0 'Skeptical-updater dummy package B (dependency of A)'
fx_build "$new" su-dummy-c 2.0 'Skeptical-updater dummy package C (independent)'
# Local repository database (repo-add ships with pacman)
(cd "$FIXTURE_REPO" && repo-add --quiet "$FIXTURE_REPO_NAME.db.tar.gz" ./*.pkg.tar.zst)
chmod -R a+rX "$FIXTURE_REPO"; chmod a+rx /srv 2>/dev/null || true
# pacman.conf: register the repo (appended at the end => lowest priority)
if ! grep -q "^\[$FIXTURE_REPO_NAME\]" /etc/pacman.conf; then
{
printf '\n[%s]\n' "$FIXTURE_REPO_NAME"
printf 'SigLevel = Optional TrustAll\n'
printf 'Server = file://%s\n' "$FIXTURE_REPO"
} >>/etc/pacman.conf
fi
# pman-style ignore file, included from the [options] section
# (inserted right after the FIRST "[options]" header; appending at EOF would
# land in whatever section happens to be last).
: >/etc/pacman.d/ignorepkg
if ! grep -q '^Include *= */etc/pacman.d/ignorepkg' /etc/pacman.conf; then
sed -i '0,/^\[options\]/s||[options]\nInclude = /etc/pacman.d/ignorepkg|' /etc/pacman.conf
fi
if [ "$FIXTURE_ISOLATE" = 1 ]; then
# comment out all repos except [su-dummy]; keep [options] intact
awk -v keep="$FIXTURE_REPO_NAME" '
/^\[.*\][[:space:]]*$/ { sec=$0; sub(/^\[/,"",sec); sub(/\].*$/,"",sec); active = (sec=="options" || sec==keep) }
{ if (sec != "" && !active && $0 !~ /^[[:space:]]*#/ && $0 !~ /^[[:space:]]*$/) print "#" $0; else print }
' /etc/pacman.conf >/etc/pacman.conf.new && mv /etc/pacman.conf.new /etc/pacman.conf
fi
# Install the 1.0 packages: b as a dependency, a and c explicit.
fx_pacman -U --noconfirm "$old"/su-dummy-a-1.0-1-any.pkg.tar.zst \
"$old"/su-dummy-b-1.0-1-any.pkg.tar.zst \
"$old"/su-dummy-c-1.0-1-any.pkg.tar.zst
fx_pacman -D --asdeps su-dummy-b
rm -rf "$old"
if [ "$FIXTURE_SYNC" = 1 ]; then fx_pacman -Sy; fi
fx_log "builder=$FIXTURE_BUILDER isolate=$FIXTURE_ISOLATE repo=$FIXTURE_REPO"
fx_log "installed: $(pacman -Q su-dummy-a su-dummy-b su-dummy-c 2>/dev/null | tr '\n' ';')"
fx_log "repo list: $(pacman-conf --repo-list | tr '\n' ' ')"
}
# Run when executed; do nothing when sourced (so tests can reuse fx_build_tar etc.
# without inheriting `set -e`).
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
fx_main "$@"
fi
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# pacman helper functions for skeptical-updater (proven on pacman 7.1.0 /
# archlinux:latest by discovery/pacman/checks.sh). Source this file.
#
# Conventions
# * every pacman call runs with LC_ALL=C (stable field names / messages) and
# COLUMNS=0 (never wrap -Qi/-Si output, even if COLUMNS is exported).
# * PM_OPTS is an optional array of extra global pacman options, e.g.
# PM_OPTS=(--dbpath /tmp/checkdb) to work against a temporary sync DB.
# * nothing here needs pacman-contrib, sudo, python or fakeroot.
PM_OPTS=("${PM_OPTS[@]}")
_pm() { LC_ALL=C COLUMNS=0 pacman "${PM_OPTS[@]}" "$@"; }
# ---------------------------------------------------------------- refresh ---
# Refresh the real sync DBs (root). Safe as long as it is followed by -Su
# (never `pacman -S foo` after a bare -Sy: that is the partial-upgrade case).
pm_refresh() { LC_ALL=C pacman -Sy "$@"; }
# Refresh a THROWAWAY sync DB (root, no fakeroot/pacman-contrib needed) and
# point PM_OPTS at it; the system DB under /var/lib/pacman/sync is untouched.
# The temp dir must be world-traversable (mode 755) because the sandboxed
# download user "alpm" has to write into it, mktemp -d's 0700 is not enough.
pm_refresh_tmpdb() {
local db
db=$(mktemp -d /tmp/su-checkdb.XXXXXX) || return 1
chmod 755 "$db"
ln -s "$(pacman-conf DBPath)local" "$db/local" || return 1
LC_ALL=C pacman -Sy --dbpath "$db" --logfile /dev/null >/dev/null || return 1
PM_OPTS=(--dbpath "$db")
PM_TMPDB=$db
}
# ------------------------------------------------------------ upgradable ---
# name<TAB>oldversion<TAB>newversion<TAB>repo<TAB>ignored(0|1)
# -Qu line : "name old -> new" or "name old -> new [ignored]"
# -Sl line : "repo name version [installed[: oldver]]"
# The candidate repo is the FIRST repo (config order) that carries the name,
# which is what both `-Qu` and `-S name` pick (verified with a dup in 2 repos).
# Prints nothing (and returns 1) when nothing is upgradable, like `pacman -Qu`.
pm_upgradable() {
local out
out=$(awk '
FNR == NR { if (!($2 in repo)) repo[$2] = $1; next }
{ printf "%s\t%s\t%s\t%s\t%d\n", $1, $2, $4, repo[$1], ($5 == "[ignored]") }
' <(_pm -Sl 2>/dev/null) <(_pm -Qu 2>/dev/null))
[ -n "$out" ] || return 1
printf '%s\n' "$out"
}
# Effective IgnorePkg patterns (globs allowed), wherever they are configured
# (pacman.conf or an Include such as /etc/pacman.d/ignorepkg).
pm_ignored_patterns() { pacman-conf IgnorePkg; }
# ------------------------------------------------------------------ info ---
# pm_field <-Qi|-Si> <Field> <target>...
# -> "<name>\t<value>" per package; wrapped continuation lines are joined with
# one space. Use "repo/name" targets with -Si (a name in 2 repos prints twice).
# List fields keep pacman's two-space separators (split with `tr -s ' ' '\n'`);
# "None" is returned verbatim. Missing targets are skipped (the pacman error
# goes to /dev/null).
pm_field() {
local op=$1 field=$2; shift 2
_pm "$op" -- "$@" 2>/dev/null | awk -v f="$field" '
function flush() { if (name != "") print name "\t" val; name = ""; val = ""; infield = 0 }
/^[ \t]*$/ { flush(); next }
/^[^ \t]/ {
infield = 0
i = index($0, ":"); k = substr($0, 1, i - 1); sub(/[ \t]+$/, "", k)
v = substr($0, i + 1); sub(/^[ \t]+/, "", v)
if (k == "Name") name = v
else if (k == f) { val = v; infield = 1 }
next
}
infield { sub(/^[ \t]+/, ""); val = val " " $0 }
END { flush() }'
}
# One-line description of the installed package / of the upgrade candidate.
pm_desc_local() { pm_field -Qi Description "$@"; } # name<TAB>desc
pm_desc_cand() { pm_field -Si Description "$@"; } # args: repo/name
# Full dumps (verbatim pacman output, never wrapped).
pm_info_local() { _pm -Qi -- "$@"; }
pm_info_cand() { _pm -Si -- "$@"; } # args: repo/name
# Installed reverse dependencies as plain names, one per line (sorted).
pm_required_by() { pm_field -Qi 'Required By' "$1" | cut -f2- | tr -s ' ' '\n' | grep -vxE 'None|' | sort -u; }
pm_optional_for() { pm_field -Qi 'Optional For' "$1" | cut -f2- | tr -s ' ' '\n' | grep -vxE 'None|' | sort -u; }
# ------------------------------------------------------------ dependency ---
# pm_closure [-r] [-o] <pkg-or-dep>...
# Recursive closure over INSTALLED packages, one real package name per line,
# sorted, roots excluded.
# default : forward closure (Depends On, with provides/soname/version
# constraints resolved: `libfoo.so=1-64`, `glibc>=2.38`, `sh`)
# -r : reverse closure (Required By)
# -o : with -r also follow "Optional For"
# Roots may be package names or provided names. One `pacman -Qi` call,
# one awk process.
pm_closure() {
local mode=deps opt=0
while [ $# -gt 0 ]; do
case $1 in -r) mode=rdeps ;; -o) opt=1 ;; --) shift; break ;; *) break ;; esac
shift
done
_pm -Qi 2>/dev/null | awk -v roots="$*" -v mode="$mode" -v opt="$opt" '
function norm(s) { gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return (s == "None") ? "" : s }
function flush( c, a, i, p) {
if (name == "") return
real[name] = 1; dep[name] = norm(F["Depends On"]); rb[name] = norm(F["Required By"])
of[name] = norm(F["Optional For"]); pv[name] = norm(F["Provides"])
name = ""; delete F; cur = ""
}
function resolve(d, b) { b = d; sub(/[<>=].*/, "", b); if (b in real) return b; return prov[b] }
/^[ \t]*$/ { flush(); next }
/^[^ \t]/ { i = index($0, ":"); k = substr($0, 1, i - 1); sub(/[ \t]+$/, "", k)
v = substr($0, i + 1); sub(/^[ \t]+/, "", v); cur = k; F[k] = v
if (k == "Name") name = v; next }
{ sub(/^[ \t]+/, ""); F[cur] = F[cur] " " $0 }
END {
flush()
for (n in real) { c = split(pv[n], a, " "); for (i = 1; i <= c; i++) { p = a[i]; sub(/[<>=].*/, "", p); prov[p] = (p in prov) ? prov[p] " " n : n } }
nr = split(roots, r, " "); head = 0; tail = 0
for (i = 1; i <= nr; i++) { m = split(resolve(r[i]), a, " "); for (j = 1; j <= m; j++) if (!(a[j] in seen)) { seen[a[j]] = 1; isroot[a[j]] = 1; q[++tail] = a[j] } }
while (head < tail) {
n = q[++head]
if (mode == "deps") { lst = dep[n]; c = split(lst, a, " "); for (i = 1; i <= c; i++) { m = split(resolve(a[i]), b, " "); for (j = 1; j <= m; j++) if (!(b[j] in seen)) { seen[b[j]] = 1; q[++tail] = b[j] } } }
else { lst = rb[n] (opt ? " " of[n] : ""); c = split(lst, a, " "); for (i = 1; i <= c; i++) if (!(a[i] in seen)) { seen[a[i]] = 1; q[++tail] = a[i] } }
}
for (n in seen) if (!(n in isroot)) print n | "sort"
}'
}
# ------------------------------------------------------- apply / dry-run ---
# Join array elements with commas (for --ignore a,b,c).
_pm_join() { local IFS=,; printf '%s' "$*"; }
# pm_plan [pkg-to-skip...] -> "repo/name newversion" per package the upgrade
# would install (dependency order, includes NEW dependencies and respects
# IgnorePkg). Exit 1 when the exclusions make the transaction unsatisfiable.
# Needs a synced DB (run pm_refresh first); does not need root.
pm_plan() {
local ign=() out err rc
[ $# -gt 0 ] && ign=(--ignore "$(_pm_join "$@")")
err=$(mktemp)
out=$(_pm -Sup --noconfirm "${ign[@]}" --print-format '%r/%n %v' </dev/null 2>"$err"); rc=$?
if [ "$rc" -eq 0 ]; then [ -z "$out" ] || printf '%s\n' "$out"
else printf '%s\n' "$out" >&2; cat "$err" >&2; fi
rm -f "$err"; return "$rc"
}
# pm_apply [pkg-to-skip...] (root) non-interactive upgrade of the already
# refreshed DB. Use `-Su`, not `-Syu`, to apply exactly what was reviewed.
pm_apply() {
local ign=()
[ $# -gt 0 ] && ign=(--ignore "$(_pm_join "$@")")
LC_ALL=C pacman -Su --noconfirm "${ign[@]}"
}
# pm_purge <pkg>... (root) -Rns: also removes now-unneeded deps + config backups
pm_purge() { LC_ALL=C pacman -Rns --noconfirm -- "$@"; }