Files
omarchycn/bin/omarchy-update-system-pkgs-when-conflicted
T
David Heinemeier HanssonandClaude Opus 5 646587e316 Recover from package file conflicts instead of predicting them
pacman refuses to install over a file it doesn't own, so any path an
Omarchy package starts shipping that a script had already written by
hand aborts the whole upgrade:

  omarchy-settings-dev: /usr/lib/systemd/user/omarchy-fcitx5.service exists in filesystem
  Errors occurred, no packages were upgraded.

The mitigation was a hand-maintained --overwrite allowlist, and it only
worked with foresight: an entry had to ship a release before the package
took the path, because pacman checks conflicts during transaction
prepare, so the script running the upgrade is the one already on disk. A
missed entry left people hard-stuck, since the upgrade that would
deliver the entry is the one refusing to run.

So react instead. omarchy-update-system-pkgs now does the ordinary thing
and hands a failed transaction to omarchy-update-system-pkgs-when-
conflicted, which moves the offending files out of the way and runs the
upgrade again. Nothing has to be predicted, and the allowlist is gone.

Moving rather than overwriting is what makes it small: no --overwrite
argument to build, no glob escaping, no separate backup step, and a
leftover directory is cleared too, which --overwrite cannot do at all.

Files go to /var/lib/omarchy/replaced/<original path>, not next to the
original. A sibling copy is not inert -- SDDM reads every file in
sddm.conf.d whatever its extension, and systemd-sleep runs every
executable in system-sleep -- and the directory a future path lives in
is unknowable, which is the whole point of a mechanism for paths nobody
predicted.

What it will not do:

- Take a file another package owns. pacman reports those with a
  "(owned by x)" suffix, so the end anchor excludes them, and pacman -Qo
  re-checks the live database before anything moves.
- Move a subset. If any reported conflict isn't recoverable the retry is
  doomed anyway, and moving leaves that config inactive for nothing --
  worse than the stuck-but-intact state.
- Leave anything inactive that was live. A failed retry, a failed move
  partway through the loop, or an interrupt all put back whatever the
  upgrade didn't install.
- Act on a report handed to it by hand. It is internal to the update,
  and an old report would clear live files for an upgrade that isn't
  happening.

Also adds a test that fails when a script writes a path under /usr that
no PKGBUILD installs, since not creating these is cheaper than
recovering from them. It reads the destination off the command, so a
path assembled from variables still slips through; the two known cases
are recorded with their reasons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:50:28 -07:00

89 lines
3.3 KiB
Bash
Executable File

#!/bin/bash
# Internal to omarchy-update-system-pkgs. Not a command to run by hand: it acts
# on a pacman error report, and an old or hand-written one would move live files
# out of the way for an upgrade that is not happening.
#
# Paths written by a script rather than installed by a package -- an earlier
# release's installer, an in-place edit -- belong to nobody, and pacman refuses
# to install over a file it doesn't own. This clears them and runs the upgrade
# again.
set -e
[[ ${OMARCHY_UPDATE_CONFLICT:-} == 1 ]] || {
echo "omarchy-update-system-pkgs-when-conflicted runs as part of omarchy update" >&2
exit 1
}
errors=${1:?usage: omarchy-update-system-pkgs-when-conflicted <pacman-error-report>}
# Where a file pacman is taking over gets moved, mirroring its full path. Kept
# out of the directory it came from: SDDM reads every file in sddm.conf.d
# whatever its extension, and systemd-sleep runs every executable in
# system-sleep, so a copy left beside the original would still be live.
replaced=${OMARCHY_REPLACED_DIR:-/var/lib/omarchy/replaced}
# Anything moved that the upgrade didn't end up installing goes back, whether
# the retry failed or the move loop itself was interrupted partway. Nothing here
# should leave configuration inactive that was live when it started.
#
# -e follows symlinks, so a dangling one reads as absent. -L catches those: a
# link pacman installed, and a relative link that no longer resolves from inside
# the quarantine.
moved=()
restore_moved() {
local path restorable=()
for path in "${moved[@]}"; do
if [[ ! -e $path && ! -L $path ]] && [[ -e $replaced$path || -L $replaced$path ]]; then
restorable+=("$path")
fi
done
((${#restorable[@]})) || return 0
echo -e "\e[33m\nPutting back what the upgrade didn't take:\e[0m"
for path in "${restorable[@]}"; do
sudo mv -T "$replaced$path" "$path"
echo " $path"
done
}
# exec'd into, so the caller's EXIT trap never ran and the report is still here.
# Cleaning it up is this script's job from now on.
trap 'restore_moved; rm -f "$errors"' EXIT
trap 'exit 1' INT TERM
# Paths one of these packages installs that pacman doesn't own. Moving them out
# of the way is what lets the upgrade through, and works on a leftover directory
# as well as a file.
#
# "pkg: /path exists in filesystem" is unowned; the same line plus "(owned by
# x)" is not, so the end anchor takes only ours. -Qo re-checks the live database
# before anything moves.
mapfile -t leftovers < <(
grep -oP '^omarchy(-dev|-settings|-settings-dev)?: \K.+(?= exists in filesystem$)' "$errors" |
while IFS= read -r path; do pacman -Qo "$path" &>/dev/null || echo "$path"; done
)
((${#leftovers[@]})) || exit 1
# All of them or none: moving a subset leaves the retry blocked by the rest, and
# the moved files inactive for nothing.
((${#leftovers[@]} == $(grep -c ' exists in filesystem' "$errors"))) || exit 1
echo -e "\e[33m\nTaking over files pacman doesn't own yet:\e[0m"
for path in "${leftovers[@]}"; do
sudo mkdir -p "$replaced${path%/*}"
# -T so an existing directory at the destination is replaced, not moved into.
sudo mv -T --backup=numbered "$path" "$replaced$path"
moved+=("$path")
echo " $path -> $replaced$path"
done
echo
if OMARCHY_UPDATE_RETRY=1 omarchy-update-system-pkgs; then
moved=()
exit 0
fi
exit 1