Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,41 @@ all of that app's factory-image changes to an existing VM, and an in-guest
update should not be assumed to reproduce them. A confirmed reset is the
deliberate, destructive way to start again from the newest bundled factory.

### Repairing update holds in an older guest

Older guests may fail Omarchy Update with conflicting `libaquamarine.so`
dependencies. New factory images hold the compatible Hyprland, aquamarine,
and Hyprtoolkit packages together, along with the direct-boot kernel and
headers. Updating the Mac app does not add these holds to an existing guest.

Copy `guest/scripts/repair-update-holds.py` from this source checkout into the
guest, then run it **inside Omarchy**, with the updater closed:

```sh
python3 repair-update-holds.py # preview only
sudo python3 repair-update-holds.py --apply
```

The command adds missing holds to both `/usr/share/try-omarchy/pacman.conf`
and `/etc/pacman.conf`. The first file is essential: Omarchy's pre-refresh
hook restores it over the second before updating. Existing holds, comments,
repository definitions, and unrelated settings are retained in each file.
Keep any custom settings you want to survive an update in the saved share
copy too; the existing update hook still replaces the active configuration.

The repair prints a backup directory under
`/var/lib/try-omarchy/update-holds-backup.*`, preserving both original files
under their relative paths. To undo it, close the updater and restore each
backup to its original location with `sudo cp -p`. Running the repair again
makes no changes when the holds are already present. It refuses to write
while pacman has a transaction lock.

Then retry **Update → Omarchy**. This command only repairs the hold list; it
does not install, downgrade, or upgrade packages, and cannot repair packages
that were already upgraded into an incompatible combination. If dependency
errors remain, retain the full error output for diagnosis instead of removing
the kernel or compositor holds.

### Growing an existing VM disk

