Merge pull request #6405 from basecamp/recover-pacman-file-conflicts

Recover from package file conflicts instead of predicting them
This commit is contained in:
David Heinemeier Hansson
2026-07-27 15:57:38 -07:00
committed by GitHub
4 changed files with 519 additions and 38 deletions
+21 -38
View File
@@ -7,42 +7,25 @@ set -e
echo -e "\e[32m\nUpdate system packages\e[0m"
# Transition --overwrite: previous script-installed Omarchy wrote these paths
# as unowned files; pacman would refuse the omarchy-settings upgrade on first
# encounter. Drop each entry once the transition release is the baseline.
errors=$(mktemp)
trap 'rm -f "$errors"' EXIT
# /usr/share/omarchy is wholly Omarchy's; files can land there unowned and would
# otherwise abort the upgrade. Packaged content wins there.
#
# An entry has to ship at least one release before the package starts owning the
# path. pacman checks file conflicts during transaction prepare, so the copy of
# this script running the upgrade is the one already on disk.
#
# The /usr/share/omarchy/* entry is permanent: that tree is wholly owned by
# the omarchy packages, but files can land there unowned (in-place extension
# work, script-written files), which would abort the whole upgrade. Packaged
# content always wins there.
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \
--overwrite '/etc/docker/daemon.json' \
--overwrite '/etc/gnupg/dirmngr.conf' \
--overwrite '/etc/mkinitcpio.conf.d/omarchy_hooks.conf' \
--overwrite '/etc/mkinitcpio.conf.d/thunderbolt_module.conf' \
--overwrite '/etc/modprobe.d/omarchy-usb-autosuspend.conf' \
--overwrite '/etc/sddm.conf.d/10-theme.conf' \
--overwrite '/etc/sddm.conf.d/10-wayland.conf' \
--overwrite '/etc/sudoers.d/omarchy-asdcontrol' \
--overwrite '/etc/sudoers.d/omarchy-passwd-tries' \
--overwrite '/etc/sudoers.d/omarchy-tzupdate' \
--overwrite '/etc/sysctl.d/90-omarchy-file-watchers.conf' \
--overwrite '/etc/sysctl.d/99-omarchy-sysctl.conf' \
--overwrite '/etc/systemd/logind.conf.d/10-ignore-power-button.conf' \
--overwrite '/etc/systemd/resolved.conf.d/10-disable-multicast.conf' \
--overwrite '/etc/systemd/resolved.conf.d/20-docker-dns.conf' \
--overwrite '/etc/systemd/system.conf.d/10-faster-shutdown.conf' \
--overwrite '/etc/systemd/system/user@.service.d/10-faster-shutdown.conf' \
--overwrite '/etc/systemd/system/docker.service.d/no-block-boot.conf' \
--overwrite '/etc/systemd/system/plocate-updatedb.service.d/ac-only.conf' \
--overwrite '/etc/systemd/system.conf.d/20-omarchy-nofile.conf' \
--overwrite '/etc/systemd/user.conf.d/20-omarchy-nofile.conf' \
--overwrite '/usr/lib/systemd/system-sleep/unmount-fuse' \
--overwrite '/usr/share/plymouth/themes/omarchy/*' \
--overwrite '/usr/share/sddm/hyprland.lua' \
--overwrite '/usr/share/sddm/themes/omarchy/*' \
--overwrite '/usr/share/omarchy/*'
# Progress bars stay on stdout. Errors are on stderr, kept for the conflict
# handler below; LC_ALL=C is what keeps them parseable in any locale.
if sudo env LC_ALL=C OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \
--overwrite '/usr/share/omarchy/*' 2>"$errors"; then
cat "$errors" >&2
exit 0
fi
cat "$errors" >&2
# An upgrade blocked only by files pacman doesn't own yet is the one failure
# worth retrying: the handler clears them and runs this again. Anything else,
# including a second failure, is for a human.
[[ ${OMARCHY_UPDATE_RETRY:-} != 1 ]] || exit 1
# exec, so the EXIT trap above does not fire and the handler can still read the
# report. It takes over deleting it.
exec env OMARCHY_UPDATE_CONFLICT=1 omarchy-update-system-pkgs-when-conflicted "$errors"
+88
View File
@@ -0,0 +1,88 @@
#!/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
+134
View File
@@ -0,0 +1,134 @@
#!/bin/bash
# A file Omarchy writes into /usr belongs to nobody, and the
# day a package starts shipping that same path, pacman refuses the upgrade for
# everyone who has the file. omarchy-update-system-pkgs-when-conflicted recovers from
# that, but the cheaper answer is to ship the file in the package instead.
#
# This flags a script writing such a path unless a PKGBUILD installs it, or it
# is recorded below with the reason it cannot be packaged.
#
# It is a net, not a proof: it reads the destination off the command, so a path
# assembled from variables passes through. The two known cases are recorded
# below, and a new one is caught only if it names the path where it writes it.
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
python3 - "$ROOT" <<'PYTHON'
import os, re, sys
from pathlib import Path
root = Path(sys.argv[1])
# Paths Omarchy writes into /usr that no package owns, with the reason.
allowed = {
# Symlinks into another package's icon theme; owning them would mean owning
# paths inside Yaru.
"/usr/share/icons/Yaru/scalable/actions",
# Hardware-conditional sleep hooks, installed only on the machines that need
# them so the hook does not exist where it does not apply.
"/usr/lib/systemd/system-sleep",
# Written through a variable, so the scan below cannot see them at the point
# they are written. Both drop configuration into another project's tree rather
# than Omarchy's, which is why neither is a candidate for omarchy-settings.
"/usr/share/chromium/extensions",
"/usr/lib/firefox/distribution",
# Static content that belongs in omarchy-settings. It cannot move there in the
# same release that first ships omarchy-update-system-pkgs-when-conflicted: the
# upgrade carrying the handler is the one that would hit the conflict, and the
# handler only helps once it is on disk. Package it the release after.
"/usr/lib/chromium/initial_preferences",
}
# One-time 3.x upgrade. It runs before this rule existed and cannot be made to
# retroactively matter for machines that already ran it.
skip_scripts = {"bin/omarchy-upgrade-to-quattro"}
pkgs_candidates = [
root.parent / "omarchy-pkgs/pkgbuilds",
root.parent.parent / "omarchy-pkgs/pkgbuilds",
root.parent / "omacom/omarchy-pkgs/pkgbuilds",
Path.home() / "Work/omacom/omarchy-pkgs/pkgbuilds",
]
override = os.environ.get("OMARCHY_PKGS_PATH")
if override:
pkgs_candidates = [Path(override) / "pkgbuilds", Path(override)] + pkgs_candidates
pkgs_root = next((p for p in pkgs_candidates if p.exists()), None)
if pkgs_root is None:
print("not ok - omarchy-pkgs checkout found for package ownership check", file=sys.stderr)
sys.exit(1)
packaged = "\n".join(p.read_text() for p in pkgs_root.glob("*/PKGBUILD"))
# Commands that put a file somewhere, as opposed to reading one.
# /etc is administrator territory that Omarchy legitimately edits. /usr is
# package territory, where writing anything is the thing worth catching.
writer = re.compile(r"\b(tee|cp|install|ln)\b|>\s*/usr/")
target = re.compile(r"/usr/[A-Za-z0-9._@/+-]+")
problems = []
for base in ("bin", "install", "migrations"):
for path in sorted((root / base).rglob("*")):
if not path.is_file():
continue
rel = str(path.relative_to(root))
if rel in skip_scripts:
continue
# Join continuations first, so a command split across lines is judged whole
# rather than as a source path on one line and a destination on the next.
joined, buf, start = [], "", 1
for lineno, line in enumerate(path.read_text(errors="ignore").splitlines(), 1):
if not buf:
start = lineno
if line.rstrip().endswith("\\"):
buf += line.rstrip()[:-1] + " "
continue
joined.append((start, buf + line))
buf = ""
if buf:
joined.append((start, buf))
for lineno, line in joined:
code = line.split("#", 1)[0]
if not writer.search(code):
continue
# A redirection names its destination directly. Otherwise cp/install/ln
# put the destination last, and anything earlier is a source being read.
redirect = re.search(r">\s*\"?(/usr/[^\s\"]+)", code)
if redirect:
hit = redirect.group(1).rstrip("/")
else:
tokens = re.sub(r"[12]?>\s*\S+", "", code).split()
if not tokens or not tokens[-1].startswith("/usr/"):
continue
hit = tokens[-1].rstrip("/")
# Omarchy's own tree and its binaries are covered elsewhere.
if hit.startswith(("/usr/share/omarchy", "/usr/bin")) or hit.count("/") < 3:
continue
# Directory or file form of a recorded path both count as recorded, but
# only on a path boundary: system-sleeping is not system-sleep.
def covers(a, b):
return a == b or b.startswith(a + "/")
if any(covers(a, hit) or covers(hit, a) for a in allowed):
continue
# Likewise in the PKGBUILD text, where the path is a destination rather
# than a prefix of a longer one.
if re.search(re.escape(hit) + r'(?=["\'\s]|$)', packaged, re.M):
continue
problems.append(f"{rel}:{lineno}: {hit}")
if problems:
print("not ok - Omarchy writes paths under /usr that no package owns", file=sys.stderr)
for p in problems:
print(f" {p}", file=sys.stderr)
print(
"\nShip it from a PKGBUILD so pacman owns it, or record it in this test\n"
"with the reason it cannot be packaged.",
file=sys.stderr,
)
sys.exit(1)
PYTHON
pass "no Omarchy script writes a path under /usr that no package owns"
+276
View File
@@ -0,0 +1,276 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
mkdir -p "$stub_bin"
cat >"$stub_bin/sudo" <<'STUB'
#!/bin/bash
exec "$@"
STUB
# Fails the first -Syu with the report under test, then succeeds unless the case
# asked for the retry to fail too.
cat >"$stub_bin/pacman" <<'STUB'
#!/bin/bash
if [[ $1 == -Qo ]]; then
# Anything in OWNED_PATHS has a package behind it; everything else is unowned.
[[ " $OWNED_PATHS " == *" $2 "* ]]
exit $?
fi
attempt=$(($(cat "$PACMAN_ATTEMPTS") + 1))
echo "$attempt" >"$PACMAN_ATTEMPTS"
if ((attempt == 1)); then
cat "$CONFLICT_REPORT" >&2
exit 1
fi
if [[ -n ${RETRY_FAILS:-} ]]; then
# Optionally commit the file first, as a partial transaction would.
[[ -n ${RETRY_INSTALLS:-} ]] && echo "packaged" >"$RETRY_INSTALLS"
echo "error: failed to retrieve some files" >&2
exit 1
fi
echo "upgrade complete"
STUB
chmod +x "$stub_bin/sudo" "$stub_bin/pacman"
replaced="$test_tmp/replaced"
run_update() {
OMARCHY_REPLACED_DIR="$replaced" \
RETRY_FAILS="${RETRY_FAILS:-}" \
RETRY_INSTALLS="${RETRY_INSTALLS:-}" \
PACMAN_ATTEMPTS="$test_tmp/attempts" \
CONFLICT_REPORT="$test_tmp/report" \
OWNED_PATHS="${OWNED_PATHS:-}" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
bash "$ROOT/bin/omarchy-update-system-pkgs"
}
# $1 blamed package, $2 path, $3 optional owning package.
write_report() {
echo 0 >"$test_tmp/attempts"
{
echo "error: failed to commit transaction (conflicting files)"
echo "$1: $2 exists in filesystem${3:+ (owned by $3)}"
} >"$test_tmp/report"
}
# Raw conflict lines, for reports the recovery must refuse wholesale.
write_raw_report() {
echo 0 >"$test_tmp/attempts"
{
echo "error: failed to commit transaction (conflicting files)"
printf '%s\n' "$@"
} >"$test_tmp/report"
}
work="$test_tmp/work"
fresh_work() {
rm -rf "$work" "$replaced"
mkdir -p "$work"
}
# An unowned path one of the packages is taking over.
fresh_work
stray="$work/omarchy-fcitx5.service"
echo "stray content" >"$stray"
write_report omarchy-settings-dev "$stray"
run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
fail "an unowned file conflict is not resolved"
[[ ! -e $stray ]] ||
fail "the file is left in pacman's way"
pass "a file pacman is taking over is moved out of its way"
# Kept out of the directory it came from, where SDDM and systemd-sleep read
# every file and every executable respectively.
[[ -z $(ls -A "$work") ]] ||
fail "something is left in the directory the replaced file came from"
grep -qx "stray content" "$replaced$stray" ||
fail "the replaced file is destroyed rather than kept out of the way"
pass "the replaced file is quarantined outside the directory it came from"
# A real fight between packages, not Omarchy's leftovers. pacman appends
# "(owned by ...)" here.
fresh_work
echo "theirs" >"$stray"
write_report omarchy-settings-dev "$stray" someone-else
if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
fail "a file owned by another package is silently taken"
fi
[[ -e $stray && ! -e $replaced$stray ]] ||
fail "a file owned by another package is silently taken"
pass "a conflict owned by another package stops the upgrade instead of being taken"
# Report reads unowned, database disagrees: the parse is never the only thing
# between a retry and another package's file.
fresh_work
echo "theirs" >"$stray"
write_report omarchy-settings-dev "$stray"
if OWNED_PATHS="$stray" run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
fail "pacman -Qo is not consulted before moving a file"
fi
[[ -e $stray && ! -e $replaced$stray ]] ||
fail "pacman -Qo is not consulted before moving a file"
pass "an owned path is left alone even when the report reads as unowned"
# A name prefix is not a namespace; only the packages that own system paths.
fresh_work
echo "stray" >"$stray"
write_report omarchy-chromium-bin "$stray"
if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
fail "an optional omarchy-prefixed package gets its conflicts auto-resolved"
fi
pass "only the packages that own system paths get their conflicts resolved"
# Not Omarchy's conflict to resolve.
fresh_work
echo "stray" >"$stray"
write_report some-other-pkg "$stray"
if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
fail "a conflict from an unrelated package is auto-resolved"
fi
pass "a conflict from a non-Omarchy package is left for a human"
# The path is used literally, so glob characters in a name mean nothing.
fresh_work
globby="$work/omarchy-[1].conf"
echo "globby" >"$globby"
write_report omarchy-settings-dev "$globby"
run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
fail "a path whose name contains glob characters is not resolved"
[[ -f "$replaced$globby" && ! -e "$globby" ]] ||
fail "a path whose name contains glob characters is treated as a pattern"
pass "a path whose name would act as a glob is moved literally"
# A leftover directory is cleared the same way a file is.
fresh_work
conflict_dir="$work/omarchy-dir"
mkdir -p "$conflict_dir"
write_report omarchy-settings-dev "$conflict_dir"
run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
fail "a conflicting directory is not cleared out of pacman's way"
[[ -d "$replaced$conflict_dir" && ! -e $conflict_dir ]] ||
fail "a conflicting directory is left in place"
pass "a conflicting directory is moved away"
# A space is legal in a package path; the parse must not truncate it.
fresh_work
spaced="$work/omarchy theme.conf"
echo "spaced" >"$spaced"
write_report omarchy-settings-dev "$spaced"
run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
fail "a conflicting path containing a space is not resolved"
[[ -f "$replaced$spaced" && ! -e "$spaced" ]] ||
fail "a conflicting path containing a space is truncated"
pass "a conflicting path containing a space is parsed whole"
# An earlier quarantined copy is the more original one, and might not be ours.
fresh_work
echo "current" >"$stray"
mkdir -p "$replaced$work"
echo "from an earlier run" >"$replaced$stray"
write_report omarchy-settings-dev "$stray"
run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
fail "a conflict with an existing quarantined copy is not resolved"
grep -qrx "from an earlier run" "$replaced" ||
fail "an existing quarantined copy is destroyed to make room for a new one"
pass "an existing quarantined copy survives a later run needing the same name"
# mv without -T would move the source inside an existing destination directory.
fresh_work
echo "ours" >"$stray"
mkdir -p "$replaced$stray"
write_report omarchy-settings-dev "$stray"
run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
fail "a conflict whose destination is a directory is not resolved"
[[ -f "$replaced$stray" ]] ||
fail "the leftover was moved inside the existing destination directory"
pass "an existing destination directory is replaced, not moved into"
# One healable conflict beside one that is not. Moving only the first leaves the
# retry blocked by the second, and that config inactive for nothing.
fresh_work
echo "ours" >"$stray"
write_raw_report \
"omarchy-settings-dev: $stray exists in filesystem" \
"some-package: $work/theirs exists in filesystem (owned by other-package)"
if run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
fail "an upgrade with an unhealable conflict reports success"
fi
[[ -e $stray && ! -e $replaced$stray ]] ||
fail "a healable conflict is moved even though another conflict dooms the retry"
pass "nothing moves unless every reported conflict is healable"
# The retry can still fail for an unrelated reason. Leave nothing inactive.
fresh_work
echo "ours" >"$stray"
write_report omarchy-settings-dev "$stray"
if RETRY_FAILS=1 run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
fail "a failed retry reports success"
fi
[[ -f $stray ]] ||
fail "a failed retry leaves the file moved away and inactive"
grep -qx "ours" "$stray" ||
fail "the restored file is not the original content"
[[ $(cat "$test_tmp/attempts") == 2 ]] ||
fail "the handler and the upgrade re-invoke each other instead of stopping"
pass "a failed retry puts the files back, without re-invoking the handler"
# A retry that failed after committing the files has nothing to restore, and
# should not announce a restore it is not doing.
fresh_work
echo "ours" >"$stray"
write_report omarchy-settings-dev "$stray"
if RETRY_FAILS=1 RETRY_INSTALLS="$stray" run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
fail "a failed retry reports success"
fi
grep -qi "restoring" "$test_tmp/out" "$test_tmp/err" &&
fail "a restore is announced when no file is put back"
grep -qx "packaged" "$stray" ||
fail "the file pacman installed was overwritten by the restore"
pass "no restore is announced when there is nothing to put back"
# A dangling symlink reads as absent to -e, so a relative link that no longer
# resolves from inside the quarantine must still be recognised and put back.
fresh_work
ln -s ./neighbour "$stray"
write_report omarchy-settings-dev "$stray"
if RETRY_FAILS=1 run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
fail "a failed retry reports success"
fi
[[ -L $stray ]] ||
fail "a symlink that dangles from the quarantine is never restored"
pass "a dangling symlink is restored rather than stranded in the quarantine"
# The handler acts on a pacman report; an old or hand-written one would move
# live files aside for an upgrade that is not happening.
fresh_work
echo "ours" >"$stray"
write_report omarchy-settings-dev "$stray"
if PATH="$stub_bin:$ROOT/bin:$PATH" OMARCHY_REPLACED_DIR="$replaced" \
bash "$ROOT/bin/omarchy-update-system-pkgs-when-conflicted" "$test_tmp/report" \
>"$test_tmp/out" 2>"$test_tmp/err"; then
fail "the handler acts on a report handed to it outside an update"
fi
[[ -f $stray ]] ||
fail "the handler moved a live file when run outside an update"
pass "the handler refuses a report handed to it outside an update"
# The happy path must not pay for any of this.
fresh_work
: >"$test_tmp/report"
echo 1 >"$test_tmp/attempts"
run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
fail "a clean upgrade fails"
[[ $(cat "$test_tmp/attempts") == 2 ]] ||
fail "a clean upgrade runs more than one pacman transaction"
pass "a clean upgrade runs a single pacman transaction"