Files
Agent-System-Dev/scripts/workspace/common.py
T
weichen b450e51c40 Add safe component editing and explicit legacy workspace transition
Why: Preserve a single Dev entry and independent repository ownership while allowing active feature branches to migrate without losing source or Git state.

Evidence: SELF 29 real Git/Go workspace tests and ordinary component evidence/lock tests passed; detailed RED/GREEN runs are recorded by make evidence in the task card.

Impact: Add fixed cloning, selected-version baselines, development overlays, npm source resolution, and independently preserved legacy snapshots. No other active worktree is modified.

Refs: Versioned dependencies U2/U5; R15; feature/dependency-workspace
2026-10-13 15:58:23 +00:00

76 lines
2.5 KiB
Python

import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import subprocess
import tempfile
class WorkspaceError(Exception):
pass
def run(args, cwd, env=None, check=True):
result = subprocess.run(args, cwd=cwd, env=env, text=True, capture_output=True,
timeout=180)
if check and result.returncode:
raise WorkspaceError(f"{args[0]} failed ({result.returncode}): {result.stderr.strip() or result.stdout.strip()}")
return result
def relative_path(value, allow_dot=False):
if not isinstance(value, str) or not value or "\\" in value or any(ord(c) < 32 for c in value):
raise WorkspaceError("invalid relative path")
path = PurePosixPath(value)
if path.is_absolute() or ".." in path.parts or (value == "." and not allow_dot):
raise WorkspaceError(f"unsafe relative path: {value}")
return path
def safe_path(root, value, allow_dot=False):
relative_path(value, allow_dot)
path = root / value
# Refuse symlink path components even when a link currently points inside root.
# This avoids creating a second apparent owner and later retargeting escapes.
current = root
for part in PurePosixPath(value).parts:
current /= part
if current.is_symlink():
raise WorkspaceError(f"symlink path is not owned by this workspace: {value}")
if not path.resolve().is_relative_to(root.resolve()):
raise WorkspaceError(f"path escapes workspace: {value}")
return path
def read_json(path):
if path.is_symlink():
raise WorkspaceError(f"refusing symlink metadata: {path}")
try:
return json.loads(path.read_text())
except (OSError, ValueError) as error:
raise WorkspaceError(f"cannot read {path.name}: {error}") from error
def atomic_write(path, content):
if path.is_symlink():
raise WorkspaceError(f"refusing symlink output: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix="." + path.name + "-", dir=path.parent)
try:
with os.fdopen(descriptor, "w") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def write_json(path, value):
atomic_write(path, json.dumps(value, indent=2, sort_keys=True) + "\n")
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()