"""Environment setup — the decisions, separated from the performing (D-263). These three ported worst and mattered most to get right. `install-godot` downloads and unzips a pinned build, `install-rust` drives `rustup`, `worktree-setup` manipulates git worktrees — none of which can be exercised in a gate without actually doing it. So the shape here is deliberate: **every function that decides is pure and importable, and every function that acts is a thin call through `core.process.run`.** `godot_plan()` answers "what would be downloaded, and is it needed" without touching the network; `install_godot()` performs it. The test of a correct port is whether the decisions can be exercised WITHOUT performing them — a rewrite that cannot be tested has to be trusted instead, and trusting an installer is how a working environment becomes an unreproducible one. """ from __future__ import annotations import os import platform import shutil from dataclasses import dataclass from pathlib import Path from tooling.core import config, console, process from tooling.core.errors import ReachError DEFAULT_GODOT_VERSION = "4.6" INSTALL_DIR = Path.home() / "bin" GODOT_BINARY = INSTALL_DIR / "godot4" # Godot publishes one archive per platform triple; an unsupported pair is a # clear failure rather than a download that 404s. PLATFORMS = { ("Linux", "x86_64"): "linux.x86_64", ("Linux", "aarch64"): "linux.arm64", ("Darwin", "x86_64"): "macos.universal", ("Darwin", "arm64"): "macos.universal", } @dataclass(frozen=True) class GodotPlan: """What installing Godot would do — decided without doing any of it.""" wanted: str installed: str | None platform_tag: str url: str filename: str @property def already_current(self) -> bool: return self.installed == self.wanted def godot_plan(wanted: str | None = None) -> GodotPlan: """Decide what an install would fetch. Pure apart from reading the binary.""" wanted = wanted or os.environ.get("GODOT_VERSION", DEFAULT_GODOT_VERSION) system, machine = platform.system(), platform.machine() tag = PLATFORMS.get((system, machine)) if tag is None: raise ReachError( f"unsupported platform: {system} {machine}", fix="install Godot manually from https://godotengine.org/download", ) filename = f"Godot_v{wanted}-stable_{tag}.zip" return GodotPlan( wanted=wanted, installed=installed_godot_version(), platform_tag=tag, filename=filename, url=( "https://github.com/godotengine/godot/releases/download/" f"{wanted}-stable/{filename}" ), ) def installed_godot_version() -> str | None: """The major.minor already on disk, or None. Never raises.""" if not GODOT_BINARY.is_file() or not os.access(GODOT_BINARY, os.X_OK): return None result = process.run([str(GODOT_BINARY), "--version"], check=False) first = (result.stdout or "").splitlines() if not first: return None return ".".join(first[0].split(".")[:2]) def install_godot(wanted: str | None = None) -> GodotPlan: """Perform the install. The decision is `godot_plan`; this is the doing.""" plan = godot_plan(wanted) if plan.already_current: console.verdict(f"Godot {plan.wanted} already installed at {GODOT_BINARY}") return plan if plan.installed: console.event( f"Godot found but version is {plan.installed}, want {plan.wanted}", level="warn", ) INSTALL_DIR.mkdir(parents=True, exist_ok=True) import tempfile with tempfile.TemporaryDirectory() as tmp: archive = Path(tmp) / plan.filename console.event(f"downloading Godot {plan.wanted} for {plan.platform_tag}", phase="godot") process.run( ["curl", "-fSL", "--progress-bar", "-o", str(archive), plan.url], capture=False, fix=f"check that GODOT_VERSION={plan.wanted} is a real release", missing_fix="install curl", ) console.event("extracting", phase="godot") process.run( ["unzip", "-q", "-o", str(archive), "-d", tmp], fix="the archive may be truncated — re-run to download it again", missing_fix="install unzip", ) extracted = next( (p for p in Path(tmp).iterdir() if p.is_file() and p.name.startswith("Godot")), None, ) if extracted is None: raise ReachError( "the Godot archive contained no binary", fix="the download may be corrupt — delete it and re-run", ) shutil.move(str(extracted), GODOT_BINARY) GODOT_BINARY.chmod(0o755) console.verdict(f"Godot {plan.wanted} installed at {GODOT_BINARY}") return plan def install_rust() -> None: """Ensure rustup, clippy and rustfmt are present.""" if shutil.which("cargo") is None: raise ReachError( "cargo is not installed", fix="install Rust from https://rustup.rs, then re-run — this command " "adds the components but does not bootstrap the toolchain", ) for component in ("clippy", "rustfmt"): console.event(f"ensuring {component}", phase="rust") process.run( ["rustup", "component", "add", component], check=False, missing_fix="install rustup from https://rustup.rs", ) console.verdict("Rust toolchain ready — clippy and rustfmt present") def worktree_plan(branch: str) -> Path: """Where a worktree for `branch` would go. Raises if it cannot be created. Both refusals are the original's and both are worth keeping: a worktree created from inside a worktree nests confusingly, and silently reusing an existing directory is how two branches end up sharing one tree. """ root = config.repo_root() if (root / ".git").is_file(): raise ReachError( "this is a worktree, not the main checkout", fix="run this from the main checkout — nesting worktrees confuses " "both git and the tools that resolve the repo root", ) target = root / ".worktrees" / branch if target.exists(): raise ReachError( f"{target} already exists", fix=f"reuse it, or remove it first with: git worktree remove {target}", ) return target def setup_worktree(branch: str, start: str = "HEAD") -> Path: """Create a worktree and link the venv into it.""" target = worktree_plan(branch) root = config.repo_root() process.run( ["git", "worktree", "add", str(target), "-b", branch, start], cwd=root, fix=f"check that {start} is a valid ref and {branch} is not already a branch", ) venv = root / ".venv" if venv.is_dir() and not (target / ".venv").exists(): (target / ".venv").symlink_to(venv) console.event(f"linked .venv -> {venv}") console.verdict( f"worktree ready: {target}\n" f" pql in this worktree needs --vault {target} (FR-4)\n" " tea runs from the main checkout — its go-git cannot read a linked worktree\n" " reach follows whichever checkout it was installed from; `make reach-repoint` " "if you want it to follow this one" ) return target