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
238 lines
11 KiB
Python
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
|