"""Export sandcage repo to a public standalone repository. Usage: uv run python scripts/export.py push [--dry-run] [--remote URL] """ from __future__ import annotations import argparse import contextlib import os import shutil import subprocess import sys from dataclasses import dataclass, field from pathlib import Path try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-redef] SCRIPT_DIR = Path(__file__).resolve().parent REPO_ROOT = SCRIPT_DIR.parent CONFIG_PATH = SCRIPT_DIR / "export.toml" @dataclass(frozen=True) class ExportConfig: branch: str remote_env: str assets: Path exclude: list[str] = field(default_factory=list) def load_config(path: Path = CONFIG_PATH) -> ExportConfig: with open(path, "rb") as f: raw = tomllib.load(f) entry = raw["export"] return ExportConfig( branch=entry["branch"], remote_env=entry["remote_env"], assets=REPO_ROOT / entry["assets"], exclude=entry.get("exclude", []), ) def load_asset_bundle(assets_dir: Path) -> dict[str, bytes]: bundle: dict[str, bytes] = {} if not assets_dir.exists(): return bundle for path in assets_dir.rglob("*"): if path.is_file(): rel = path.relative_to(assets_dir).as_posix() bundle[rel] = path.read_bytes() return bundle def materialize_assets(target: Path, bundle: dict[str, bytes]) -> None: for relpath, content in bundle.items(): dst = target / relpath dst.parent.mkdir(parents=True, exist_ok=True) dst.write_bytes(content) def remove_excluded_paths(target: Path, exclude: list[str]) -> None: for pattern in exclude: path = target / pattern if path.is_dir(): shutil.rmtree(path) elif path.is_file(): path.unlink() def _run(cmd: list[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess: result = subprocess.run(cmd, cwd=cwd, check=False, capture_output=True, encoding="utf-8", errors="replace") if check and result.returncode != 0: detail = (result.stderr or result.stdout or "").strip() if detail: print(f" stderr: {detail}", file=sys.stderr) raise SystemExit( f"error: command failed (exit {result.returncode}): {' '.join(cmd)}" ) return result @contextlib.contextmanager def export_worktree(repo_root: Path): wt_path = repo_root / ".export-worktrees" / "sandcage" wt_path.parent.mkdir(parents=True, exist_ok=True) _run(["git", "worktree", "prune"], cwd=repo_root, check=False) _run(["git", "worktree", "remove", "--force", str(wt_path)], cwd=repo_root, check=False) if wt_path.exists(): shutil.rmtree(wt_path, ignore_errors=True) _run(["git", "worktree", "add", "--detach", str(wt_path), "HEAD"], cwd=repo_root) try: yield wt_path finally: _run(["git", "worktree", "remove", "--force", str(wt_path)], cwd=repo_root, check=False) if wt_path.exists(): shutil.rmtree(wt_path, ignore_errors=True) _run(["git", "worktree", "prune"], cwd=repo_root, check=False) def _git_push(remote: str, branch: str, target: str, cwd: Path) -> None: fetch = _run(["git", "fetch", remote, target], cwd=cwd, check=False) if fetch.returncode == 0: expected = _run(["git", "rev-parse", "FETCH_HEAD"], cwd=cwd).stdout.strip() _run(["git", "push", f"--force-with-lease={target}:{expected}", remote, f"{branch}:{target}"], cwd=cwd) else: _run(["git", "push", remote, f"{branch}:{target}"], cwd=cwd) def run_export(*, repo_root: Path, cfg: ExportConfig, remote: str | None, dry_run: bool) -> None: if not dry_run and remote is None: raise SystemExit( f"error: remote required for push (set {cfg.remote_env} or pass --remote)" ) bundle = load_asset_bundle(cfg.assets) source_sha = _run( ["git", "rev-parse", "--short", "HEAD"], cwd=repo_root, ).stdout.strip() with export_worktree(repo_root) as worktree: branch_exists = _run( ["git", "rev-parse", "--verify", cfg.branch], cwd=repo_root, check=False, ).returncode == 0 if branch_exists: _run(["git", "checkout", cfg.branch], cwd=worktree) _run(["git", "rm", "-rf", "."], cwd=worktree, check=False) _run(["git", "checkout", source_sha, "--", "."], cwd=worktree) else: _run(["git", "checkout", "--orphan", cfg.branch], cwd=worktree) remove_excluded_paths(worktree, cfg.exclude) materialize_assets(worktree, bundle) _run(["git", "add", "-A"], cwd=worktree) diff = _run(["git", "diff", "--cached", "--quiet"], cwd=worktree, check=False) if diff.returncode != 0: _run(["git", "-c", "user.email=export@sandcage", "-c", "user.name=sandcage-export", "commit", "-m", f"\U0001f947 export from upstream ({source_sha})"], cwd=worktree) if dry_run: print(f"dry-run: skipping push. Branch '{cfg.branch}' is ready locally.") return assert remote is not None print(f"force-with-lease push {cfg.branch} -> {remote}:main") _git_push(remote, cfg.branch, "main", worktree) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="export", description="Export sandcage to public repo", ) sub = parser.add_subparsers(dest="cmd", required=True) p_push = sub.add_parser("push", help="export to standalone repo") p_push.add_argument("--remote", default=None) p_push.add_argument("--dry-run", action="store_true") args = parser.parse_args(argv) cfg = load_config() if args.cmd == "push": remote = args.remote or os.environ.get(cfg.remote_env) run_export(repo_root=REPO_ROOT, cfg=cfg, remote=remote, dry_run=args.dry_run) return 0 if __name__ == "__main__": sys.exit(main())