From 0980986ed43697c21514521fb3976bfdf0633658 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:58:06 +1000 Subject: [PATCH 01/17] refactor: move expand/expand_dests to entry methods --- src/caelestia/subcommands/install.py | 6 ++--- src/caelestia/utils/dots/manifest.py | 40 +++++++++++++++------------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/caelestia/subcommands/install.py b/src/caelestia/subcommands/install.py index a8bc830..036a526 100644 --- a/src/caelestia/subcommands/install.py +++ b/src/caelestia/subcommands/install.py @@ -6,7 +6,7 @@ from argparse import Namespace from pathlib import Path from caelestia.utils.dots.deployer import Deployer -from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError, expand, expand_dests +from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError from caelestia.utils.dots.packages import DEFAULT_AUR_HELPER, PackageInstaller from caelestia.utils.dots.source import DotsSource, SourceError from caelestia.utils.dots.state import DotsState @@ -180,12 +180,12 @@ class Command: log("Installing configs...") deployer = Deployer() for entry in manifest.enabled_entries(): - src = source.working_path(expand(entry.src)) + src = source.working_path(entry.expanded_src()) if not src.exists(): warn(f"missing in source, skipping: {entry.src}") continue - dests = expand_dests(entry.dest) + dests = entry.expanded_dests() if not dests: warn(f"dest glob matched nothing, skipping: {entry.dest}") continue diff --git a/src/caelestia/utils/dots/manifest.py b/src/caelestia/utils/dots/manifest.py index f4acfb5..abf0f40 100644 --- a/src/caelestia/utils/dots/manifest.py +++ b/src/caelestia/utils/dots/manifest.py @@ -25,36 +25,38 @@ class ComponentError(Exception): """Raised when component flags are invalid or contradictory.""" -def expand(text: str) -> Path: +def _expand(text: str) -> Path: """Expand $VAR/${VAR} env vars (with XDG defaults) and ~ in a path.""" env = {**_XDG_DEFAULTS, **os.environ} return Path(Template(text).safe_substitute(env)).expanduser() -def expand_dests(dest: str) -> list[Path]: - """Expand globs within a dest path. - - Globs from the start until the segment with the last glob so subdirs are - created if they didn't exist previously. - """ - - expanded = expand(dest) - if not _GLOB_MAGIC.search(str(expanded)): - return [expanded] - - parts = expanded.parts - glob_idx = max(i for i, part in enumerate(parts) if _GLOB_MAGIC.search(part)) - pattern = str(Path(*parts[: glob_idx + 1])) - tail = parts[glob_idx + 1 :] - return [Path(match, *tail) for match in sorted(glob.glob(pattern))] - - @dataclass(frozen=True) class ManifestEntry: src: str dest: str + def expanded_src(self) -> Path: + return _expand(self.src) + + def expanded_dests(self) -> list[Path]: + """The dest path with globs expanded. + + Globs from the start until the segment with the last glob so subdirs are + created if they didn't exist previously. + """ + + expanded = _expand(self.dest) + if not _GLOB_MAGIC.search(str(expanded)): + return [expanded] + + parts = expanded.parts + glob_idx = max(i for i, part in enumerate(parts) if _GLOB_MAGIC.search(part)) + pattern = str(Path(*parts[: glob_idx + 1])) + tail = parts[glob_idx + 1 :] + return [Path(match, *tail) for match in sorted(glob.glob(pattern))] + @dataclass(frozen=True) class ManifestComponent: From be61b8b07288a2d5a1bf5a56fd6d4f2581c35528 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:59:54 +1000 Subject: [PATCH 02/17] feat: record files deployed --- src/caelestia/subcommands/install.py | 7 +++++-- src/caelestia/utils/dots/deployer.py | 13 +++++++++++-- src/caelestia/utils/dots/state.py | 6 ++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/caelestia/subcommands/install.py b/src/caelestia/subcommands/install.py index 036a526..de53607 100644 --- a/src/caelestia/subcommands/install.py +++ b/src/caelestia/subcommands/install.py @@ -38,7 +38,7 @@ class Command: self.create_backup() source, tip, manifest = self.fetch_manifest() - self.deploy_configs(source, manifest) + deployed = self.deploy_configs(source, manifest) helper, packages, local_packages = self.install_packages(source, manifest) self.run_hooks(manifest) @@ -48,6 +48,7 @@ class Command: enabled_components=manifest.enabled_components, packages=packages, local_packages=local_packages, + deployed_files=deployed, ).save() self.print_done() @@ -175,7 +176,7 @@ class Command: manifest.resolve_components(enable=enabled) return - def deploy_configs(self, source: DotsSource, manifest: Manifest) -> None: + def deploy_configs(self, source: DotsSource, manifest: Manifest) -> dict[str, str]: print() log("Installing configs...") deployer = Deployer() @@ -194,6 +195,8 @@ class Command: deployer.place(src, Path(dest)) info(f"{entry.src} -> {dest}") + return deployer.deployed_files + def install_packages(self, source: DotsSource, manifest: Manifest) -> tuple[str, list[str], dict[str, list[str]]]: installer = PackageInstaller.get(self.args.aur_helper, self.args.noconfirm) diff --git a/src/caelestia/utils/dots/deployer.py b/src/caelestia/utils/dots/deployer.py index e8f766b..12d80e8 100644 --- a/src/caelestia/utils/dots/deployer.py +++ b/src/caelestia/utils/dots/deployer.py @@ -2,10 +2,15 @@ import shutil import tempfile from pathlib import Path +from caelestia.utils.paths import dots_dir + class Deployer: """Places files from the dots clone into their destinations.""" + def __init__(self): + self.deployed_files: dict[str, str] = {} + def place(self, src: Path, dest: Path) -> None: """Place a whole entry (file or directory tree), replacing any existing dest.""" @@ -27,7 +32,7 @@ class Deployer: elif path.is_dir(): (dest / path.relative_to(src)).mkdir(parents=True, exist_ok=True) - def place_file(self, src: Path, dest: Path) -> None: + def place_file(self, src: Path, dest: Path, record: bool = True) -> None: """Atomically place a single file, replacing any existing dest.""" if dest.is_dir() and not dest.is_symlink(): @@ -44,11 +49,15 @@ class Deployer: Path(f.name).unlink() raise + if record: + # Keep relative to dots dir + self.deployed_files[str(dest)] = str(src.relative_to(dots_dir)) + def write_new(self, src: Path, dest: Path) -> Path: """Write the upstream version alongside dest as .new and return that path.""" new_path = dest.parent / f"{dest.name}.new" - self.place_file(src, new_path) + self.place_file(src, new_path, record=False) return new_path def remove(self, path: Path) -> None: diff --git a/src/caelestia/utils/dots/state.py b/src/caelestia/utils/dots/state.py index d47f3c9..8a9aabc 100644 --- a/src/caelestia/utils/dots/state.py +++ b/src/caelestia/utils/dots/state.py @@ -21,6 +21,10 @@ class DotsState: packages: list[str] = field(default_factory=list) local_packages: dict[str, list[str]] = field(default_factory=dict) + # Files placed by the last deploy. Only files, not directories + # Maps dest -> src + deployed_files: dict[str, str] = field(default_factory=dict) + @staticmethod def load() -> "DotsState": try: @@ -37,6 +41,7 @@ class DotsState: enabled_components=data.get("enabled_components", []), packages=data.get("packages", []), local_packages=data.get("local_packages", {}), + deployed_files=data.get("deployed_files", {}), ) def save(self) -> None: @@ -48,5 +53,6 @@ class DotsState: "enabled_components": self.enabled_components, "packages": self.packages, "local_packages": self.local_packages, + "deployed_files": self.deployed_files, }, ) From 7def47d120870985a162be56c095993b5a794b0b Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:53:05 +1000 Subject: [PATCH 03/17] feat: add diff module --- src/caelestia/utils/dots/diff.py | 94 ++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/caelestia/utils/dots/diff.py diff --git a/src/caelestia/utils/dots/diff.py b/src/caelestia/utils/dots/diff.py new file mode 100644 index 0000000..16bced5 --- /dev/null +++ b/src/caelestia/utils/dots/diff.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass, field +from pathlib import Path + +from caelestia.utils.dots.manifest import ManifestEntry +from caelestia.utils.dots.source import DotsSource, SourceError + + +class _Continue(Exception): + """Signals the deployed-files loop to skip to the next entry.""" + + +@dataclass(frozen=True) +class Changeset: + place: list[tuple[str, Path]] = field(default_factory=list) # (repofile, dest) to fast-forward + conflicts: list[tuple[str, Path]] = field(default_factory=list) # (repofile, dest) -> write .new + deletes: list[Path] = field(default_factory=list) # We placed it, upstream removed it, unmodified + stale: list[Path] = field(default_factory=list) # Upstream removed it but user modified it + + def is_empty(self) -> bool: + return not (self.place or self.conflicts or self.deletes or self.stale) + + @staticmethod + def compute( + source: DotsSource, + applied_rev: str, + tip: str, + entries: list[ManifestEntry], + deployed: dict[str, str], + ) -> "Changeset": + """Collect all file changes needed into a Changeset.""" + + changed = set(source.changed_files(applied_rev, tip)) + place: list[tuple[str, Path]] = [] + conflicts: list[tuple[str, Path]] = [] + deletes: list[Path] = [] + stale: list[Path] = [] + + # Collect all files to deploy (entry sources can be dirs so we recurse into them) + to_deploy: dict[Path, str] = {} + for entry in entries: + src_root = str(entry.expanded_src()) + repo_files = source.files_at(tip, src_root) + for dest in entry.expanded_dests(): + for repo_file in repo_files: + to_deploy[dest / Path(repo_file).relative_to(src_root)] = repo_file + files_to_deploy = set(to_deploy) + + # Already deployed files + for dest, src in deployed.items(): + dest_path = Path(dest) + + def try_read(rev: str, path: str) -> bytes: + try: + return source.blob_at(rev, path) + except SourceError: + # Read failed, keep it just in case + stale.append(dest_path) + raise _Continue + + try: + if dest_path not in files_to_deploy: # Removed file + if not dest_path.exists(): + continue + + if try_read(applied_rev, src) == dest_path.read_bytes(): + deletes.append(dest_path) + else: + stale.append(dest_path) + elif src in changed: # Existing file that needs updating + if not dest_path.exists(): + place.append((src, dest_path)) + continue + + dest_content = dest_path.read_bytes() + if try_read(tip, src) == dest_content: + continue # File is already up to date + + if try_read(applied_rev, src) == dest_content: + place.append((src, dest_path)) + else: + conflicts.append((src, dest_path)) + except _Continue: + continue + + # New files to deploy + for dest in files_to_deploy - set(Path(d) for d in deployed): + src = to_deploy[dest] + if not dest.exists() or source.blob_at(tip, src) == dest.read_bytes(): + # Dest nonexistent or already equal to new content + place.append((src, dest)) + else: + conflicts.append((src, dest)) + + return Changeset(place=place, conflicts=conflicts, deletes=deletes, stale=stale) From 222023f6d5a184ea33034c4f5d3b0d9e4a13ee18 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:20:50 +1000 Subject: [PATCH 04/17] fix: handle applied rev not existing in diff --- src/caelestia/utils/dots/diff.py | 24 +++++++++++++++--------- src/caelestia/utils/dots/source.py | 9 +++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/caelestia/utils/dots/diff.py b/src/caelestia/utils/dots/diff.py index 16bced5..164d032 100644 --- a/src/caelestia/utils/dots/diff.py +++ b/src/caelestia/utils/dots/diff.py @@ -29,7 +29,8 @@ class Changeset: ) -> "Changeset": """Collect all file changes needed into a Changeset.""" - changed = set(source.changed_files(applied_rev, tip)) + has_base = source.has_rev(applied_rev) + changed = set(source.changed_files(applied_rev, tip)) if has_base else set() place: list[tuple[str, Path]] = [] conflicts: list[tuple[str, Path]] = [] deletes: list[Path] = [] @@ -58,27 +59,32 @@ class Changeset: raise _Continue try: - if dest_path not in files_to_deploy: # Removed file + if dest_path not in files_to_deploy: # No longer managed by any entry if not dest_path.exists(): continue - if try_read(applied_rev, src) == dest_path.read_bytes(): + if has_base and try_read(applied_rev, src) == dest_path.read_bytes(): deletes.append(dest_path) else: stale.append(dest_path) - elif src in changed: # Existing file that needs updating + else: # Still managed; `src` is what we last placed, `new_src` the current source + new_src = to_deploy[dest_path] + if has_base and new_src == src and new_src not in changed: + continue # Unchanged upstream + if not dest_path.exists(): - place.append((src, dest_path)) + place.append((new_src, dest_path)) continue dest_content = dest_path.read_bytes() - if try_read(tip, src) == dest_content: + if try_read(tip, new_src) == dest_content: continue # File is already up to date - if try_read(applied_rev, src) == dest_content: - place.append((src, dest_path)) + # Fast-forward only when the user hasn't edited since last deploy + if has_base and try_read(applied_rev, src) == dest_content: + place.append((new_src, dest_path)) else: - conflicts.append((src, dest_path)) + conflicts.append((new_src, dest_path)) except _Continue: continue diff --git a/src/caelestia/utils/dots/source.py b/src/caelestia/utils/dots/source.py index d4f79fd..19e498e 100644 --- a/src/caelestia/utils/dots/source.py +++ b/src/caelestia/utils/dots/source.py @@ -68,6 +68,15 @@ class DotsSource: out = self._git("diff", "--name-only", base, head) return [line for line in out.splitlines() if line] + def has_rev(self, rev: str) -> bool: + """Whether `rev` resolves to a commit.""" + + try: + self._git("rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}") + return True + except SourceError: + return False + def clean(self) -> None: """Remove all untracked files in the git repo.""" self._git("clean", "-fdx") From c8e18ef6ed7c1a711080fde8c389071ddc66bf68 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:21:21 +1000 Subject: [PATCH 05/17] refactor: use aur helpers constant for parser --- src/caelestia/parser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/caelestia/parser.py b/src/caelestia/parser.py index 2e24ed8..69ed85e 100644 --- a/src/caelestia/parser.py +++ b/src/caelestia/parser.py @@ -14,6 +14,7 @@ from caelestia.subcommands import ( wallpaper, ) from caelestia.utils.dots.manifest import Manifest +from caelestia.utils.dots.packages import AUR_HELPERS from caelestia.utils.dots.source import DotsSource from caelestia.utils.io import warn from caelestia.utils.paths import wallpapers_dir @@ -150,7 +151,7 @@ def parse_args() -> tuple[argparse.ArgumentParser, argparse.Namespace]: formatter_class=argparse.RawDescriptionHelpFormatter, ) install_parser.set_defaults(cls=install.Command) - install_parser.add_argument("--aur-helper", choices=["yay", "paru"], help="the AUR helper to use") + install_parser.add_argument("--aur-helper", choices=AUR_HELPERS, help="the AUR helper to use") install_parser.add_argument( "--enable-components", metavar="LIST", help="comma-separated list of components to enable" ) From 710cba39c3a3d90e6609e1246249096749626cef Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:57:17 +1000 Subject: [PATCH 06/17] refactor: reusable select prompt + hooks + local build --- src/caelestia/subcommands/install.py | 83 +++------------------------- src/caelestia/utils/dots/misc.py | 39 +++++++++++++ src/caelestia/utils/io.py | 51 +++++++++++++++++ 3 files changed, 97 insertions(+), 76 deletions(-) create mode 100644 src/caelestia/utils/dots/misc.py diff --git a/src/caelestia/subcommands/install.py b/src/caelestia/subcommands/install.py index de53607..11b2f9b 100644 --- a/src/caelestia/subcommands/install.py +++ b/src/caelestia/subcommands/install.py @@ -1,20 +1,18 @@ -import os import shutil -import subprocess import textwrap from argparse import Namespace from pathlib import Path from caelestia.utils.dots.deployer import Deployer from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError +from caelestia.utils.dots.misc import build_local_packages, run_hooks from caelestia.utils.dots.packages import DEFAULT_AUR_HELPER, PackageInstaller from caelestia.utils.dots.source import DotsSource, SourceError from caelestia.utils.dots.state import DotsState -from caelestia.utils.io import PROMPT_COLOUR, confirm, disable_input, fatal, format_msg, info, log, pause, prompt, warn +from caelestia.utils.io import confirm, disable_input, fatal, info, log, pause, prompt_selection, warn from caelestia.utils.paths import ( config_backup_dir, config_dir, - dots_dir, ) @@ -40,7 +38,7 @@ class Command: source, tip, manifest = self.fetch_manifest() deployed = self.deploy_configs(source, manifest) helper, packages, local_packages = self.install_packages(source, manifest) - self.run_hooks(manifest) + run_hooks(manifest, "post_install") DotsState( aur_helper=helper, @@ -126,55 +124,9 @@ class Command: if not comp_arr: return - print(format_msg(PROMPT_COLOUR, True, "Components to enable?")) - max_idx_w = len(str(len(comp_arr))) - for i, comp in enumerate(comp_arr): - print(format_msg(PROMPT_COLOUR, True, f" {i + 1:<{max_idx_w}}\t{comp}")) - print(format_msg(PROMPT_COLOUR, True, "[A]ll or (1 2 3, 1-3, ^4)")) - - def _valid_v(v: str) -> int: - try: - i_v = int(v, base=10) - 1 # -1 to translate to 0 index - except ValueError: - raise ValueError(f'Given value "{v}" must be an integer.') - if i_v < 0 or i_v >= len(comp_arr): - raise ValueError(f'Given value "{v}" must be between 1 and {len(comp_arr)} inclusive.') - return i_v - - def _parse(ans: str) -> list[str] | None: - if ans in ("a", "all"): - return list(manifest.components) - if not ans: - return None - - enabled: list[str] = [] - for tok in ans.split(): - fr, sep, to = tok.partition("-") - if sep: - fr = _valid_v(fr) - to = _valid_v(to) - if fr > to: - raise ValueError(f'Given range "{tok}" must be lo-hi.') - enabled += comp_arr[fr : to + 1] - elif tok.startswith("^"): - t = _valid_v(tok[1:]) - enabled += comp_arr[:t] + comp_arr[t + 1 :] - else: - t = _valid_v(tok) - enabled.append(comp_arr[t]) - return list(set(enabled)) - - while True: - ans = prompt("", end="").lower().strip() - try: - enabled = _parse(ans) - except ValueError as e: - warn(f"invalid input. {e} Please try again.") - continue - - if enabled is not None: - manifest.resolve_components(enable=enabled) - return + selected = prompt_selection(comp_arr, "Components to enable?") + if selected: + manifest.resolve_components(enable=selected) def deploy_configs(self, source: DotsSource, manifest: Manifest) -> dict[str, str]: print() @@ -211,31 +163,10 @@ class Command: if local_dirs: print() log("Building local packages...") - for path in local_dirs: - directory = source.working_path(path) - if not directory.is_dir(): - warn(f"missing in repo, skipping: {path}") - continue - - log(f"Building {path}...") - local_packages[path] = installer.build_install(directory) + local_packages = build_local_packages(installer, source, local_dirs) return getattr(installer, "helper", DEFAULT_AUR_HELPER), packages, local_packages - def run_hooks(self, manifest: Manifest) -> None: - hooks = manifest.enabled_hooks("post_install") - if not hooks: - return - - print() - log("Running post-install hooks...") - env = {**os.environ, "CAELESTIA_DOTS": str(dots_dir)} - for hook in hooks: - info(f"Running hook: {hook}") - result = subprocess.run(hook, shell=True, env=env) - if result.returncode != 0: - warn(f"hook exited with {result.returncode}") - def print_done(self) -> None: print() info("All done! Caelestia has been installed.") diff --git a/src/caelestia/utils/dots/misc.py b/src/caelestia/utils/dots/misc.py new file mode 100644 index 0000000..c3c15a8 --- /dev/null +++ b/src/caelestia/utils/dots/misc.py @@ -0,0 +1,39 @@ +import os +import subprocess + +from caelestia.utils.dots.manifest import Manifest +from caelestia.utils.dots.packages import PackageInstaller +from caelestia.utils.dots.source import DotsSource +from caelestia.utils.io import info, log, warn +from caelestia.utils.paths import dots_dir + + +def build_local_packages(installer: PackageInstaller, source: DotsSource, paths: list[str]) -> dict[str, list[str]]: + """Build and install each local PKGBUILD dir, returning {path: installed package names}.""" + + built: dict[str, list[str]] = {} + for path in paths: + directory = source.working_path(path) + if not directory.is_dir(): + warn(f"missing in repo, skipping: {path}") + continue + log(f"Building {path}...") + built[path] = installer.build_install(directory) + return built + + +def run_hooks(manifest: Manifest, kind: str) -> None: + """Run the global + enabled components' hooks of the given kind (e.g. "post_install").""" + + hooks = manifest.enabled_hooks(kind) + if not hooks: + return + + print() + log(f"Running {kind.replace('_', '-')} hooks...") + env = {**os.environ, "CAELESTIA_DOTS": str(dots_dir)} + for hook in hooks: + info(f"Running hook: {hook}") + result = subprocess.run(hook, shell=True, env=env) + if result.returncode != 0: + warn(f"hook exited with {result.returncode}") diff --git a/src/caelestia/utils/io.py b/src/caelestia/utils/io.py index 020ca45..a4a8f25 100644 --- a/src/caelestia/utils/io.py +++ b/src/caelestia/utils/io.py @@ -80,6 +80,57 @@ def confirm(msg: str, prefix: bool = True, default: bool = True) -> bool: return answer in ("y", "yes") +def prompt_selection(items: list[str], header: str) -> list[str]: + """Prompt the user to pick from a numbered list, returning the selected items. + + Accepts `[A]ll`/`a`, single indices, ranges (`1-3`) and exclusions (`^4`). + Empty input selects nothing. Re-prompts until the input parses. + """ + + print(format_msg(PROMPT_COLOUR, True, header)) + max_idx_w = len(str(len(items))) + for i, item in enumerate(items): + print(format_msg(PROMPT_COLOUR, True, f" {i + 1:<{max_idx_w}}\t{item}")) + print(format_msg(PROMPT_COLOUR, True, "[A]ll or (1 2 3, 1-3, ^4)")) + + def valid_idx(v: str) -> int: + try: + idx = int(v, base=10) - 1 # -1 to translate to 0 index + except ValueError: + raise ValueError(f'Given value "{v}" must be an integer.') + if idx < 0 or idx >= len(items): + raise ValueError(f'Given value "{v}" must be between 1 and {len(items)} inclusive.') + return idx + + def parse(ans: str) -> list[str]: + if ans in ("a", "all"): + return list(items) + if not ans: + return [] + + selected: list[str] = [] + for tok in ans.split(): + fr, sep, to = tok.partition("-") + if sep: + lo, hi = valid_idx(fr), valid_idx(to) + if lo > hi: + raise ValueError(f'Given range "{tok}" must be lo-hi.') + selected += items[lo : hi + 1] + elif tok.startswith("^"): + t = valid_idx(tok[1:]) + selected += items[:t] + items[t + 1 :] + else: + selected.append(items[valid_idx(tok)]) + return list(set(selected)) + + while True: + ans = prompt("", end="").lower().strip() + try: + return parse(ans) + except ValueError as e: + warn(f"invalid input. {e} Please try again.") + + def pause() -> None: if _disable_input: return From a0aa37bb9bf487d3a9c7b05bd0f6a1b95ac54457 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:05:46 +1000 Subject: [PATCH 07/17] fix: remove duplicate resolve component --- src/caelestia/subcommands/install.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/caelestia/subcommands/install.py b/src/caelestia/subcommands/install.py index 11b2f9b..6364b20 100644 --- a/src/caelestia/subcommands/install.py +++ b/src/caelestia/subcommands/install.py @@ -107,10 +107,14 @@ class Command: disable = _parse_list_arg(self.args.disable_components) try: manifest = source.manifest_at(tip) - manifest.resolve_components(enable=enable, disable=disable) + # No flags given, prompt user for non-default components if enable is None and disable is None: - self.prompt_optional_components(manifest) + optional = [name for name, comp in manifest.components.items() if not comp.default] + if optional: + enable = prompt_selection(optional, "Components to enable?") + + manifest.resolve_components(enable=enable, disable=disable) except (SourceError, ManifestError, ComponentError) as e: fatal(e) @@ -119,15 +123,6 @@ class Command: return source, tip, manifest - def prompt_optional_components(self, manifest: Manifest) -> None: - comp_arr = manifest.disabled_components - if not comp_arr: - return - - selected = prompt_selection(comp_arr, "Components to enable?") - if selected: - manifest.resolve_components(enable=selected) - def deploy_configs(self, source: DotsSource, manifest: Manifest) -> dict[str, str]: print() log("Installing configs...") From 4824483bbab67cdb78f014832ef3f21422bc1256 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:27:30 +1000 Subject: [PATCH 08/17] feat: implement update command --- src/caelestia/parser.py | 7 + src/caelestia/subcommands/update.py | 202 +++++++++++++++++++++++++++ src/caelestia/utils/dots/deployer.py | 19 ++- src/caelestia/utils/dots/packages.py | 9 ++ 4 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 src/caelestia/subcommands/update.py diff --git a/src/caelestia/parser.py b/src/caelestia/parser.py index 69ed85e..d6d8f45 100644 --- a/src/caelestia/parser.py +++ b/src/caelestia/parser.py @@ -11,6 +11,7 @@ from caelestia.subcommands import ( screenshot, shell, toggle, + update, wallpaper, ) from caelestia.utils.dots.manifest import Manifest @@ -161,6 +162,12 @@ def parse_args() -> tuple[argparse.ArgumentParser, argparse.Namespace]: install_parser.add_argument("--noconfirm", action="store_true", help="use defaults for all prompts") _set_install_epilog(install_parser) + # Create parser for update opts + update_parser = command_parser.add_parser("update", help="update the Caelestia dotfiles") + update_parser.set_defaults(cls=update.Command) + update_parser.add_argument("--aur-helper", choices=AUR_HELPERS, help="the AUR helper to use") + update_parser.add_argument("--noconfirm", action="store_true", help="use defaults for all prompts") + return parser, parser.parse_args() diff --git a/src/caelestia/subcommands/update.py b/src/caelestia/subcommands/update.py new file mode 100644 index 0000000..caac889 --- /dev/null +++ b/src/caelestia/subcommands/update.py @@ -0,0 +1,202 @@ +import sys +from argparse import Namespace +from pathlib import Path + +from caelestia.utils.dots.deployer import Deployer +from caelestia.utils.dots.diff import Changeset +from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError +from caelestia.utils.dots.misc import build_local_packages, run_hooks +from caelestia.utils.dots.packages import PackageInstaller +from caelestia.utils.dots.source import DotsSource, SourceError +from caelestia.utils.dots.state import DotsState +from caelestia.utils.io import disable_input, fatal, info, log, prompt_selection, warn + + +class Command: + args: Namespace + + def __init__(self, args: Namespace) -> None: + self.args = args + + def run(self) -> None: + if self.args.noconfirm: + disable_input() + + state = DotsState.load() + if state.applied_rev is None: + fatal("dots not installed yet. Run `caelestia install` first.") + + # Run system update + installer = PackageInstaller.get(self.args.aur_helper or state.aur_helper, self.args.noconfirm) + installer.system_update() + + # Get manifest or exit if up to date + source, tip, manifest = self.fetch_manifest(state, state.applied_rev) + + # Apply file changes + entries = manifest.enabled_entries() + try: + changeset = Changeset.compute(source, state.applied_rev, tip, entries, state.deployed_files) + source.checkout_tip() + except SourceError as e: + fatal(e) + new_files, placed = self.deploy_changeset(source, changeset) + + # Install new/remove old packages + desired = manifest.enabled_packages() + state.packages = self.sync_packages(installer, state.packages, desired) + + # Install new/remove old local PKGBUILD packages + desired_local = manifest.enabled_local_packages() + state.local_packages = self.sync_local_packages(installer, source, state.local_packages, desired_local) + + # Run hooks + run_hooks(manifest, "post_update") + + # Update state + deployed = dict(state.deployed_files) + for dest in (*changeset.deletes, *changeset.stale): + deployed.pop(str(dest), None) + deployed.update(placed) + state.deployed_files = deployed + state.applied_rev = tip + state.enabled_components = manifest.enabled_components + state.aur_helper = getattr(installer, "helper", state.aur_helper) + state.save() + + self.summarize(changeset, new_files) + + def fetch_manifest(self, state: DotsState, applied_rev: str) -> tuple[DotsSource, str, Manifest]: + print() + log("Fetching dots repo...") + source = DotsSource() + try: + source.ensure() + tip = source.tip_rev() + if tip == applied_rev: + info("Dots already up to date.") + sys.exit(0) + + manifest = source.manifest_at(tip) + if source.has_rev(applied_rev): + known = set(source.manifest_at(applied_rev).components) + else: + # Treat all components as known if rev is invalid so we don't overwrite existing prefs + known = set(manifest.components) + except (SourceError, ManifestError) as e: + fatal(e) + + # Enable components recorded at install time + any new components that are default on + enabled = [ + name + for name, comp in manifest.components.items() + if name in state.enabled_components or (name not in known and comp.default) + ] + + # Let the user opt into any new optional components + new_comps = [name for name, comp in manifest.components.items() if name not in known and not comp.default] + if new_comps: + info(f"New components: {', '.join(new_comps)}") + enabled += prompt_selection(new_comps, "Components to enable?") + + disabled = [name for name in manifest.components if name not in enabled] + try: + manifest.resolve_components(enable=enabled, disable=disabled) + except ComponentError as e: + fatal(e) + + info(f"Enabled components: {', '.join(enabled) or 'none'}") + + return source, tip, manifest + + def deploy_changeset(self, source: DotsSource, changeset: Changeset) -> tuple[list[Path], dict[str, str]]: + print() + + if changeset.is_empty(): + info("No configs to update.") + return [], {} + + log("Updating configs...") + deployer = Deployer() + + for repofile, dest in changeset.place: + src = source.working_path(repofile) + if not src.exists(): + warn(f"missing in source, skipping: {repofile}") + continue + deployer.place_file(src, dest) + info(f"{repofile} -> {dest}") + + new_files = [] + for repofile, dest in changeset.conflicts: + src = source.working_path(repofile) + if not src.exists(): + warn(f"missing in source, skipping: {repofile}") + continue + new_path = deployer.write_new(src, dest) + new_files.append(new_path) + warn(f"{dest} has local changes; upstream version written as {new_path.name}") + + for dest in changeset.deletes: + deployer.remove(dest) + deployer.prune_empty_dirs(dest, Path.home()) + info(f"Removed {dest}") + + return new_files, deployer.deployed_files + + def sync_packages(self, installer: PackageInstaller, current: list[str], desired: list[str]) -> list[str]: + to_install = [p for p in desired if p not in current] + to_remove = [p for p in current if p not in desired] + installed = list(current) + + if to_install: + print() + info(f"Installing new packages: {', '.join(to_install)}") + installer.install(to_install) + installed.extend(p for p in to_install if p not in installed) + + if to_remove: + print() + info(f"Packages no longer required: {', '.join(to_remove)}") + selected = prompt_selection(to_remove, "Packages to remove?") + if selected: + installer.remove(selected) + installed = [p for p in installed if p not in selected] + + return installed + + def sync_local_packages( + self, installer: PackageInstaller, source: DotsSource, current: dict[str, list[str]], desired: list[str] + ) -> dict[str, list[str]]: + to_build = [p for p in desired if p not in current] + to_remove = [p for p in current if p not in desired] + installed = dict(current) + + if to_build: + print() + log(f"Building new local packages: {', '.join(to_build)}") + installed.update(build_local_packages(installer, source, to_build)) + + if to_remove: + print() + info(f"Local packages no longer required: {', '.join(to_remove)}") + selected = prompt_selection(to_remove, "Local packages to remove?") + if selected: + installer.remove([pkg for path in selected for pkg in current[path]]) + for path in selected: + installed.pop(path, None) + + return installed + + def summarize(self, changeset: Changeset, new_files: list[Path]) -> None: + print() + info(f"Updated {len(changeset.place)} file(s), removed {len(changeset.deletes)}, {len(new_files)} conflict(s).") + if new_files: + info("The following files were changed upstream but you had edited them locally.") + info("Your versions were kept; the upstream versions were written alongside as .new:") + for path in new_files: + info(f" {path}") + if changeset.stale: + info("These files are no longer managed but differ from what was installed, so were kept:") + for path in changeset.stale: + info(f" {path}") diff --git a/src/caelestia/utils/dots/deployer.py b/src/caelestia/utils/dots/deployer.py index 12d80e8..9ec3bb4 100644 --- a/src/caelestia/utils/dots/deployer.py +++ b/src/caelestia/utils/dots/deployer.py @@ -2,7 +2,10 @@ import shutil import tempfile from pathlib import Path -from caelestia.utils.paths import dots_dir +from caelestia.utils.paths import cache_dir, config_dir, data_dir, dots_dir, state_dir + +# Dirs to never prune even if empty +_PROTECTED_DIRS = frozenset({Path.home(), config_dir, data_dir, state_dir, cache_dir}) class Deployer: @@ -65,3 +68,17 @@ class Deployer: path.unlink() elif path.is_dir(): shutil.rmtree(path) + + def prune_empty_dirs(self, start: Path, stop: Path) -> None: + """Removes dirs recursively from start to stop. + + Will never prune protected dirs (home, config, cache, etc). + """ + + parent = start.parent + while parent != stop and stop in parent.parents and parent not in _PROTECTED_DIRS: + try: + parent.rmdir() + except OSError: + break + parent = parent.parent diff --git a/src/caelestia/utils/dots/packages.py b/src/caelestia/utils/dots/packages.py index d967c42..0b66ec7 100644 --- a/src/caelestia/utils/dots/packages.py +++ b/src/caelestia/utils/dots/packages.py @@ -73,6 +73,9 @@ class PackageInstaller(ABC): def build_install(self, directory: Path) -> list[str]: """Build and install the PKGBUILD in `directory`, returning the installed package names.""" + @abstractmethod + def system_update(self) -> None: ... + class NoopInstaller(PackageInstaller): """Used off Arch, where the dots' packages are not available via pacman/AUR.""" @@ -89,6 +92,9 @@ class NoopInstaller(PackageInstaller): info(f"Skipping local package build (not on Arch): {directory}") return [] + def system_update(self) -> None: + info("Skipping system update (not on Arch)") + class ArchInstaller(PackageInstaller): def __init__(self, helper: str, noconfirm: bool = False) -> None: @@ -128,3 +134,6 @@ class ArchInstaller(PackageInstaller): subprocess.run(["makepkg", "-fsi", *self.flags], cwd=directory, env=env, check=True) return names + + def system_update(self) -> None: + subprocess.run([self.helper, "-Syu", *self.flags], check=True) From be13e5897b44ddb07ee44d83d87f67ffec12579d Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:28:58 +1000 Subject: [PATCH 09/17] feat: add completions for update cmd --- completions/caelestia.fish | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/completions/caelestia.fish b/completions/caelestia.fish index 0759525..32273e7 100644 --- a/completions/caelestia.fish +++ b/completions/caelestia.fish @@ -1,7 +1,7 @@ set -l seen '__fish_seen_subcommand_from' set -l has_opt '__fish_contains_opt' -set -l commands shell toggle scheme screenshot record clipboard emoji-picker wallpaper resizer install +set -l commands shell toggle scheme screenshot record clipboard emoji-picker wallpaper resizer install update set -l not_seen "not $seen $commands" # Disable file completions @@ -21,6 +21,7 @@ complete -c caelestia -n $not_seen -a 'emoji' -d 'Emoji/glyph utilities' complete -c caelestia -n $not_seen -a 'wallpaper' -d 'Manage the wallpaper' complete -c caelestia -n $not_seen -a 'resizer' -d 'Window resizer' complete -c caelestia -n $not_seen -a 'install' -d 'Install the Caelestia dotfiles' +complete -c caelestia -n $not_seen -a 'update' -d 'Update the Caelestia dotfiles' # Shell set -l commands mpris drawers wallpaper notifs @@ -133,3 +134,7 @@ complete -c caelestia -n "$seen install" -l 'aur-helper' -d 'The AUR helper to u complete -c caelestia -n "$seen install" -l 'enable-components' -d 'List of components to enable' -r complete -c caelestia -n "$seen install" -l 'disable-components' -d 'List of components to disable' -r complete -c caelestia -n "$seen install" -l 'noconfirm' -d 'Use defaults for all prompts' + +# Update +complete -c caelestia -n "$seen update" -l 'aur-helper' -d 'The AUR helper to use' -a 'yay paru' -r +complete -c caelestia -n "$seen update" -l 'noconfirm' -d 'Use defaults for all prompts' From 338c78f7897d0f2fe790c07dd7abfd438dac8280 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:10:17 +1000 Subject: [PATCH 10/17] fix: disable git transforming weird chars Git transforms non ascii and other chars into octal escaped versions, which we don't want --- src/caelestia/utils/dots/source.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/caelestia/utils/dots/source.py b/src/caelestia/utils/dots/source.py index 19e498e..f5023bb 100644 --- a/src/caelestia/utils/dots/source.py +++ b/src/caelestia/utils/dots/source.py @@ -101,10 +101,12 @@ class DotsSource: # --- Helpers --- def _git(self, *args: str) -> str: - return self._run("git", "-C", str(dots_dir), *args) + # core.quotePath=false so non-ASCII paths come back verbatim, not octal-escaped + return self._run("git", "-C", str(dots_dir), "-c", "core.quotePath=false", *args) def _git_bytes(self, *args: str) -> bytes: - result = subprocess.run(["git", "-C", str(dots_dir), *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + cmd = ["git", "-C", str(dots_dir), "-c", "core.quotePath=false", *args] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode != 0: raise SourceError(result.stderr.decode().strip() or f"git {' '.join(args)} failed") return result.stdout From 68a758a53b088bd493d88338ceb7dfe8bdae1b02 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:11:39 +1000 Subject: [PATCH 11/17] fix: handle user deleted but upstream changed properly Also catch error reading blob for new files --- src/caelestia/utils/dots/diff.py | 35 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/caelestia/utils/dots/diff.py b/src/caelestia/utils/dots/diff.py index 164d032..b93d763 100644 --- a/src/caelestia/utils/dots/diff.py +++ b/src/caelestia/utils/dots/diff.py @@ -3,6 +3,7 @@ from pathlib import Path from caelestia.utils.dots.manifest import ManifestEntry from caelestia.utils.dots.source import DotsSource, SourceError +from caelestia.utils.io import warn class _Continue(Exception): @@ -15,9 +16,10 @@ class Changeset: conflicts: list[tuple[str, Path]] = field(default_factory=list) # (repofile, dest) -> write .new deletes: list[Path] = field(default_factory=list) # We placed it, upstream removed it, unmodified stale: list[Path] = field(default_factory=list) # Upstream removed it but user modified it + deleted_changed: list[tuple[str, Path]] = field(default_factory=list) # User deleted it, upstream changed -> .new def is_empty(self) -> bool: - return not (self.place or self.conflicts or self.deletes or self.stale) + return not (self.place or self.conflicts or self.deletes or self.stale or self.deleted_changed) @staticmethod def compute( @@ -30,11 +32,18 @@ class Changeset: """Collect all file changes needed into a Changeset.""" has_base = source.has_rev(applied_rev) + if not has_base: + warn( + "the previously applied revision is missing from the dots clone; files that differ " + "from the latest version will be written as .new instead of updated in place." + ) + changed = set(source.changed_files(applied_rev, tip)) if has_base else set() place: list[tuple[str, Path]] = [] conflicts: list[tuple[str, Path]] = [] deletes: list[Path] = [] stale: list[Path] = [] + deleted_changed: list[tuple[str, Path]] = [] # Collect all files to deploy (entry sources can be dirs so we recurse into them) to_deploy: dict[Path, str] = {} @@ -69,13 +78,17 @@ class Changeset: stale.append(dest_path) else: # Still managed; `src` is what we last placed, `new_src` the current source new_src = to_deploy[dest_path] + if not dest_path.exists(): + # User deleted a managed file locally + if has_base and new_src == src and new_src not in changed: + continue # Respect the deletion; upstream has nothing new to offer + # Upstream changed it (or base is unknown): surface as .new, don't restore + deleted_changed.append((new_src, dest_path)) + continue + if has_base and new_src == src and new_src not in changed: continue # Unchanged upstream - if not dest_path.exists(): - place.append((new_src, dest_path)) - continue - dest_content = dest_path.read_bytes() if try_read(tip, new_src) == dest_content: continue # File is already up to date @@ -91,10 +104,18 @@ class Changeset: # New files to deploy for dest in files_to_deploy - set(Path(d) for d in deployed): src = to_deploy[dest] - if not dest.exists() or source.blob_at(tip, src) == dest.read_bytes(): + try: + new_content = source.blob_at(tip, src) + except SourceError: + # Failed to read the upstream blob; skip rather than abort the whole update + warn(f"could not read from source, skipping: {src}") + continue + if not dest.exists() or new_content == dest.read_bytes(): # Dest nonexistent or already equal to new content place.append((src, dest)) else: conflicts.append((src, dest)) - return Changeset(place=place, conflicts=conflicts, deletes=deletes, stale=stale) + return Changeset( + place=place, conflicts=conflicts, deletes=deletes, stale=stale, deleted_changed=deleted_changed + ) From d83a85745d36966d240ea7b847e9138dd5d5e741 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:13:10 +1000 Subject: [PATCH 12/17] fix: save state after each update phase So cancellations per phase don't leave state partially wrong Also part 2 of the previous commit, wire into updater --- src/caelestia/subcommands/update.py | 61 ++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/src/caelestia/subcommands/update.py b/src/caelestia/subcommands/update.py index caac889..ad01a3b 100644 --- a/src/caelestia/subcommands/update.py +++ b/src/caelestia/subcommands/update.py @@ -40,31 +40,36 @@ class Command: source.checkout_tip() except SourceError as e: fatal(e) - new_files, placed = self.deploy_changeset(source, changeset) + new_files, revived_files, placed = self.deploy_changeset(source, changeset) - # Install new/remove old packages - desired = manifest.enabled_packages() - state.packages = self.sync_packages(installer, state.packages, desired) - - # Install new/remove old local PKGBUILD packages - desired_local = manifest.enabled_local_packages() - state.local_packages = self.sync_local_packages(installer, source, state.local_packages, desired_local) - - # Run hooks - run_hooks(manifest, "post_update") - - # Update state + # Persist file changes immediately so a later failure can't lose track of them deployed = dict(state.deployed_files) for dest in (*changeset.deletes, *changeset.stale): deployed.pop(str(dest), None) deployed.update(placed) state.deployed_files = deployed + state.save() + + # Install new/remove old packages + desired = manifest.enabled_packages() + state.packages = self.sync_packages(installer, state.packages, desired) + state.save() + + # Install new/remove old local PKGBUILD packages + desired_local = manifest.enabled_local_packages() + state.local_packages = self.sync_local_packages(installer, source, state.local_packages, desired_local) + state.save() + + # Run hooks + run_hooks(manifest, "post_update") + + # Mark the new revision applied state.applied_rev = tip state.enabled_components = manifest.enabled_components state.aur_helper = getattr(installer, "helper", state.aur_helper) state.save() - self.summarize(changeset, new_files) + self.summarize(changeset, new_files, revived_files) def fetch_manifest(self, state: DotsState, applied_rev: str) -> tuple[DotsSource, str, Manifest]: print() @@ -109,12 +114,14 @@ class Command: return source, tip, manifest - def deploy_changeset(self, source: DotsSource, changeset: Changeset) -> tuple[list[Path], dict[str, str]]: + def deploy_changeset( + self, source: DotsSource, changeset: Changeset + ) -> tuple[list[Path], list[Path], dict[str, str]]: print() if changeset.is_empty(): info("No configs to update.") - return [], {} + return [], [], {} log("Updating configs...") deployer = Deployer() @@ -137,12 +144,22 @@ class Command: new_files.append(new_path) warn(f"{dest} has local changes; upstream version written as {new_path.name}") + revived_files = [] + for repofile, dest in changeset.deleted_changed: + src = source.working_path(repofile) + if not src.exists(): + warn(f"missing in source, skipping: {repofile}") + continue + new_path = deployer.write_new(src, dest) + revived_files.append(new_path) + warn(f"{dest} was removed but changed upstream; upstream version written as {new_path.name}") + for dest in changeset.deletes: deployer.remove(dest) deployer.prune_empty_dirs(dest, Path.home()) info(f"Removed {dest}") - return new_files, deployer.deployed_files + return new_files, revived_files, deployer.deployed_files def sync_packages(self, installer: PackageInstaller, current: list[str], desired: list[str]) -> list[str]: to_install = [p for p in desired if p not in current] @@ -188,14 +205,20 @@ class Command: return installed - def summarize(self, changeset: Changeset, new_files: list[Path]) -> None: + def summarize(self, changeset: Changeset, new_files: list[Path], revived_files: list[Path]) -> None: print() - info(f"Updated {len(changeset.place)} file(s), removed {len(changeset.deletes)}, {len(new_files)} conflict(s).") + conflicts = len(new_files) + len(revived_files) + info(f"Updated {len(changeset.place)} file(s), removed {len(changeset.deletes)}, {conflicts} conflict(s).") if new_files: info("The following files were changed upstream but you had edited them locally.") info("Your versions were kept; the upstream versions were written alongside as .new:") for path in new_files: info(f" {path}") + if revived_files: + info("These files were removed by you but changed upstream, so were not restored.") + info("The upstream versions were written alongside as .new:") + for path in revived_files: + info(f" {path}") if changeset.stale: info("These files are no longer managed but differ from what was installed, so were kept:") for path in changeset.stale: From 0410fed68c4ff845250d0a3b7598f62ac8908f71 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:17:08 +1000 Subject: [PATCH 13/17] fix: remove untracked files from state --- src/caelestia/subcommands/update.py | 2 +- src/caelestia/utils/dots/diff.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/caelestia/subcommands/update.py b/src/caelestia/subcommands/update.py index ad01a3b..1daea7d 100644 --- a/src/caelestia/subcommands/update.py +++ b/src/caelestia/subcommands/update.py @@ -44,7 +44,7 @@ class Command: # Persist file changes immediately so a later failure can't lose track of them deployed = dict(state.deployed_files) - for dest in (*changeset.deletes, *changeset.stale): + for dest in (*changeset.deletes, *changeset.stale, *changeset.untracked): deployed.pop(str(dest), None) deployed.update(placed) state.deployed_files = deployed diff --git a/src/caelestia/utils/dots/diff.py b/src/caelestia/utils/dots/diff.py index b93d763..bc0a4ac 100644 --- a/src/caelestia/utils/dots/diff.py +++ b/src/caelestia/utils/dots/diff.py @@ -17,6 +17,7 @@ class Changeset: deletes: list[Path] = field(default_factory=list) # We placed it, upstream removed it, unmodified stale: list[Path] = field(default_factory=list) # Upstream removed it but user modified it deleted_changed: list[tuple[str, Path]] = field(default_factory=list) # User deleted it, upstream changed -> .new + untracked: list[Path] = field(default_factory=list) # Gone + no longer managed; drop from state def is_empty(self) -> bool: return not (self.place or self.conflicts or self.deletes or self.stale or self.deleted_changed) @@ -44,6 +45,7 @@ class Changeset: deletes: list[Path] = [] stale: list[Path] = [] deleted_changed: list[tuple[str, Path]] = [] + untracked: list[Path] = [] # Collect all files to deploy (entry sources can be dirs so we recurse into them) to_deploy: dict[Path, str] = {} @@ -70,6 +72,8 @@ class Changeset: try: if dest_path not in files_to_deploy: # No longer managed by any entry if not dest_path.exists(): + # Gone from disk and no entry manages it + untracked.append(dest_path) continue if has_base and try_read(applied_rev, src) == dest_path.read_bytes(): @@ -117,5 +121,10 @@ class Changeset: conflicts.append((src, dest)) return Changeset( - place=place, conflicts=conflicts, deletes=deletes, stale=stale, deleted_changed=deleted_changed + place=place, + conflicts=conflicts, + deletes=deletes, + stale=stale, + deleted_changed=deleted_changed, + untracked=untracked, ) From c09cd1a6098757885c06fc919c7271f777c25b32 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:17:24 +1000 Subject: [PATCH 14/17] fix: warn on duplicate components in manifest --- src/caelestia/utils/dots/manifest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/caelestia/utils/dots/manifest.py b/src/caelestia/utils/dots/manifest.py index abf0f40..297f3f6 100644 --- a/src/caelestia/utils/dots/manifest.py +++ b/src/caelestia/utils/dots/manifest.py @@ -7,6 +7,8 @@ from pathlib import Path from string import Template from typing import Any +from caelestia.utils.io import warn + _XDG_DEFAULTS = { "XDG_CONFIG_HOME": str(Path.home() / ".config"), "XDG_DATA_HOME": str(Path.home() / ".local/share"), @@ -105,6 +107,8 @@ class Manifest: components = {} for comp in raw.get("components", []): parsed = _parse_component(comp) + if parsed.name in components: + warn(f"duplicate component '{parsed.name}'; using the last definition") components[parsed.name] = parsed return Manifest( From 844f6d22b3a72410c5b29992911f1a6aac55daeb Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:56:42 +1000 Subject: [PATCH 15/17] fix: account for moved src --- src/caelestia/subcommands/update.py | 2 ++ src/caelestia/utils/dots/diff.py | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/caelestia/subcommands/update.py b/src/caelestia/subcommands/update.py index 1daea7d..294e823 100644 --- a/src/caelestia/subcommands/update.py +++ b/src/caelestia/subcommands/update.py @@ -46,6 +46,8 @@ class Command: deployed = dict(state.deployed_files) for dest in (*changeset.deletes, *changeset.stale, *changeset.untracked): deployed.pop(str(dest), None) + for repofile, dest in changeset.remap: + deployed[str(dest)] = repofile deployed.update(placed) state.deployed_files = deployed state.save() diff --git a/src/caelestia/utils/dots/diff.py b/src/caelestia/utils/dots/diff.py index bc0a4ac..c468e65 100644 --- a/src/caelestia/utils/dots/diff.py +++ b/src/caelestia/utils/dots/diff.py @@ -18,6 +18,7 @@ class Changeset: stale: list[Path] = field(default_factory=list) # Upstream removed it but user modified it deleted_changed: list[tuple[str, Path]] = field(default_factory=list) # User deleted it, upstream changed -> .new untracked: list[Path] = field(default_factory=list) # Gone + no longer managed; drop from state + remap: list[tuple[str, Path]] = field(default_factory=list) # Up to date but source path moved; restate mapping def is_empty(self) -> bool: return not (self.place or self.conflicts or self.deletes or self.stale or self.deleted_changed) @@ -46,6 +47,7 @@ class Changeset: stale: list[Path] = [] deleted_changed: list[tuple[str, Path]] = [] untracked: list[Path] = [] + remap: list[tuple[str, Path]] = [] # Collect all files to deploy (entry sources can be dirs so we recurse into them) to_deploy: dict[Path, str] = {} @@ -95,7 +97,10 @@ class Changeset: dest_content = dest_path.read_bytes() if try_read(tip, new_src) == dest_content: - continue # File is already up to date + # Already up to date; restate the mapping if the source path moved + if new_src != src: + remap.append((new_src, dest_path)) + continue # Fast-forward only when the user hasn't edited since last deploy if has_base and try_read(applied_rev, src) == dest_content: @@ -127,4 +132,5 @@ class Changeset: stale=stale, deleted_changed=deleted_changed, untracked=untracked, + remap=remap, ) From 91e55a322f475a027c26e2e78f8d44866cbc4fdd Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:57:10 +1000 Subject: [PATCH 16/17] fix: account for fs errors when reading files --- src/caelestia/utils/dots/diff.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/caelestia/utils/dots/diff.py b/src/caelestia/utils/dots/diff.py index c468e65..ae4e576 100644 --- a/src/caelestia/utils/dots/diff.py +++ b/src/caelestia/utils/dots/diff.py @@ -10,6 +10,15 @@ class _Continue(Exception): """Signals the deployed-files loop to skip to the next entry.""" +def _read_local(path: Path) -> bytes | None: + """Read a local file, returning None if it can't be read (perms, is a dir, etc.).""" + + try: + return path.read_bytes() + except OSError: + return None + + @dataclass(frozen=True) class Changeset: place: list[tuple[str, Path]] = field(default_factory=list) # (repofile, dest) to fast-forward @@ -78,9 +87,11 @@ class Changeset: untracked.append(dest_path) continue - if has_base and try_read(applied_rev, src) == dest_path.read_bytes(): + local = _read_local(dest_path) + if local is not None and has_base and try_read(applied_rev, src) == local: deletes.append(dest_path) else: + # Modified, or unreadable so we can't verify; keep it just in case stale.append(dest_path) else: # Still managed; `src` is what we last placed, `new_src` the current source new_src = to_deploy[dest_path] @@ -95,7 +106,12 @@ class Changeset: if has_base and new_src == src and new_src not in changed: continue # Unchanged upstream - dest_content = dest_path.read_bytes() + dest_content = _read_local(dest_path) + if dest_content is None: + # Unreadable (perms, became a dir, ...); surface upstream as .new, don't clobber + conflicts.append((new_src, dest_path)) + continue + if try_read(tip, new_src) == dest_content: # Already up to date; restate the mapping if the source path moved if new_src != src: @@ -119,10 +135,11 @@ class Changeset: # Failed to read the upstream blob; skip rather than abort the whole update warn(f"could not read from source, skipping: {src}") continue - if not dest.exists() or new_content == dest.read_bytes(): + if not dest.exists() or new_content == _read_local(dest): # Dest nonexistent or already equal to new content place.append((src, dest)) else: + # Differs, or exists but unreadable; surface upstream as .new conflicts.append((src, dest)) return Changeset( From 3d51f46b21e94dafd95a45e1297cf67230395bbb Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:04:23 +1000 Subject: [PATCH 17/17] feat: cache git blobs --- src/caelestia/utils/dots/source.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/caelestia/utils/dots/source.py b/src/caelestia/utils/dots/source.py index f5023bb..d2c7159 100644 --- a/src/caelestia/utils/dots/source.py +++ b/src/caelestia/utils/dots/source.py @@ -17,6 +17,8 @@ class DotsSource: cfg = get_config().get("dots", {}) self.url = cfg.get("url", "https://github.com/caelestia-dots/caelestia.git") self.branch = cfg.get("branch", "main") + # Cache git blobs by (ref, relpath); objects are immutable for a given rev + self._blob_cache: dict[tuple[str, str], bytes] = {} @property def remote_ref(self) -> str: @@ -90,7 +92,10 @@ class DotsSource: return self._git("show", f"{ref}:{relpath}") def blob_at(self, ref: str, relpath: str) -> bytes: - return self._git_bytes("show", f"{ref}:{relpath}") + key = (ref, relpath) + if key not in self._blob_cache: + self._blob_cache[key] = self._git_bytes("show", f"{ref}:{relpath}") + return self._blob_cache[key] def files_at(self, ref: str, relpath: str) -> list[str]: """Repo-relative paths of all files under relpath at ref (the path itself if it is a file)."""