mirror of
https://github.com/caelestia-dots/cli.git
synced 2026-06-21 08:20:01 -05:00
154 lines
6.5 KiB
Python
154 lines
6.5 KiB
Python
from dataclasses import dataclass, field
|
|
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):
|
|
"""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
|
|
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
|
|
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)
|
|
|
|
@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."""
|
|
|
|
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]] = []
|
|
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] = {}
|
|
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: # No longer managed by any entry
|
|
if not dest_path.exists():
|
|
# Gone from disk and no entry manages it
|
|
untracked.append(dest_path)
|
|
continue
|
|
|
|
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]
|
|
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
|
|
|
|
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:
|
|
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:
|
|
place.append((new_src, dest_path))
|
|
else:
|
|
conflicts.append((new_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]
|
|
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 == _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(
|
|
place=place,
|
|
conflicts=conflicts,
|
|
deletes=deletes,
|
|
stale=stale,
|
|
deleted_changed=deleted_changed,
|
|
untracked=untracked,
|
|
remap=remap,
|
|
)
|