Files
bryanpedini 3af159e09a fix(show): star marker, aligned gutter and smarter AUR merge
N?? / show marks the upstream candidate with "*" instead of ">" (the
prompt, which read as a command in logs), and every line but the note
gets a two-character gutter: "  " for the installed record, "* " for the
candidate, so keys and colons line up. The plain native fallback (zypper,
unmergeable records) and the not-installed candidate use the same gutter.

pacman records (-Qi with -Si or the AUR helper's -Sia) are now compared
the way they are meant:
- values unwrapped (continuation lines joined, runs of blanks as one):
  yay wraps Depends On / Optional Deps at 80 columns even without a
  terminal unless COLUMNS=0, which the helper now gets for -Qua and -Sia
- Depends On, Provides, Conflicts With, Make/Check Deps and the other
  lists as sets of exact tokens in any order (a constraint is a change)
- Optional Deps by package names ("name: description [installed]" in
  -Qi, bare names from the AUR)
- a differing value is one line like -Qi, Optional Deps one entry per
  line; candidate-only fields sit next to their counterpart (Repository
  after Version, AUR URL after URL, Maintainer after Packager, ...)

Verified on recorded claude-desktop records (paru 2.1.0, yay 13.0.1, the
installed 2.9939.4-1 from the AUR's own .SRCINFO); discovery/pacman.md
section 15.2.
2026-10-10 16:01:51 +02:00

45 KiB

Discovery: pacman (Arch Linux) and pman

Everything below was run in throwaway containers (docker run --rm -i, LC_ALL=C) on 2026-10-09 against archlinux:latest (pacman 7.1.0 / libalpm 16.0.1, bash 5.3.20). Companion files (all in discovery/pacman/):

file purpose
fixture.sh E2E fixture: dummy packages su-dummy-{a,b,c} 1.0 installed, 2.0 offered by a local repo
lib.sh proven bash helpers (pm_upgradable, pm_field, pm_closure, pm_plan, ...)
checks.sh executable proof of this document (ALL CHECKS PASSED on archlinux:latest and archlinux:base-devel)
aur.sh proof of section 15 (paru and yay output formats; needs network: Arch mirrors, the AUR, GitHub)

Re-run the proof:

docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/fixture.sh
docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/checks.sh

0. Summary / chosen commands

need command
refresh (root) pacman -Sy (then apply with pacman -Su, see section 1)
upgradable list pacman -Qu -> name old -> new [ignored]; repo from pacman -Sl (first repo with that name wins)
exact transaction (incl. new deps) pacman -Sup --noconfirm [--ignore a,b] --print-format '%r/%n %v'
description pacman -Qi -- name / pacman -Si -- repo/name, field Description
installed reverse deps pacman -Qi fields Required By / Optional For
dependency closure one pacman -Qi dump + awk BFS (pm_closure); pactree is not in the base image
per-run exclusion pacman -Su --noconfirm --ignore a,b (or repeated --ignore); globs work
upgrade pacman -Su --noconfirm (-Syu if you did not refresh before)
purge pacman -Rns --noconfirm -- pkg...
AUR (paru / yay, section 15) list <helper> -Qua, record <helper> -Sia PKG, upgrade <helper> -Sua [--noconfirm] [--ignore a,b], never as root

Top gotchas (details below):

  1. The fresh image has no sync databases: pacman -Si/-Sl/-Qu are useless (and -S bats says "not found") until pacman -Sy has run.
  2. pacman -Qu has no --ignore (error: invalid option '--ignore', exit 1). Per-run exclusions must be filtered by the tool itself; [ignored] is only shown for IgnorePkg/IgnoreGroup from the config.
  3. pacman -Qu exits 1 when nothing is upgradable (empty output); -Su/-Syu exit 0 with there is nothing to do.
  4. Excluding a package that another upgraded package needs at a higher version makes the whole transaction fail under --noconfirm (exit 1, nothing upgraded). Pre-check with pacman -Sup.
  5. -Qi/-Si wrap long values at $COLUMNS (even when piped) if COLUMNS is exported. Use COLUMNS=0 and a continuation-aware parser (lib.sh does both).
  6. pactree -l -u prints dependency strings (libncursesw.so=6-64, linux-api-headers>=4.10), not package names, and pacman-contrib is not installed.
  7. The image's /etc/pacman.conf ends with a second [options] block (NoExtract lines). Appending Include = /etc/pacman.d/ignorepkg at EOF works by accident here; on a stock Arch host the last section is [extra] and pacman just warns directive 'IgnorePkg' in section 'extra' not recognized. and ignores nothing.
  8. makepkg refuses root (exit 10); runuser -u <user> -- works, sudo is absent.

1. Refreshing metadata

$ pacman -Sy                 # root; image has only /var/lib/pacman/{local,sync(empty)} before this
:: Synchronizing package databases...
 core downloading...
 extra downloading...
exit=0

Timing: cold -Sy 0.21-0.25 s, warm 0.12 s (fast CDN; core.db 130 KB, extra.db 8.8 MB). A second -Sy prints the same "downloading..." lines (NoProgressBar), so the output cannot tell "up to date" from "refreshed".

On a fresh image (no -Sy):

$ pacman -Qu ; echo $?
warning: database file for 'core' does not exist (use '-Sy' to download)
warning: database file for 'extra' does not exist (use '-Sy' to download)
1
$ pacman -Si bats
error: package 'bats' was not found

How many upgrades does a fresh image have? Today only 6 (image built 5 days earlier):

$ pacman -Sy && pacman -Qu
ca-certificates-mozilla 3.130-1 -> 3.131-1
device-mapper 2.03.42-1 -> 2.03.43-2
expat 2.8.5-1 -> 2.9.0-1
glib2 2.88.3-1 -> 2.90.1-1
hwdata 0.411-1 -> 0.412-1        (+ libsysprof-capture)

The count is time-dependent; tests must not assert on it. Use FIXTURE_ISOLATE=1 (section 10) for an exact set.

Partial-upgrade concern

pacman -Sy alone moves the sync DB ahead of the installed packages. That is only dangerous if you then install something (pacman -S foo) without a full upgrade; a -Sy immediately followed by -Su is the same as -Syu. Two safe designs:

  • A (recommended, simple): pacman -Sy, list with -Qu, let the user exclude, then apply with pacman -Su --noconfirm --ignore ... (note -Su, not -Syu: it applies exactly the reviewed DB snapshot; -Syu would re-sync and could add newer, unreviewed versions). If the user aborts after the refresh, tell them the DB is refreshed (do not run pacman -S x without -u).

  • B (no side effects, root, no extra packages): temporary sync DB. Verified:

    tmp=$(mktemp -d /tmp/checkdb.XXXXXX); chmod 755 "$tmp"          # 755 is REQUIRED (see below)
    ln -s /var/lib/pacman/local "$tmp/local"
    pacman -Sy --dbpath "$tmp" --logfile /dev/null                  # exit 0
    pacman -Qu --dbpath "$tmp"                                      # same list as design A
    pacman -Sup --dbpath "$tmp" --print-format '%r/%n %v'           # same transaction view
    ls /var/lib/pacman/sync                                         # still empty: system DB untouched
    

    With the default mktemp -d mode 0700 it fails: error: could not open file /tmp/checkdb.X/sync/download-XXXX/core.db.part: Permission denied (the sandboxed download user alpm cannot traverse it); chmod 755 or --disable-sandbox fixes it. When applying, use -Syu (re-sync) since the real DB was not refreshed. lib.sh: pm_refresh_tmpdb sets PM_OPTS=(--dbpath ...).

  • checkupdates (pacman-contrib) is NOT in the base image (archlinux:latest and :base-devel both lack it and pactree), and it hard-requires fakeroot (Cannot find the fakeroot binary; also absent from latest, present in base-devel) even as root. After pacman -S pacman-contrib fakeroot it works: prints the same -Qu lines (drops [ignored] rows, per its source grep -v '\[.*\]'), exit 0 = updates (verified), 2 = none (verified), 1 = error (from the source: Cannot fetch updates). It is just B with fakeroot instead of chmod 755; prefer B.

pacman -Qu and pacman -Sup need no root (verified as a normal user, exit 0), only the refresh and the real upgrade do.

2. Machine-parsable upgradable list

pacman -Qu (piped: no colour even with Color in pacman.conf; add --color never to be safe):

$ pacman -Qu
su-dummy-a 1.0-1 -> 2.0-1
su-dummy-b 1.0-1 -> 2.0-1
su-dummy-c 1.0-1 -> 2.0-1
$ pacman -Qu -q          # names only

With IgnorePkg (from Include = /etc/pacman.d/ignorepkg inside [options], one IgnorePkg = x per line, several names per line allowed, globs allowed):

$ pacman -Qu
ca-certificates-mozilla 3.130-1 -> 3.131-1
expat 2.8.5-1 -> 2.9.0-1 [ignored]
glib2 2.88.3-1 -> 2.90.1-1 [ignored]      # after "IgnorePkg = glib2 hwdata"

Format: name SP oldver SP "->" SP newver [SP "[ignored]"]; versions contain no spaces (epochs look like 1:1.37.2-3). Exit 0 if >=1 line, exit 1 and empty output otherwise ([ignored] rows count as lines, so exit stays 0 when only ignored rows remain).

Repo of the candidate: -Qu does not print it. Use pacman -Sl (repo name version [installed[: oldver]]):

$ pacman -Sl | grep -E ' (expat|glib2) '
core expat 2.9.0-1 [installed: 2.8.5-1]
core glib2 2.90.1-1 [installed: 2.88.3-1]

A name can be in several repos. Verified with su-dummy-b 2.0-1 in [su-dummy] and 3.0-1 in a later [su-dummy2]: -Qu reports 1.0-1 -> 2.0-1 (the first repo in pacman.conf order wins), -Sl lists both rows in config order, and -Si su-dummy-b prints both blocks, so take the first -Sl row per name and use repo/name for -Si. Exact parse (this is lib.sh: pm_upgradable, output name<TAB>old<TAB>new<TAB>repo<TAB>ignored(0|1)):

awk '
  FNR == NR { if (!($2 in repo)) repo[$2] = $1; next }                    # pacman -Sl
  { printf "%s\t%s\t%s\t%s\t%d\n", $1, $2, $4, repo[$1], ($5 == "[ignored]") }   # pacman -Qu
' <(LC_ALL=C pacman -Sl) <(LC_ALL=C pacman -Qu)
su-dummy-a	1.0-1	2.0-1	su-dummy	0
ca-certificates-mozilla	3.130-1	3.131-1	core	0

Alternative that also yields the repo directly and is authoritative for what -Su will do: pacman -Sup --print-format '%r|%n|%v|%s|%l' (%s download size, %l URL; works as non-root; dependency order; includes new dependencies and skips ignored packages, which -Qu does not):

$ pacman -Sup --print-format '%r|%n|%v|%s'
core|ca-certificates-mozilla|3.131-1|391549
...

Verified with su-dummy-a 3.0 depending on a brand-new su-dummy-new: -Qu listed 3 packages, -Sup listed 4 (su-dummy-new 1.0-1 appears before su-dummy-a), and the new package is installed "as a dependency". So a skeptical UI should show -Qu rows plus "also installs: ..." from -Sup minus -Qu. Not covered by -Qu: packages pulled in via replaces.

3. Description and full info

$ pacman -Qi bash | head -5
Name            : bash
Version         : 5.3.20-1
Description     : The GNU Bourne Again shell
Architecture    : x86_64
URL             : https://www.gnu.org/software/bash/bash.html
$ pacman -Si su-dummy/su-dummy-a | head -4        # the candidate (new version)
Repository      : su-dummy
Name            : su-dummy-a
Version         : 2.0-1
Description     : Skeptical-updater dummy package A
  • Installed: pacman -Qi -- name; candidate: pacman -Si -- repo/name (always qualify with the repo, see the two-repo note above). -Si has Repository, Download Size, Build Date, new Depends On; it has no Required By/Install Reason (those only exist for -Qi).

  • Description only (simple, single target, piped, no COLUMNS exported): pacman -Qi -- bash | sed -n 's/^Description *: //p'.

  • Robust version (any number of targets, wrapped lines joined, field split at the first colon) is lib.sh: pm_field -Qi|-Si Field target... -> name<TAB>value:

    pm_field() { local op=$1 field=$2; shift 2
      LC_ALL=C COLUMNS=0 pacman "$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() }'; }
    

    pm_field -Qi Description bash -> bash<TAB>The GNU Bourne Again shell; also unaffected by COLUMNS=20 (verified: raw COLUMNS=20 pacman -Qi bash does wrap).

  • Full dump = the raw command output, never wrapped when COLUMNS=0 or unset and stdout is not a tty (wrapping is driven by the COLUMNS env var, not by a real tty: stty cols 100 + pipe did not wrap, COLUMNS=80 + pipe did). Optional Deps is the exception: one entry per line (continuation lines are new entries, not wraps):

    Optional Deps   : diffutils: for pacdiff
                      fakeroot: for checkupdates
    
  • Missing target: error: package 'x' was not found on stderr, exit 1, other targets still printed.

4. Installed reverse dependencies

pacman -Qi fields (names only, no versions, two-space separated, None when empty; computed over installed packages and already resolved through provides, e.g. bash is listed as a dependent of readline although it depends on libreadline.so=8-64):

$ pacman -Qi bash | grep -E '^(Required By|Optional For)'
Required By     : audit  base  bzip2  ca-certificates-utils  device-mapper  e2fsprogs  gawk  ...
Optional For    : ncurses  pcre2  tzdata
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; }

Proven: pm_required_by su-dummy-b -> su-dummy-a; pm_required_by su-dummy-a -> empty; pm_optional_for bash -> ncurses pcre2 tzdata; pm_required_by glibc | wc -l -> 111 (a 1021-char line). Note -Si does not have these fields; for the candidate, reverse deps = the installed ones.

5. Recursive dependency closure restricted to installed packages

Premise check: pacman -Qq/-Qi/-Q DO resolve provides and version-constrained dependency strings in pacman 7.1 (they use the satisfier lookup), so no special handling is needed on this version:

$ pacman -Qq sh                    -> bash          (exit 0)
$ pacman -Qq 'glibc>=2.38'         -> glibc         (exit 0)
$ pacman -Qq libreadline.so=8-64   -> readline      (exit 0)
$ pacman -Qq 'glibc>=99'           -> error: package 'glibc>=99' was not found   (exit 1)
$ pacman -T sh ; echo $?           -> (nothing) 0   ;  pacman -T nonexist -> "nonexist", exit 127
$ pacman -Qqs '^sh$'               -> bash          (-Qs also searches provides, but it is a regex search; do not use)

(Unquoted >= is a shell redirect, always quote dependency strings. I did not verify older pacman releases, so pm_closure below does not rely on this.)

pactree (pacman-contrib, absent from latest and base-devel): after installing it, pactree -l -u bash prints dependency strings, not names:

$ pactree -l -u bash
bash
readline
glibc
linux-api-headers>=4.10
tzdata
filesystem
iana-etc
libncursesw.so=6-64          <- provided name, the package "ncurses" itself never appears
libgcc
libstdc++
$ pactree -l -u pacman | wc -l     -> 108   (contains libcrypt.so=2-64, libp11-kit=0.26.5-1, ca-certificates-utils>=20181109-3, ...)

So to use it you must resolve every line: pactree -l -u P | xargs -r pacman -Qq -- | sort -u (works on 7.1). pactree -r -l -u P is the reverse closure. pactree -l -u nonexist -> error: package 'nonexist' not found, exit 1.

Pure-pacman implementation (lib.sh: pm_closure): a single pacman -Qi dump (all installed packages, ~10 ms) parsed by one awk process that builds name, Provides -> provider map, Depends On, Required By, Optional For, then BFS. A dependency string d is resolved as: strip the constraint (sub(/[<>=].*/,"")); if it is a real installed package use it; otherwise use every installed package whose Provides has that base name. Usage:

pm_closure bash            # forward closure, roots excluded, sorted, one real name per line
pm_closure -r glibc        # reverse closure via "Required By" (add -o to follow "Optional For" too)
pm_closure sh              # roots may be provided names  -> same as bash

Sample: pm_closure bash -> filesystem glibc iana-etc libgcc libstdc++ linux-api-headers ncurses readline tzdata (9 packages; pactree printed 10 strings, including libncursesw.so=6-64 for ncurses). Proof (checks.sh, all PASS): for bash (9), pacman (107) and su-dummy-a (1), the output equals pactree -l -u P | xargs -r pacman -Qq -- | sort -u minus the root; reverse closures of glibc and bash equal pactree -r -l -u. If several installed packages provide the same name the closure is a superset of pactree's single pick (does not happen in the base image). Optional deps are excluded (as in pactree's default).

6. Per-run exclusion

Both forms work (verified, --noconfirm, fixture):

pacman -Su --noconfirm --ignore su-dummy-a,su-dummy-c            # comma separated
pacman -Su --noconfirm --ignore su-dummy-a --ignore su-dummy-c   # repeated
pacman -Su --noconfirm --ignore 'su-dummy-[ab]'                  # globs work, like IgnorePkg

Result: exit=0, only su-dummy-b upgraded; the warning goes to stderr:

warning: su-dummy-a: ignoring package upgrade (1.0-1 => 2.0-1)
warning: su-dummy-c: ignoring package upgrade (1.0-1 => 2.0-1)

Ignoring everything: stdout :: Starting full system upgrade... + there is nothing to do, warnings on stderr, exit 0.

  • pacman -Qu --ignore x is invalid (error: invalid option '--ignore', exit 1): [ignored] marks only IgnorePkg/IgnoreGroup from the configuration. Effective list wherever it was configured: pacman-conf IgnorePkg (one pattern per line; IgnorePkg = glib2 hwdata -> two lines).

  • Include placement: Include = /etc/pacman.d/ignorepkg must be inside [options]. Inserted right after the first header with sed -i '0,/^\[options\]/s||[options]\nInclude = /etc/pacman.d/ignorepkg|' /etc/pacman.conf (that is what fixture.sh does). Appended at EOF of a stock config (last section [extra]): warning: config file /etc/pacman.d/ignorepkg, line 1: directive 'IgnorePkg' in section 'extra' not recognized. and the package is still upgraded. After writing, verify with pacman-conf IgnorePkg.

  • Dependency trap (FIXTURE_STRICT_DEP=1: su-dummy-a 2.0 needs su-dummy-b>=2.0), --ignore su-dummy-b (or IgnorePkg = su-dummy-b):

    warning: su-dummy-b: ignoring package upgrade (1.0-1 => 2.0-1)
    warning: cannot resolve "su-dummy-b>=2.0", a dependency of "su-dummy-a"
    :: The following package cannot be upgraded due to unresolvable dependencies:
          su-dummy-a
    :: Do you want to skip the above package for this upgrade? [y/N]
    :: unable to satisfy dependency 'su-dummy-b>=2.0' required by su-dummy-a
    error: failed to prepare transaction (could not satisfy dependencies)       exit=1
    

    With --noconfirm the default answer is N: the whole run aborts and nothing (not even su-dummy-c) is upgraded. Same for excluding a new dependency (--ignore su-dummy-new). Pre-check without root/side effects: pacman -Sup --noconfirm --ignore su-dummy-b --print-format '%r/%n %v' </dev/null -> exit 1, stdout :: unable to satisfy dependency ..., stderr error: failed to prepare transaction ... (lib.sh: pm_plan [skip...]; exit 0 and an empty list when everything is ignored). --ask 16 (verified) answers that prompt "yes": pacman -Su --noconfirm --ask 16 --ignore su-dummy-b exits 0, upgrades only su-dummy-c and silently leaves su-dummy-a behind. Probably not what a skeptical user wants; at most offer it as an explicit choice after showing the pm_plan failure.

7. Non-interactive upgrade, exit codes

$ pacman -Syu --noconfirm; echo $?        # upgrades; prints the ":: Proceed with installation? [Y/n]" line but auto-answers
0
$ pacman -Syu --noconfirm                 # nothing left
:: Synchronizing package databases...
 su-dummy downloading...
:: Starting full system upgrade...
 there is nothing to do                   <- leading space; exit 0
$ pacman -Qu; echo $?                     # nothing upgradable: no output
1
situation exit message
upgrade ok / nothing to do 0 there is nothing to do (stdout)
pacman -Qu nothing upgradable 1 (empty)
not root (-Sy, -Su, -Rns) 1 error: you cannot perform this operation unless you are root.
db locked (/var/lib/pacman/db.lck) 1 error: failed to synchronize all databases (unable to lock database) (-Qu/-Qi still work, exit 0)
excluded dep unsatisfiable 1 see section 6
checkupdates 0 updates / 2 none / 1 error

--noconfirm answers every question with its default: install prompts are Y, but "skip unresolvable package?", conflict removal and HoldPkg prompts are N (they abort with exit 1). Messages are split over stdout and stderr (warning:/error: -> stderr, :: unable to satisfy ... -> stdout), so capture both.

8. Purge

$ pacman -Rns --noconfirm su-dummy-a ; echo $?     # a explicit, b installed --asdeps and only needed by a
removing su-dummy-a...
removing su-dummy-b...                              # -s also removed the now-unneeded dependency
0
$ pacman -Rns --noconfirm su-dummy-b ; echo $?      # required by a
checking dependencies...                            (stdout)
:: removing su-dummy-b breaks dependency 'su-dummy-b' required by su-dummy-a
error: failed to prepare transaction (could not satisfy dependencies)       (stderr)
1                                                   # nothing removed
$ pacman -Rns --noconfirm nope ; echo $?            -> error: target not found: nope      1
$ pacman -Rns --noconfirm su-dummy-c nope           -> same error, exit 1, su-dummy-c NOT removed (all or nothing)
$ pacman -Rns --noconfirm su-dummy-b su-dummy-a     -> 0 (order irrelevant, both removed)
$ pacman -Rns --noconfirm pacman                    -> breaks dependency 'pacman' required by base, exit 1
$ pacman -Rdd --noconfirm pacman                    -> ":: HoldPkg was found in target list. Do you want to continue? [y/N]" exit 1

-R without -s leaves the dependency as an orphan (pacman -Qdtq -> su-dummy-b).

9. Base image facts

item archlinux:latest (= archlinux:base, same manifest digest) archlinux:base-devel
exists yes; also archlinux:multilib-devel yes (pulled for this test, 950 MB vs 399 MB)
bash 5.3.20 5.3.20
pacman 7.1.0 (libalpm 16.0.1) 7.1.0
installed packages 137 170
sudo no yes (/etc/sudoers has only root ALL=(ALL:ALL) ALL)
python3 no (core/python 3.14.7-1, 13.5 MB download) yes
fakeroot no (core/fakeroot 1:1.37.2-3, 77 KB) yes
makepkg yes (/usr/sbin/makepkg) but unusable without fakeroot yes, works
gcc/make/debugedit no yes
pacman-contrib (checkupdates, pactree) no (extra/pacman-contrib 1.13.1-1) no
bats / git no / no no / no
su, runuser, bsdtar, repo-add, pacman-conf, vercmp, curl, awk(gawk) yes yes
default $LANG C.UTF-8 (set LC_ALL=C anyway) same
sync DBs absent until pacman -Sy absent

which is not installed; use command -v. All binaries live in /usr/bin (/usr/sbin is a symlink).

bats: extra/bats 1.14.0-1 (installs parallel, perl, db5.3 as deps). After pacman -Sy: pacman -S --noconfirm bats took 6.3 s, bats --version -> Bats 1.14.0, a smoke test printed 1..1 / ok 1 hello. Without a prior -Sy: pacman -Si bats -> error: package 'bats' was not found. (Installing straight after -Sy works today; the proper form on a stale image is pacman -Syu first.)

makepkg as root (exit 10):

==> ERROR: Running makepkg as root is not allowed as it can cause permanent,
catastrophic damage to your system.

(--asroot no longer exists: makepkg: invalid option '--asroot'.) Recipe, verified in both images (on latest first pacman -Sy --noconfirm fakeroot; the base-devel image already has it):

useradd -m builder
runuser -u builder -- bash -c 'cd /home/builder/pkg && makepkg --force --nodeps --noconfirm'     # exit 0
su -s /bin/bash builder -c 'cd /home/builder/pkg && makepkg --force --nodeps'                    # also works
sudo -u builder ...                                                                              # works in base-devel (verified); sudo does not exist in archlinux:latest

Use PKGDEST=/dir (writable by the build user!) to choose where the .pkg.tar.zst goes and options=('!debug' '!strip') in the PKGBUILD for arch=('any') payloads: without it makepkg 7 aborts on latest with ==> ERROR: Cannot find the debugedit binary required for including source files in debug packages. (debugedit is only in base-devel). --nodeps skips the dependency check (no sudo needed).

10. E2E fixture (discovery/pacman/fixture.sh)

docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/fixture.sh
fixture: builder=tar isolate=0 repo=/srv/su-repo
fixture: installed: su-dummy-a 1.0-1;su-dummy-b 1.0-1;su-dummy-c 1.0-1;
fixture: repo list: core extra su-dummy
exit 0

What it does (root, inside the container):

  1. Builds su-dummy-{a,b,c} at 1.0-1 (to install) and 2.0-1 (to offer). su-dummy-a has depend = su-dummy-b. Each ships /usr/share/su-dummy/<name>/VERSION (contains the version) so tests can check an upgrade really happened. Descriptions: Skeptical-updater dummy package A (B has "(dependency of A)", C "(independent)").
  2. Default builder = tar: hand-made .pkg.tar.zst containing only .PKGINFO + payload (bsdtar --zstd -cf ... .PKGINFO usr). Verified that pacman -U, repo-add, -Qi, -Qk and -Syu accept it without .MTREE/.BUILDINFO. No network, no fakeroot, no build user. FIXTURE_BUILDER=makepkg uses real makepkg as user su-builder via runuser (installs fakeroot with pacman -Sy fakeroot if missing, so network; sets PKGDEST, SOURCE_DATE_EPOCH, options=('!debug' '!strip')). Both verified on latest and base-devel; both produce identical -Qu results.
  3. repo-add creates /srv/su-repo/su-dummy.db.tar.gz (+.db symlink) containing the 2.0 packages only; appends to /etc/pacman.conf:
    [su-dummy]
    SigLevel = Optional TrustAll
    Server = file:///srv/su-repo
    
  4. Installs the 1.0 packages with pacman -U, then pacman -D --asdeps su-dummy-b (a and c explicit, b "Installed as a dependency for another package", so pacman -Rns su-dummy-a removes b too).
  5. Creates an empty /etc/pacman.d/ignorepkg and inserts Include = /etc/pacman.d/ignorepkg after the first [options] header (pman layout).
  6. Does not sync (unless FIXTURE_SYNC=1), so the tool under test performs the refresh itself.

Result after pacman -Sy:

$ pacman -Qu | tail -3
su-dummy-a 1.0-1 -> 2.0-1
su-dummy-b 1.0-1 -> 2.0-1
su-dummy-c 1.0-1 -> 2.0-1
$ pacman -Sl | grep dummy
su-dummy su-dummy-a 2.0-1 [installed: 1.0-1]

Knobs: FIXTURE_ISOLATE=1 comments out [core]/[extra] so -Qu lists exactly the 3 dummies, works offline and is deterministic (default mode additionally lists whatever real upgrades the image has, 6 today; filter with grep '^su-dummy-'); FIXTURE_STRICT_DEP=1 makes a-2.0 depend on su-dummy-b>=2.0 (the section 6 trap); FIXTURE_REPO, FIXTURE_SYNC. The file can be sourced: it defines fx_build_tar <outdir> <name> <ver> <desc> [dep...] and does not enable set -e in the caller (used to publish extra packages, e.g. the su-dummy-new and two-repo experiments above).

Typical flow in a test: FIXTURE_ISOLATE=1 bash fixture.sh; pacman -Sy; <tool>; pacman -Q su-dummy-a -> su-dummy-a 2.0-1, cat /usr/share/su-dummy/su-dummy-a/VERSION -> 2.0. Re-running the fixture in the same container is safe (verified): [su-dummy] and the Include are not duplicated, the repo is rebuilt, and if the packages were already upgraded to 2.0 they are downgraded back to 1.0 by pacman -U (so it also works as a reset between test cases).

11. Timings (this host, fast CDN, three runs agree within 0.05 s)

step time
container start (docker run --rm archlinux:latest true) 0.30 s
pacman -Sy cold / warm 0.21-0.25 s / 0.12 s
fixture (tar builder, includes 3 installs + repo-add) 0.23 s
fixture (makepkg builder, includes pacman -Sy fakeroot, 6 makepkg runs) 7.3-7.7 s (one cold run: 12.9 s)
pm_upgradable (-Sl + -Qu + awk) 0.15 s
pm_closure pacman (107 packages) 0.02 s
pacman -S --noconfirm bats 6.3 s
pacman -S pacman-contrib 0.4 s
whole checks.sh incl. container start, contrib install and a full real -Su ~5-8 s
image pull archlinux:latest ~10 s (399 MB); base-devel 950 MB

12. pman mapping and bugs found

pman (/home/bryanpedini/projects/sources/pman/pman.sh) is a bash case over verbs; it prepends sudo when not root and the verb needs it (config, install, update, upgrade, remove, purge, clean, provides, providefiles, reflector, ignore, unignore when $UID != 0; it exits 1 if sudo is absent, as in the base image when not root: verified pman upgrade as an unprivileged user in archlinux:latest -> exit 1; in that container the sudo command needed but not found message itself was swallowed by echo ... > /dev/stderr failing with Permission denied, a side effect of runuser + docker's root-owned stderr pipe).

pman verb runs skeptical-updater uses
update pacman -Sy then prints N packages are available for update (pacman -Qu | wc -l, counts [ignored] rows) refresh
upgradable pacman -Qu list
localinfo / info pacman -Qi / -Si info (info shows both repos if duplicated)
upgrade pacman -Syu + extra args (pman upgrade --noconfirm --ignore a,b works, verified) apply (prefer -Su after a refresh)
purge / remove pacman -Rns / -Rs purge
ignored echo "$(sed 's/IgnorePkg = //' /etc/pacman.d/ignorepkg | sort)" read
ignore p... appends IgnorePkg = p to /etc/pacman.d/ignorepkg write
unignore p... sed -i "/IgnorePkg = p/d" write

Verified end to end with the fixture as root: pman update -> 3 packages are available for update; pman ignore su-dummy-c + pman upgradable -> su-dummy-c 1.0-1 -> 2.0-1 [ignored]; pman upgrade --noconfirm printed warning: su-dummy-c: ignoring package upgrade (1.0-1 => 2.0-1) and upgraded a and b (exit 0); pman purge --noconfirm su-dummy-a removed a and b.

pman bugs (verified; relevant if the tool reuses /etc/pacman.d/ignorepkg):

  • pman ignore foo after pman ignore barfoo prints foo is already ignored, skipping... (the check is grep -e "$pkg$", an unanchored suffix match) and foo is never added.
  • pman unignore su-dummy deletes the line IgnorePkg = su-dummy-c (sed "/IgnorePkg = su-dummy/d" is an unanchored prefix/substring match); regex characters in names are not escaped either.
  • ignored/ignore/unignore assume one package per line in IgnorePkg = <pkg> form and never check that pacman.conf actually includes the file in [options] (silently ineffective, section 6).
  • The tool should read the effective list with pacman-conf IgnorePkg and write with anchored matches (grep -qxF "IgnorePkg = $pkg", sed -i "/^IgnorePkg = ${pkg}\$/d" after escaping).

13. Install reason (N? / info): Install Reason of pacman -Qi

Verified 2026-10-10 on archlinux:latest (pacman 7.1.0), fixture FIXTURE_ISOLATE=1 FIXTURE_ORPHAN=1 FIXTURE_SYNC=1, COLUMNS=0 LC_ALL=C pacman -Qi -- PKG:

su-dummy-a       Install Reason  : Explicitly installed                              rc 0
su-dummy-b       Install Reason  : Installed as a dependency for another package     rc 0   (-D --asdeps)
su-dummy-c       Install Reason  : Explicitly installed                              rc 0
su-dummy-orphan  Install Reason  : Installed as a dependency for another package     rc 0   (-U --asdeps)
no-such-pkg      error: package 'no-such-pkg' was not found                          rc 1   (also for a package only in a sync db)
  • pacman -D --asexplicit su-dummy-b / --asdeps flip the line at once. Works as nobody and with db.lck present (-Qi takes no lock). One line, no wrapping with COLUMNS=0.
  • pacman -Qi sh answers for the provider (bash), like the description and Required By already do.
  • Mapping: Explicitly installed -> explicit, Installed as a dependency for another package -> dependency, -Qi failing -> not installed, anything else (or no such line) -> unknown.
  • Fixture: su-dummy-b is installed --asdeps, a/c explicitly; e2e_reset re-applies both after its pacman -U. No change needed.

14. Full info (N?? / show): -Qi merged with -Si REPO/PKG

Verified 2026-10-10 on archlinux:latest (pacman 7.1.0), COLUMNS=0 LC_ALL=C: the fixture (FIXTURE_ISOLATE=1 FIXTURE_ORPHAN=1 FIXTURE_SYNC=1) and a real upgradable package after pacman -Sy (glib2 2.88.3-1 installed, core 2.90.1-1).

$ pacman -Qi su-dummy-b                          $ pacman -Si su-dummy/su-dummy-b
Name            : su-dummy-b                     Repository      : su-dummy
Version         : 1.0-1                          Name            : su-dummy-b
Description     : Skeptical-updater dummy ...    Version         : 2.0-1
Architecture    : any                            Description     : Skeptical-updater dummy ...
URL             : https://example.invalid/...    Architecture    : any
Licenses        : custom                         URL / Licenses / Groups / Provides / Depends On /
Groups / Provides / Depends On / Optional Deps   Optional Deps / Conflicts With / Replaces
Required By     : su-dummy-a                     Download Size   : 388.00 B
Optional For    : None                           Installed Size  : 4.00 B
Conflicts With / Replaces                        Packager        : su-fixture <...>
Installed Size  : 4.00 B                         Build Date      : Tue Nov 14 22:13:20 2023
Packager        : su-fixture <...>               Validated By    : SHA-256 Sum
Build Date      : Tue Nov 14 22:13:20 2023
Install Date    : Sat Oct 10 11:55:39 2026
Install Reason  : Installed as a dependency for another package
Install Script  : No
Validated By    : None
  • Both are one record per target (blank line after it), every key padded to the same column (: at column 17 in both, C locale), so the keys pair one to one. -Qi only: Required By, Optional For, Install Date, Install Reason, Install Script. -Si only: Repository, Download Size. Both, usually different: Version, Installed Size, Build Date, Validated By (None/Signature installed, SHA-256 Sum Signature in the repo), Depends On/Provides/Optional Deps when they changed.
  • Multi-line: only Optional Deps (continuation lines indented to the value column, one entry each - glib2: dconf: ... / glib2-devel: ... / gvfs: ...); with COLUMNS=0 nothing else wraps (Provides of glib2, 6 entries, stays one line).
  • The candidate is -Si REPO/PKG with REPO = the first repository -Sl lists PKG in (pacman's choice, the join pacman_upgradable uses); plain -Si PKG prints one record per repository that has PKG.
  • Missing sides: -Qi of a package only in a sync database fails (not installed: the candidate alone); a local-only package has no -Si record (the plain -Qi stays).

15. AUR helpers: paru and yay (-Qua, -Sia, -Sua)

Verified 2026-10-10 on archlinux:latest (pacman 7.1.0 / libalpm 16.0.1) with paru 2.1.0 and yay 13.0.1, both built from the AUR, run as the unprivileged user bob (wheel, NOPASSWD sudo) with stdin from /dev/null, stdout piped, LC_ALL=C. paru-bin 2.1.0 does not start on this pacman (paru: error while loading shared libraries: libalpm.so.15: cannot open shared object file, exit 127: it links libalpm 15), so paru was built from the paru package (cargo); yay is yay-bin. Outdated AUR package: pfetch 1.10.0-1 (built from an older commit of its AUR git), 1.11.0-1 in the AUR; devel package: pfetch-git; foreign package that is not in the AUR: su-local-only (hand-built). Proof: discovery/pacman/aur.sh (needs network):

docker run --rm -i -v "$PWD/discovery/pacman:/d:ro" archlinux:latest bash /d/aur.sh

15.1 -Qua: the AUR upgrades

$ paru -Qua                                  $ yay -Qua
pfetch 1.10.0-1 -> 1.11.0-1                  pfetch 1.10.0-1 -> 1.11.0-1 [122d13h]
[exit 0]                                     [exit 0]
  • Format name SP old SP "->" SP new [SP extra], like pacman -Qu; no colour when piped (both). yay 13 appends the time since the package's Last Modified in the AUR ([122d13h]: 9 Jun 2026 to 10 Oct 2026); paru nothing. paru -Qu / yay -Qu print the same line here (repository and AUR upgrades together).

  • IgnorePkg = pfetch: paru prints pfetch 1.10.0-1 -> 1.11.0-1 [ignored] (exit 0); yay leaves the package out (no output, exit 1).

  • Nothing to upgrade: exit 1, nothing on stdout or stderr (both, with and without --devel).

  • The AUR unreachable (127.0.0.1 aur.archlinux.org in /etc/hosts): exit 1 with the error on stderr:

    paru: error: error sending request for url (https://aur.archlinux.org/rpc): error trying to connect: tcp connect
          error: Connection refused (os error 111): ... Connection refused (os error 111)        (one line)
    yay:   -> 1 error occurred:
          	* request failed: Get "https://aur.archlinux.org/rpc?arg%5B%5D=paru&...&type=info&v=5": dial tcp
          127.0.0.1:443: connect: connection refused                                              (then 2 blank lines)
    

    So exit 1 with an empty stderr = nothing to upgrade; anything else = failure.

  • A foreign package that is not in the AUR is not mentioned by -Qua (only -Sua says :: packages not in the AUR: su-local-only / -> Packages not in AUR: su-local-only). Foreign = in no sync database: with the repositories of the E2E fixture commented out, every base package is foreign to the helper.

  • Devel (VCS) packages, after paru -S pfetch-git, paru --gendb, yay -Y --gendb and replacing the commit ids recorded in ~/.local/state/paru/devel.toml / ~/.cache/yay/vcs.json by older ones: paru checks them by default (paru -Qua, with or without --devel; -Sua prints :: Looking for devel upgrades...), yay only with --devel (or yay -Y --devel --save). The new version is the literal latest-commit:

    $ paru -Qua                                  $ yay -Qua --devel          ($ yay -Qua: exit 1, nothing)
    pfetch-git r432.a906ff8-1 -> latest-commit   pfetch-git r432.a906ff8-1 -> latest-commit [2299d6h]
    
  • As root: paru -Qua works (same output); yay -Qua works but prints ^[[1m^[[33m -> ^[[0m^[[0mAvoid running yay as root/sudo. on stdout. paru -Sua as root: error: can't install AUR package as root (exit 1); yay -Sua as root fails as well (exit 1). The helpers must run as a normal user.

15.2 -Sia PKG: the AUR record

paru -Si pfetch, paru -Sia pfetch and paru -Si --aur pfetch print the same record; yay -Si pfetch asks the repositories only (error: package 'pfetch' was not found / -> exit status 1, exit 1), yay -Sia pfetch gives the AUR record. Both are pacman-style, one record followed by a blank line:

$ paru -Sia pfetch                                          $ yay -Sia pfetch (keys padded to 30)
Repository      : aur                                       Repository                    : aur
Name            : pfetch                                    Name / Version / Description / URL / Licenses /
Version         : 1.11.0-1                                  Groups / Provides / Depends On / Optional Deps /
Description     : A pretty system information tool ...      Make Deps / Check Deps / Conflicts With /
URL             : https://github.com/Un1q32/pfetch          Replaces                      : None
AUR URL         : https://aur.archlinux.org/packages/pfetch AUR URL                       : https://aur...
Groups          : None                                      First Submitted               : Sat 16 Jan 2021 09:13:08 AM UTC
Licenses        : MIT                                       Keywords                      : fetch
Provides        : pfetch                                    Last Modified                 : Tue 09 Jun 2026 10:43:50 PM UTC
Depends On      : None                                      Maintainer                    : yigits
Make Deps       : None                                      Popularity                    : 0.103440
Check Deps      : None                                      Votes                         : 48
Optional Deps   : None                                      Out-of-date                   : No
Conflicts With  : pfetch-git
Maintainer      : yigits
Votes           : 48
Popularity      : 0.10344
First Submitted : Sat, 16 Jan 2021 09:13:08
Last Modified   : Tue, 9 Jun 2026 22:43:50
Out Of Date     : No
  • Comparable with pacman -Qi pfetch (section 14): one record per side, every : in one column per side (paru: 17, like pacman; yay: 31, re-padded by the merge), keys spelled like pacman's. Both, here identical: Name, Description, URL, Licenses, Groups, Provides, Depends On, Optional Deps, Conflicts With (yay also Replaces); different: Version. -Qi only: Architecture, Required By, Optional For, Installed Size, Packager, Build Date, Install Date, Install Reason, Install Script, Validated By. AUR only: Repository (aur), AUR URL, Make Deps, Check Deps, Maintainer, Votes, Popularity, First Submitted, Last Modified, Out Of Date (yay: Out-of-date, plus Keywords).

  • Not in the AUR: paru error: package 'no-such-package-zzz' was not found, yay -> No AUR package found for no-such-package-zzz, exit 1. AUR unreachable: the -Qua errors above, exit 1.

  • Optional dependencies are bare names in both (Optional Deps : pipewire kdialog gnome-keyring kwallet for google-chrome, whose AUR entries carry descriptions such as pipewire: WebRTC desktop sharing under Wayland): the descriptions are dropped. -Qi prints one name: description [installed] per line.

  • yay wraps long values at 80 columns - Depends On, Optional Deps, continuation lines indented to the value column (31) - whenever COLUMNS is unset or 80, also without any terminal (stdout and stdin not a tty); COLUMNS=0 turns it off. paru 2.1.0 printed one line per field in every case tried (no terminal; an 80-column pty with stdout piped, COLUMNS unset, 0 or 80; stdout the pty itself with COLUMNS=0). Verified 2026-10-10 with claude-desktop 2.31226.1-1 (39 dependencies), yay -Sia claude-desktop without COLUMNS=0:

    Depends On                    : alsa-lib  at-spi2-core  bubblewrap  cairo  dbus
                                    expat  gcc-libs  glib2  glibc  gtk3
                                    ...
                                    xdg-desktop-portal  xdg-utils  qemu-system-x86
                                    edk2-ovmf
    Optional Deps                 : gnome-keyring  kwallet  libayatana-appindicator
                                    xdg-desktop-portal-gtk  xdg-desktop-portal-kde
                                    qemu-system-aarch64  edk2-aarch64
    

    (paru: Depends On : alsa-lib at-spi2-core bubblewrap ... edk2-ovmf on one line.) The installed side for that comparison, pacman -Qi claude-desktop of 2.9939.4-1 (a stand-in package built from the AUR's own .SRCINFO of that version, commit b7d5715, plus a dummy kwallet for [installed]): Depends On one line without bubblewrap, libpipewire, qemu-system-x86, edk2-ovmf; Optional Deps : gnome-keyring: credential storage via Secret Service (GNOME) / kwallet: credential storage (KDE Plasma) [installed] / ... one per line. These records are the unit fixtures (tests/unit/data/pacman/Qi.txt, tests/unit/data/aur/Sia-{paru,yay}-claude-desktop.txt).

15.3 -Sua: the AUR upgrade

  • --ignore covers AUR packages (both): paru -Sua --noconfirm --ignore pfetch -> there is nothing to do, stderr warning: pfetch: ignoring package upgrade (1.10.0-1 => 1.11.0-1), exit 0; yay -Sua --noconfirm --ignore pfetch -> -> pfetch: ignoring package upgrade (1.10.0-1 => 1.11.0-1) and there is nothing to do (stdout), exit 0. Names that are not installed (--ignore su-dummy-x,su-dummy-y) are accepted and pfetch is upgraded.
  • --noconfirm, stdin /dev/null: both upgrade without asking (exit 0). paru still prints :: Proceed to review? [Y/n]: and answers it; yay's menus (Packages to exclude, Packages to cleanBuild?, Diffs to show?) take their default (none). The helper installs with sudo pacman -U itself (here NOPASSWD; otherwise sudo asks for the user's password on the terminal).
  • Without --noconfirm, stdin /dev/null: paru takes the defaults at end of input and upgrades (printing the PKGBUILD as its review); yay exits 1 at its first menu (:: 1 package to upgrade/install. ... Excluding packages may cause partial upgrades), nothing upgraded. So --noconfirm whenever stdin is not a terminal; on a terminal the helper keeps its prompts (paru's PKGBUILD review, yay's diff menu).
  • Devel: paru -Sua --noconfirm rebuilt pfetch-git for the latest-commit row (warning: pfetch-git-r432.a906ff8-1 is up to date -- reinstalling, since only the recorded commit was faked).
  • runuser -u bob -- env (as root) sets HOME=/home/bob, USER, LOGNAME and MAIL (PAM) and keeps PATH and the working directory; runuser is in util-linux (the base image has it, sudo it has not).

15.4 Chosen

need command (as the invoking user: runuser -u "$SUDO_USER" -- env HOME=<home>, else sudo -u ...; unprivileged runs as they are; a root login without SUDO_USER: no AUR)
helper --aur-helper=paru|yay|none / --no-aur, else paru, else yay
list <helper> -Qua: rows name old new aur (flag aur, [ignored] -> held); a name pacman -Qu lists wins; exit 1 + empty stderr = none, other failures: warn, no AUR rows
N?? pacman -Qi merged with <helper> -Sia PKG (section 14's merge; pacman mode: values unwrapped, lists as token sets, Optional Deps as name sets, AUR-only fields next to their counterparts)
COLUMNS COLUMNS=0 for -Qua and -Sia (yay's wrapping); the upgrade keeps the terminal's
upgrade after the repository step: <helper> -Sua [--noconfirm when stdin is not a terminal] [--ignore <excluded rows>]