fix: deref legacy symlinks (#127)

* fix: deref legacy syms before deploy

Deploy will completely overwrite symlinked dirs, so you'd lose all extra
content inside them
Also deref syms in config backup before deleting legacy dir

* fix: restore link if deref fails
This commit is contained in:
2 * r + 2 * t
2026-06-19 17:11:39 +10:00
committed by GitHub
parent 096e583618
commit cfc62f683e
2 changed files with 89 additions and 19 deletions
+60 -1
View File
@@ -4,7 +4,13 @@ from argparse import Namespace
from pathlib import Path from pathlib import Path
from caelestia.utils.dots.deployer import Deployer from caelestia.utils.dots.deployer import Deployer
from caelestia.utils.dots.legacy import LEGACY_META_PKG, detect_legacy_repo, legacy_to_delete from caelestia.utils.dots.legacy import (
LEGACY_META_PKG,
detect_legacy_repo,
legacy_config_symlinks,
legacy_symlinks,
legacy_to_delete,
)
from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError
from caelestia.utils.dots.misc import build_local_packages, run_hooks from caelestia.utils.dots.misc import build_local_packages, run_hooks
from caelestia.utils.dots.packages import DEFAULT_AUR_HELPER, PackageError, PackageInstaller from caelestia.utils.dots.packages import DEFAULT_AUR_HELPER, PackageError, PackageInstaller
@@ -23,6 +29,21 @@ def _parse_list_arg(value: str | None) -> list[str] | None:
return [item.strip() for item in value.split(",") if item.strip()] return [item.strip() for item in value.split(",") if item.strip()]
def _deref_symlink(link: Path, target: Path) -> None:
"""Replace symlink `link` with a real copy of `target`'s content."""
bak = link.rename(link.parent / f"{link.name}.bak")
try:
if target.is_dir():
shutil.copytree(target, link, symlinks=True)
else:
shutil.copy2(target, link)
except OSError:
bak.rename(link)
raise
bak.unlink()
class Command: class Command:
args: Namespace args: Namespace
@@ -43,6 +64,7 @@ class Command:
except PackageError as e: except PackageError as e:
fatal(e) fatal(e)
run_hooks(manifest, "post_package") run_hooks(manifest, "post_package")
self.dereference_legacy(legacy_dir) # Copy legacy content into place before deploy overwrites the symlinks
deployed = self.deploy_configs(source, manifest) deployed = self.deploy_configs(source, manifest)
run_hooks(manifest, "post_install") run_hooks(manifest, "post_install")
@@ -171,6 +193,42 @@ class Command:
return installer, packages, local_packages return installer, packages, local_packages
def dereference_legacy(self, legacy_dir: Path | None) -> None:
"""Replace legacy symlinks with real copies of their targets."""
symlinks = legacy_symlinks(legacy_dir)
if not symlinks:
return
print()
log("Preserving content from legacy symlinks...")
for path in symlinks:
target = path.resolve()
if not target.exists():
continue
try:
_deref_symlink(path, target)
info(f"Copied {target} -> {path}")
except OSError as e:
warn(f"failed to preserve {path}: {e}")
def deref_backup_syms(self, legacy_dir: Path | None) -> None:
"""Deref the backup's legacy symlinks before the repo is cleared, so the backup keeps real content."""
if not config_backup_dir.is_dir():
return
for link in legacy_config_symlinks(config_backup_dir, legacy_dir):
target = link.resolve()
if not target.exists():
continue
try:
_deref_symlink(link, target)
except OSError as e:
warn(f"failed to preserve {link} in backup: {e}")
def migrate_legacy(self, installer: PackageInstaller, legacy_dir: Path | None) -> None: def migrate_legacy(self, installer: PackageInstaller, legacy_dir: Path | None) -> None:
"""Clean up a previous install.fish setup (repo, symlinks and metapackage).""" """Clean up a previous install.fish setup (repo, symlinks and metapackage)."""
@@ -186,6 +244,7 @@ class Command:
deployer = Deployer() deployer = Deployer()
try: try:
self.deref_backup_syms(legacy_dir)
for path in to_delete: for path in to_delete:
deployer.remove(path) deployer.remove(path)
info(f"Deleted {path}") info(f"Deleted {path}")
+29 -18
View File
@@ -46,6 +46,10 @@ def _find_legacy_repo(path: Path) -> Path | None:
return path return path
def _filter_candidates(candidates: list[Path], legacy_dir: Path) -> list[Path]:
return [path for path in candidates if path.is_symlink() and legacy_dir in path.resolve().parents]
def detect_legacy_repo() -> Path | None: def detect_legacy_repo() -> Path | None:
for conf in _confs: for conf in _confs:
path = config_dir / conf path = config_dir / conf
@@ -59,25 +63,32 @@ def detect_legacy_repo() -> Path | None:
return _find_legacy_repo(data_dir / "caelestia") return _find_legacy_repo(data_dir / "caelestia")
def legacy_config_symlinks(base: Path, legacy_dir: Path | None) -> list[Path]:
"""Config-relative links install.fish created, resolved under `base` (the live config or a backup of it)."""
if not legacy_dir:
return []
candidates = [base / conf for conf in _confs]
return _filter_candidates(candidates, legacy_dir)
def legacy_symlinks(legacy_dir: Path | None) -> list[Path]:
"""All paths symlinked into the legacy repo (the links install.fish created)."""
if not legacy_dir:
return []
extras = [
*(Path.home() / ".zen").glob("*/chrome/userChrome.css"),
Path.home() / ".local/lib/caelestia/caelestiafox",
]
return [*legacy_config_symlinks(config_dir, legacy_dir), *_filter_candidates(extras, legacy_dir)]
def legacy_to_delete(legacy_dir: Path | None) -> list[Path]: def legacy_to_delete(legacy_dir: Path | None) -> list[Path]:
if not legacy_dir: if not legacy_dir:
return [] return []
to_delete = [] return [*legacy_symlinks(legacy_dir), legacy_dir]
for conf in _confs:
path = config_dir / conf
if path.is_symlink() and legacy_dir in path.resolve().parents:
to_delete.append(path)
others = [
*(Path.home() / ".zen").glob("*/chrome/userChrome.css"),
Path.home() / ".local/lib/caelestia/caelestiafox",
]
for path in others:
if path.is_symlink() and legacy_dir in path.resolve().parents:
to_delete.append(path)
to_delete.append(legacy_dir)
return to_delete