"""Per-distribution sync scope (include/exclude paths). Each instance-sync deployment ships its own ``scope.json`` that picks which files participate in sync. Lives at ``/.cloud-sync/scope.json``. Defaults are baked in so a fresh install with no scope.json works. """ from __future__ import annotations import json import sys from dataclasses import dataclass, field from pathlib import Path DEFAULT_INCLUDE: list[str] = [ "options.txt", "optionsof.txt", "optionsshaders.txt", "config/", "journeymap/data/", "screenshots/", ] DEFAULT_EXCLUDE: list[str] = [ ".cloud-sync/", # never sync our own state dir ".cloud-token", # legacy location (pre-jar/pre-restic era) "config/simple-mod-sync*", "config/packwiz*", "**/cache/", "**/*.log", "**/*.tmp", ] @dataclass(frozen=True) class Scope: include: list[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE)) exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE)) def load(pack_folder: Path) -> Scope: """Read scope.json or return defaults.""" path = pack_folder / ".cloud-sync" / "scope.json" if not path.exists(): return Scope() try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: print( f"instance-sync: scope.json invalid ({e}); using defaults", file=sys.stderr, ) return Scope() return Scope( include=list(data.get("include", DEFAULT_INCLUDE)), exclude=list(data.get("exclude", DEFAULT_EXCLUDE)), ) def materialize_for_restic(pack_folder: Path, scope: Scope) -> tuple[Path, Path]: """Write files-from + exclude-from text files restic can consume. Files include directories; restic recurses into them. Exclude patterns are matched against file paths during the walk. """ state_dir = pack_folder / ".cloud-sync" state_dir.mkdir(parents=True, exist_ok=True) files_from = state_dir / "files-from.txt" exclude_from = state_dir / "exclude-from.txt" files_from.write_text( "\n".join(_trim_trailing_slash(p) for p in scope.include) + "\n", encoding="utf-8", ) exclude_from.write_text( "\n".join(scope.exclude) + "\n", encoding="utf-8", ) return files_from, exclude_from def _trim_trailing_slash(s: str) -> str: return s.rstrip("/") if s.endswith("/") else s