Files
Agent-System-Dev/scripts/workspace/cli.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

238 lines
11 KiB
Python

import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
from .common import WorkspaceError, safe_path, write_json
from .git import Git
from .go import Go
from .manifest import Manifest, validate_branch
from .npm import Npm
from .state import State
from .transition import Transition
def process_environment(root):
try:
from .transport import environment
except ModuleNotFoundError as error:
if error.name != "workspace.transport":
raise
result = os.environ.copy()
result.update({"GOWORK": "off", "GOTOOLCHAIN": "local"})
else:
result = environment(root)
# Read-only reporting does not refresh another owner's Git index.
result["GIT_OPTIONAL_LOCKS"] = "0"
for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"):
result.pop(name, None)
return result
class Workspace:
def __init__(self, root):
self.root = root.resolve()
self.manifest = Manifest(self.root)
self.state = State(self.root)
self.env = process_environment(self.root)
self.git = Git(self.env)
self.go = Go(self)
self.npm = Npm(self)
self.transition = Transition(self)
def baseline(self, component):
name = component["id"]
if component["role"] == "go-library":
resolved = self.go.resolve(component)
if resolved:
return resolved
elif component["role"] == "npm-library":
resolved = self.npm.resolve(component)
if resolved:
return resolved
elif name in self.manifest.lock:
return self.manifest.lock[name]["commit"], {"kind": "source-lock"}
legacy = self.git.gitlink(self.root, component.get("legacy_path"))
if legacy:
return legacy, {"kind": "legacy-gitlink", "path": component.get("legacy_path")}
raise WorkspaceError(f"no fixed version source for {name}; latest main is not a fallback")
def record(self, component, commit, source):
record = {"path": component["path"], "origin": component["repo"], "baseline": commit,
"source": source, "owner_root": str(self.root), "mode": "preparing", "linked": False}
self.state.data["components"][component["id"]] = record
return record
def prepare_one(self, component):
name = component["id"]
path = safe_path(self.root, component["path"])
commit, source = self.baseline(component)
record = self.state.data["components"].get(name)
if record and (record["path"] != component["path"] or record["origin"] != component["repo"]):
raise WorkspaceError(f"registered component identity changed: {name}")
if path.exists():
editable = record and record.get("mode") in ("editable", "linked")
partial = record and record.get("mode") == "preparing"
actual = self.git.safe(path, component["repo"], clean=not (editable or partial))
if editable:
if record["baseline"] != commit:
raise WorkspaceError(f"version changed while {name} is editable; owner must synchronize explicitly")
if actual["branch"] != record.get("branch"):
raise WorkspaceError(f"registered edit branch changed: {name}")
return record
if partial:
if any(entry.name != ".git" for entry in path.iterdir()):
raise WorkspaceError(f"partial clone contains files: {name}; preserve them for its owner")
else:
if actual["branch"] or actual["head"] != commit:
raise WorkspaceError(f"existing checkout differs from fixed source: {name}; no automatic checkout")
if record is None:
record = self.record(component, commit, source)
record.update({"mode": "versioned", "baseline": commit, "source": source})
self.state.save()
return record
elif record and record.get("mode") != "preparing":
raise WorkspaceError(f"registered source is missing: {name}; owner must resolve it")
if record is None:
record = self.record(component, commit, source)
record.update({"baseline": commit, "source": source, "mode": "preparing"})
self.state.save()
try:
if not path.exists():
self.git.call(self.root, "clone", "--no-checkout", "--", component["repo"], str(path))
self.git.call(path, "fetch", "--no-tags", "origin", commit)
self.git.call(path, "checkout", "--detach", commit)
self.git.safe(path, component["repo"])
record.update({"mode": "versioned", "branch": None})
record.pop("error", None)
self.state.save()
return record
except Exception as error:
record["error"] = str(error)
self.state.save()
raise
def edit(self, component, branch):
self.go.check_owned()
if component["role"] in ("go-library", "npm-library"):
for consumer in self.manifest.select():
if consumer["role"] == "application" and (consumer.get("modules") or component["role"] == "npm-library"):
path = safe_path(self.root, consumer["path"])
if path.exists():
self.git.safe(path, consumer["repo"], clean=False)
else:
self.prepare_one(consumer)
name = component["id"]
record = self.prepare_one(component)
path = safe_path(self.root, component["path"])
branch = branch or "feature/workspace-" + name.lower()
validate_branch(branch)
if record.get("mode") in ("editable", "linked"):
if record.get("branch") != branch:
raise WorkspaceError(f"component already belongs to {record.get('branch')}; no branch replacement")
self.git.safe(path, component["repo"], clean=False)
else:
self.git.safe(path, component["repo"])
if self.git.call(path, "show-ref", "--verify", "--quiet", "refs/heads/" + branch, check=False).returncode == 0:
raise WorkspaceError(f"branch already exists: {branch}; owner must choose its checkout")
self.git.call(path, "checkout", "-b", branch, record["baseline"])
record.update({"mode": "editable", "branch": branch})
self.state.save()
record["linked"] = bool(self.go.modules(component))
if record["linked"]:
record["mode"] = "linked"
self.go.update()
self.state.save()
def unlink(self, component):
self.go.check_owned()
record = self.state.data["components"].get(component["id"])
if record is None:
raise WorkspaceError(f"component is not registered: {component['id']}")
self.git.safe(safe_path(self.root, component["path"]), component["repo"], clean=False)
record["linked"] = False
if record.get("mode") == "linked":
record["mode"] = "editable"
self.go.update()
self.state.save()
def status(self, components):
result = {"schema": 1, "root": str(self.root), "components": {},
"go_work": self.state.data.get("go_work")}
for component in components:
name = component["id"]
record = self.state.data["components"].get(name)
entry = {"path": component["path"], "repo": component["repo"], "role": component["role"],
"declared": self.manifest.lock.get(name), "registered": record}
path = safe_path(self.root, component["path"])
if path.exists():
try:
entry["actual"] = self.git.inspect(path)
except WorkspaceError as error:
entry["error"] = str(error)
result["components"][name] = entry
return result
def verify(self, components):
self.go.check_owned()
for component in components:
record = self.state.data["components"].get(component["id"])
if not record or record.get("linked"):
raise WorkspaceError(f"fixed source missing or development overlay active: {component['id']}")
actual = self.git.safe(safe_path(self.root, component["path"]), component["repo"])
baseline, _ = self.baseline(component)
if actual["head"] != baseline or actual["branch"]:
raise WorkspaceError(f"fixed source differs from declared version: {component['id']}")
def snapshot(self):
write_json(safe_path(self.root, ".workspace/resolved.json"), self.status(self.manifest.select()))
def main(argv=None):
parser = argparse.ArgumentParser(description="Fixed component sources and explicit local editing")
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
parser.add_argument("command", choices=("prepare", "edit", "unlink", "status", "verify", "transition"))
parser.add_argument("--name")
parser.add_argument("--branch")
parser.add_argument("--apply", action="store_true")
args = parser.parse_args(argv)
try:
if args.branch:
validate_branch(args.branch)
if args.command in ("edit", "unlink") and not args.name:
raise WorkspaceError("--name is required for component editing/unlinking")
if args.apply and args.command != "transition":
raise WorkspaceError("--apply is only valid for transition")
workspace = Workspace(args.root)
components = workspace.manifest.select(args.name, fixed=args.command in ("prepare", "verify"))
if args.command == "status":
result = workspace.status(components)
elif args.command == "transition" and not args.apply:
result = {"schema": 1, "components": {component["id"]: {"legacy": workspace.transition.inspect(component)} for component in components}}
elif args.command == "verify":
workspace.verify(components)
result = workspace.status(components)
else:
with workspace.state.operation():
# Read state again under the operation lock: another process may have
# finished between initial inspection and lock acquisition.
workspace.state = State(workspace.root)
if args.command == "transition":
workspace.transition.apply_all(components, args.branch)
for component in components:
if args.command == "prepare":
workspace.prepare_one(component)
elif args.command == "edit":
workspace.edit(component, args.branch)
elif args.command == "unlink":
workspace.unlink(component)
workspace.snapshot()
result = workspace.status(components)
print(json.dumps(result, indent=2, sort_keys=True))
return 0
except (WorkspaceError, OSError, ValueError, subprocess.TimeoutExpired) as error:
print(f"workspace: {error}", file=sys.stderr)
return 1