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