"""Manages a pool of SVN working copies ("workspaces") so concurrent sessions stop sharing one. A workspace is a full copy of the main working copy under SCRATCH_ROOT\\NN. SCRATCH_ROOT is the main working copy's path plus "-Scratch". A lock file per workspace, kept beside the workspaces rather than inside them, records who holds it. The Claude Code WorktreeCreate and WorktreeRemove hooks drive take and release. Everything else is for the user at a prompt. python Scripts/scratch.py make 4 Creates workspaces 01..04 from the main working copy and builds each. python Scripts/scratch.py list Shows every workspace, its holder and whether it has changes. python Scripts/scratch.py stale Lists locks older than ten days. python Scripts/scratch.py take ... Locks a free workspace and prints its path. python Scripts/scratch.py release PATH Unlocks a workspace that has no changes. """ import argparse import datetime import json import os import re import shutil import subprocess import sys SCRATCH_SUFFIX = "-Scratch" def locate_roots(scripts_directory): """Returns (main_root, scratch_root) for a Scripts directory in the main working copy or in one of its workspaces. A workspace's copy of the scripts must find the same pool and the same main as main's copy does. """ root = os.path.normpath(os.path.join(os.path.abspath(scripts_directory), "..")) parent = os.path.dirname(root) if parent.endswith(SCRATCH_SUFFIX): return parent[:-len(SCRATCH_SUFFIX)], parent return root, root + SCRATCH_SUFFIX MAIN_ROOT, SCRATCH_ROOT = locate_roots(os.path.dirname(os.path.abspath(__file__))) STALE_DAYS = 10 TIME_FORMAT = "%Y-%m-%dT%H:%M:%S" # Games updates through the cached read-only bot login, so a machine without that login fails on it. UPDATED_EXTERNALS = ("Modules", "Scripts", ".claude", "3rdParty", "Games") class NoFreeWorkspace(Exception): """Every workspace is locked, or none exist.""" class WorkspaceHasChanges(Exception): """A workspace holds uncommitted work, so releasing it would hide that work from its next holder.""" def workspace_names(scratch_root): """Returns the names of the workspace directories, such as '01', in order.""" if not os.path.isdir(scratch_root): return [] return sorted(name for name in os.listdir(scratch_root) if re.fullmatch(r"\d\d", name) and os.path.isdir(os.path.join(scratch_root, name))) def lock_path(scratch_root, name): """Returns where a workspace's lock file lives, which is outside the workspace so an svn revert can't touch it.""" return os.path.join(scratch_root, "locks", name + ".json") def deliveries_path(scratch_root, name): """Returns where deliver.py records what it delivered from a workspace, outside it for the same reason.""" return os.path.join(scratch_root, "locks", name + ".deliveries.json") def read_holder(scratch_root, name): """Returns the holder dictionary for a workspace, or None when it is free.""" try: with open(lock_path(scratch_root, name), "r", encoding="utf-8") as lock_file: return json.load(lock_file) except FileNotFoundError: return None def describe_holder(name, holder): """Returns one line naming a workspace's holder.""" return "{}: session {} ({}) since {} for {}".format( name, holder.get("session_id"), holder.get("account"), holder.get("time"), holder.get("task")) def take(scratch_root, session_id, account, task, now=None): """Locks the first free workspace for a session and returns its path. Creating the lock file exclusively makes two sessions racing for the same workspace resolve to one winner. """ now = now or datetime.datetime.now() holder = {"session_id": session_id, "account": account, "task": task, "time": now.strftime(TIME_FORMAT)} os.makedirs(os.path.join(scratch_root, "locks"), exist_ok=True) names = workspace_names(scratch_root) for name in names: try: descriptor = os.open(lock_path(scratch_root, name), os.O_CREAT | os.O_EXCL | os.O_WRONLY) except FileExistsError: continue with os.fdopen(descriptor, "w", encoding="utf-8") as lock_file: json.dump(holder, lock_file) return os.path.join(scratch_root, name) holders = [describe_holder(name, read_holder(scratch_root, name)) for name in names if read_holder(scratch_root, name)] raise NoFreeWorkspace("No free workspace under {}. Held by:\n{}".format(scratch_root, "\n".join(holders))) def workspace_name_for_path(scratch_root, workspace_path): """Returns the workspace name for a path at or inside a workspace, or raises ValueError for any other path.""" relative = os.path.relpath(os.path.normpath(workspace_path), os.path.normpath(scratch_root)) name = relative.split(os.sep)[0] if not re.fullmatch(r"\d\d", name): raise ValueError("{} is not inside a workspace of {}".format(workspace_path, scratch_root)) return name def changed_paths(workspace_path): """Returns the lines of `svn status` that show work, leaving out the externals svn lists with an X.""" output = subprocess.run(["svn", "status", workspace_path, "--non-interactive"], capture_output=True, text=True, check=True).stdout return [line for line in output.splitlines() if line[:7].strip() and not line.startswith(("X", "Performing status", "--- Changelist"))] def unversioned_paths(status_output, workspace_path): """Returns the unversioned paths in `svn status` output, leaving out build output and anything outside it.""" returnMe = [] workspace_prefix = os.path.normcase(os.path.normpath(workspace_path)) + os.sep for line in status_output.splitlines(): if not line.startswith("?"): continue path = os.path.normpath(line[8:].strip()) relative = os.path.normcase(path)[len(workspace_prefix):] if os.path.normcase(path).startswith(workspace_prefix) and relative.split(os.sep)[0] not in ("bin", "junk"): returnMe.append(path) return returnMe def working_copies(workspace_path): """Returns the workspace root and each external beneath it, since every one is a separate working copy.""" returnMe = [workspace_path] for name in sorted(os.listdir(workspace_path)): if os.path.isdir(os.path.join(workspace_path, name, ".svn")): returnMe.append(os.path.join(workspace_path, name)) return returnMe def release(scratch_root, workspace_path, has_changes=None): """Unlocks a workspace, refusing while `has_changes` reports uncommitted work in it.""" name = workspace_name_for_path(scratch_root, workspace_path) changes = (has_changes or changed_paths)(os.path.join(scratch_root, name)) if changes: raise WorkspaceHasChanges("{} has {} changed paths, such as {}".format(name, len(changes), changes[0])) # The next holder starts clean, so deliveries recorded for this holder's changelists mean nothing to it. for path in (lock_path(scratch_root, name), deliveries_path(scratch_root, name)): try: os.remove(path) except FileNotFoundError: pass def stale_locks(scratch_root, now=None, max_age_days=STALE_DAYS): """Returns (name, holder) for each lock older than `max_age_days`.""" now = now or datetime.datetime.now() returnMe = [] for name in workspace_names(scratch_root): holder = read_holder(scratch_root, name) if holder is None: continue age = now - datetime.datetime.strptime(holder["time"], TIME_FORMAT) if age > datetime.timedelta(days=max_age_days): returnMe.append((name, holder)) return returnMe def run(command, description): """Runs a command, raising with its output when it fails.""" completed = subprocess.run(command, capture_output=True, text=True) if completed.returncode != 0: raise RuntimeError("{} failed ({}):\n{}{}".format(description, completed.returncode, completed.stdout, completed.stderr)) def refresh(workspace_path): """Brings a workspace's source externals up to date with trunk.""" for external in UPDATED_EXTERNALS: run(["svn", "update", os.path.join(workspace_path, external), "--non-interactive"], "svn update " + external) def make_workspace(scratch_root, name, main_root=MAIN_ROOT): """Creates one workspace as a clean copy of the main working copy, then builds it so later builds are incremental.""" workspace_path = os.path.join(scratch_root, name) if os.path.exists(workspace_path): raise FileExistsError(workspace_path) # /R:0 because the default is a million retries, which stalls on files Visual Studio holds open. # Exit codes below 8 mean success. copied = subprocess.run(["robocopy", main_root, workspace_path, "/E", "/MT:16", "/R:0", "/W:0", "/XD", ".vs", "/NFL", "/NDL", "/NJH", "/NJS", "/NP"], capture_output=True, text=True) if copied.returncode >= 8: raise RuntimeError("robocopy failed ({}):\n{}".format(copied.returncode, copied.stdout)) # The copy carries whatever edits other sessions had in progress, tracked or not. A recursive revert stops at # externals, so each working copy is reverted on its own. for working_copy in working_copies(workspace_path): run(["svn", "revert", "-R", working_copy, "--non-interactive"], "svn revert " + working_copy) # A revert leaves each file in whatever changelist it was in. run(["svn", "changelist", "--remove", "-R", working_copy, "--non-interactive"], "svn changelist " + working_copy) status = subprocess.run(["svn", "status", workspace_path, "--non-interactive"], capture_output=True, text=True, check=True).stdout for path in unversioned_paths(status, workspace_path): if os.path.isdir(path): shutil.rmtree(path) else: os.remove(path) refresh(workspace_path) # A cold-start test flake shouldn't throw away a workspace, since the build only warms Bin for later builds. build = [sys.executable, "-u", os.path.join(workspace_path, "Scripts", "build.py"), "--no-interactive", "--quiet-summary"] for attempt in range(2): try: run(build, "build") break except RuntimeError as error: print("Build attempt {} of workspace {} failed:\n{}".format(attempt + 1, name, error), file=sys.stderr) return workspace_path def make_command(arguments): existing = set(workspace_names(SCRATCH_ROOT)) for number in range(1, arguments.count + 1): name = "{:02d}".format(number) if name in existing: print("{} already exists".format(name)) continue print("Making {}...".format(name), flush=True) print(make_workspace(SCRATCH_ROOT, name)) def list_command(arguments): for name in workspace_names(SCRATCH_ROOT): holder = read_holder(SCRATCH_ROOT, name) state = describe_holder(name, holder) if holder else name + ": free" changes = len(changed_paths(os.path.join(SCRATCH_ROOT, name))) print("{}{}".format(state, " ({} changed paths)".format(changes) if changes else "")) def stale_command(arguments): for name, holder in stale_locks(SCRATCH_ROOT): print(describe_holder(name, holder)) def take_command(arguments): workspace_path = take(SCRATCH_ROOT, arguments.session, arguments.account, arguments.task) refresh(workspace_path) print(workspace_path) def release_command(arguments): release(SCRATCH_ROOT, arguments.path) def main(): parser = argparse.ArgumentParser(description="Manages the pool of SVN working copies sessions work in.") commands = parser.add_subparsers(dest="command", required=True) make_parser = commands.add_parser("make", help="Creates workspaces 01..COUNT that don't exist yet.") make_parser.add_argument("count", type=int) make_parser.set_defaults(handler=make_command) commands.add_parser("list", help="Shows every workspace.").set_defaults(handler=list_command) commands.add_parser("stale", help="Lists locks older than ten days.").set_defaults(handler=stale_command) take_parser = commands.add_parser("take", help="Locks a free workspace, updates it and prints its path.") take_parser.add_argument("--session", required=True) take_parser.add_argument("--account", default="unknown") take_parser.add_argument("--task", default="") take_parser.set_defaults(handler=take_command) release_parser = commands.add_parser("release", help="Unlocks a workspace with no changes.") release_parser.add_argument("path") release_parser.set_defaults(handler=release_command) arguments = parser.parse_args() try: arguments.handler(arguments) except (NoFreeWorkspace, WorkspaceHasChanges, RuntimeError, FileExistsError) as error: print(error, file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())