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
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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
|
||||
Reference in New Issue
Block a user