Files
Agent-System-Dev/scripts/workspace/cli.py
T
weichen 93ac63fb3d Compose immutable dependency sources and preserve branch transitions
Why:
Replace repeated submodule source pointers with consumer-owned language versions and fixed application sources, keeping independent repository edits and existing branches intact.

Evidence:
SELF task receipts contain startup/sync evidence, real Go and npm consumption, Native overlay/unlink, safe transition/ref preservation, credential boundary and historical build regressions. Exact committed full composition and RDM checks follow this checkpoint; formal acceptance and GATE CI remain pending.

Impact:
Add isolated build/test composition, source provenance and safe owner refresh, adapt governance and service build callers, remove replaced index links while retaining original Git metadata, and adopt reusable dependency standards.

Refs:
Documents approved dependency-workspace r1/R1-R15 and U1-U7 implementation plan; feature/dependency-workspace task card.
2026-10-13 17:18:07 +00:00

283 lines
14 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 refresh_versioned(self, component, path, record, commit, source):
old_commit = record["baseline"]
old_source = record["source"]
reference = "refs/workspace/fixed/" + old_commit
try:
self.git.call(path, "fetch", "--no-tags", "origin", commit)
# Fetch can take time. Recheck ownership and files before changing
# this registered checkout, without forcing any owner's changes.
actual = self.git.safe(path, component["repo"])
if actual["branch"] or actual["head"] != old_commit:
raise WorkspaceError("fixed checkout changed during fetch; owner must inspect it")
saved = self.git.text(path, "rev-parse", "--verify", "--quiet", reference, check=False)
if saved and saved != old_commit:
raise WorkspaceError("preserved fixed reference has another value; owner must inspect it")
if not saved:
self.git.call(path, "update-ref", reference, old_commit, "0" * 40)
self.git.call(path, "checkout", "--no-overwrite-ignore", "--detach", commit)
actual = self.git.safe(path, component["repo"])
if actual["head"] != commit or actual["branch"]:
raise WorkspaceError("refreshed checkout differs from the declared fixed source")
history = list(record.get("previous_sources", []))
previous = {"commit": old_commit, "source": old_source, "ref": reference}
if previous not in history:
history.append(previous)
record.update({"baseline": commit, "source": source, "branch": None,
"mode": "versioned", "previous_sources": history})
record.pop("error", None)
self.state.save()
return record
except Exception as error:
# In particular, a failed fetch retains the old baseline and mode,
# so retrying the explicit refresh cannot mistake it for a partial clone.
record["error"] = str(error)
self.state.save()
raise
def prepare_one(self, component, refresh_fixed=False):
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=refresh_fixed or not (editable or partial))
if refresh_fixed:
if not record or record.get("mode") != "versioned" or actual["branch"] or actual["head"] != record["baseline"]:
raise WorkspaceError(f"fixed refresh requires a registered detached versioned checkout at its baseline: {name}")
if actual["head"] != commit:
return self.refresh_versioned(component, path, record, commit, source)
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})
record.pop("error", None)
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")
parser.add_argument("--refresh-fixed", action="store_true", help="explicitly refresh registered clean detached fixed sources")
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")
if args.refresh_fixed and args.command != "prepare":
raise WorkspaceError("--refresh-fixed is only valid for prepare")
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, refresh_fixed=args.refresh_fixed)
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