To add capacity without resetting the VM, shut down Omarchy and run the
Expand Down
140 changes: 140 additions & 0 deletions guest/scripts/repair-update-holds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Repair package holds in an existing Try Omarchy guest without upgrading it."""

import argparse
import os
from pathlib import Path
import platform
import re
import shutil
import stat
import tempfile


HOLDS = ("linux-aarch64", "linux-aarch64-headers", "hyprland", "aquamarine", "hyprtoolkit")
CONFIGS = ("usr/share/try-omarchy/pacman.conf", "etc/pacman.conf")


def add_holds(text):
lines = text.splitlines(keepends=True)
section = None
options = []
directives = []
present = set()
for index, line in enumerate(lines):
content = line.split("#", 1)[0].strip()
if content.startswith("[") and content.endswith("]"):
section = content[1:-1]
if section == "options":
options.append(index)
elif section == "options" and re.match(r"IgnorePkg\s*=", content):
directives.append(index)
present.update(content.split("=", 1)[1].split())
if len(options) != 1:
raise ValueError("expected exactly one [options] section")
missing = [name for name in HOLDS if name not in present]
if not missing:
return text
if directives:
index = directives[0]
line = lines[index]
body = line.rstrip("\r\n")
ending = line[len(body):]
value, marker, comment = body.partition("#")
lines[index] = value.rstrip() + " " + " ".join(missing)
if marker:
lines[index] += " " + marker + comment
lines[index] += ending
else:
index = options[0]
ending = "\r\n" if lines[index].endswith("\r\n") else "\n"
if not lines[index].endswith("\n"):
lines[index] += ending
lines.insert(index + 1, "IgnorePkg = " + " ".join(missing) + ending)
return "".join(lines)


def replace_file(path, content):
info = path.stat()
fd, name = tempfile.mkstemp(prefix=".try-omarchy-holds-", dir=path.parent)
temporary = Path(name)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
if os.geteuid() == 0:
os.fchown(stream.fileno(), info.st_uid, info.st_gid)
os.fchmod(stream.fileno(), stat.S_IMODE(info.st_mode))
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)


def repair(root, apply=False):
plans = []
for relative in CONFIGS:
path = root / relative
if path.is_symlink() or not path.is_file():
raise ValueError(f"missing or symlinked configuration: /{relative}")
before = path.read_bytes()
after = add_holds(before.decode("utf-8")).encode("utf-8")
plans.append((path, before, after))
print(f"/{relative}: {'already correct' if before == after else 'add missing compatibility holds'}")
changes = [plan for plan in plans if plan[1] != plan[2]]
if not changes:
print("Both configurations already contain the required holds; nothing changed.")
return
if not apply:
print("Required holds: " + " ".join(HOLDS))
print("Preview only. Run with sudo and --apply to save both configurations.")
return

# Cooperate with pacman so a package transaction cannot overlap the repair.
lock = root / "var/lib/pacman/db.lck"
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
os.close(fd)
try:
for path, before, _ in plans:
if path.read_bytes() != before:
raise ValueError("configuration changed during repair; retry when the updater is closed")
backup_root = root / "var/lib/try-omarchy"
backup_root.mkdir(parents=True, exist_ok=True)
backup = Path(tempfile.mkdtemp(prefix="update-holds-backup.", dir=backup_root))
for path, _, _ in plans:
destination = backup / path.relative_to(root)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, destination)
print(f"Previous configurations retained in {backup}")
written = []
try:
for path, before, after in changes:
replace_file(path, after)
written.append((path, before))
except OSError:
for path, before in reversed(written):
replace_file(path, before)
raise
print("Guest update holds repaired. Retry Omarchy Update.")
finally:
lock.unlink()


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true", help="back up and update both configurations")
args = parser.parse_args()
if platform.system() != "Linux" or platform.machine() != "aarch64":
parser.error("run this command inside the Try Omarchy ARM guest")
if "omarchy.qemu_virgl=1" not in Path("/proc/cmdline").read_text().split():
parser.error("this guest does not have the Try Omarchy boot marker")
if args.apply and os.geteuid() != 0:
parser.error("--apply requires sudo")
try:
repair(Path("/"), args.apply)
except (OSError, ValueError) as error:
parser.exit(1, f"repair-update-holds: {error}\nNo packages were installed or upgraded. Close the updater before retrying; do not remove an active pacman lock.\n")


if __name__ == "__main__":
main()
120 changes: 120 additions & 0 deletions guest/tests/test_repair_update_holds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import contextlib
import importlib.util
import io
from pathlib import Path
import stat
import tempfile
import unittest
from unittest.mock import patch


GUEST = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("repair_update_holds", GUEST / "scripts/repair-update-holds.py")
repair = importlib.util.module_from_spec(spec)
spec.loader.exec_module(repair)


class UpdateHoldsTests(unittest.TestCase):
def test_preserves_custom_settings_comments_and_multiple_directives(self):
original = "# custom\n[options]\n IgnorePkg = custom linux-aarch64 # keep this\nIgnorePkg = aquamarine\nUnknownSetting = yes\n[custom]\nServer = https://example.com/$arch\n"
result = repair.add_holds(original)
self.assertIn("custom linux-aarch64 linux-aarch64-headers hyprland hyprtoolkit # keep this", result)
self.assertTrue(result.endswith("IgnorePkg = aquamarine\nUnknownSetting = yes\n[custom]\nServer = https://example.com/$arch\n"))
self.assertEqual(repair.add_holds(result), result)

def test_missing_directive_added_inside_options(self):
for original in ("[options]", "# IgnorePkg = ignored\n[options]\nColor\n[core]\nServer = example\n"):
result = repair.add_holds(original)
self.assertIn("[options]\nIgnorePkg = " + " ".join(repair.HOLDS), result)
self.assertEqual(repair.add_holds(result), result)

def test_crlf_and_absent_final_newline_preserved(self):
original = "[options]\r\nIgnorePkg = custom # comment\r\n[core]\r\nServer = example"
result = repair.add_holds(original)
self.assertNotIn("\n", result.replace("\r\n", ""))
self.assertTrue(result.endswith("Server = example"))

def test_rejects_missing_or_duplicate_options(self):
for original in ("IgnorePkg = custom\n[core]\n", "[options]\n[options]\n"):
with self.assertRaises(ValueError):
repair.add_holds(original)

def test_holds_match_factory_configuration(self):
text = (GUEST / "pacman.aarch64.conf").read_text()
holds = next(line.split("=", 1)[1].split() for line in text.splitlines() if line.startswith("IgnorePkg ="))
self.assertEqual(set(holds), set(repair.HOLDS))

def setUp(self):
output = contextlib.redirect_stdout(io.StringIO())
output.__enter__()
self.addCleanup(output.__exit__, None, None, None)
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.paths = [self.root / relative for relative in repair.CONFIGS]
for path in self.paths:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("[options]\nIgnorePkg = linux-aarch64 custom\n[extra]\nServer = example\n")
path.chmod(0o640)
(self.root / "var/lib/pacman").mkdir(parents=True)
self.originals = [path.read_bytes() for path in self.paths]

def test_preview_has_no_side_effects(self):
repair.repair(self.root)
self.assertEqual([p.read_bytes() for p in self.paths], self.originals)
self.assertFalse((self.root / "var/lib/try-omarchy").exists())
self.assertFalse((self.root / "var/lib/pacman/db.lck").exists())

def test_apply_backup_modes_hook_restore_and_repeat(self):
repair.repair(self.root, apply=True)
backups = list((self.root / "var/lib/try-omarchy").iterdir())
self.assertEqual(len(backups), 1)
for path, before in zip(self.paths, self.originals):
self.assertEqual((backups[0] / path.relative_to(self.root)).read_bytes(), before)
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640)
# The existing pre-refresh hook restores the saved configuration.
self.paths[1].write_bytes(self.paths[0].read_bytes())
after = [p.read_bytes() for p in self.paths]
repair.repair(self.root, apply=True)
self.assertEqual([p.read_bytes() for p in self.paths], after)
self.assertEqual(list((self.root / "var/lib/try-omarchy").iterdir()), backups)
self.assertFalse((self.root / "var/lib/pacman/db.lck").exists())

def test_checks_both_files_before_writing(self):
self.paths[1].write_text("[core]\n")
with self.assertRaises(ValueError):
repair.repair(self.root, apply=True)
self.assertEqual(self.paths[0].read_bytes(), self.originals[0])

def test_refuses_symlink(self):
self.paths[1].unlink()
self.paths[1].symlink_to(self.paths[0])
with self.assertRaises(ValueError):
repair.repair(self.root, apply=True)
self.assertEqual(self.paths[0].read_bytes(), self.originals[0])

def test_active_pacman_lock_is_not_removed(self):
lock = self.root / "var/lib/pacman/db.lck"
lock.write_text("in use")
with self.assertRaises(FileExistsError):
repair.repair(self.root, apply=True)
self.assertEqual(lock.read_text(), "in use")
self.assertEqual([p.read_bytes() for p in self.paths], self.originals)

def test_second_write_failure_restores_first(self):
replace = repair.replace_file

def fail_second(path, content):
if path == self.paths[1]:
raise OSError("simulated write failure")
replace(path, content)

with patch.object(repair, "replace_file", side_effect=fail_second):
with self.assertRaises(OSError):
repair.repair(self.root, apply=True)
self.assertEqual([p.read_bytes() for p in self.paths], self.originals)
self.assertFalse((self.root / "var/lib/pacman/db.lck").exists())


if __name__ == "__main__":
unittest.main()
Loading