import json from pathlib import Path import shutil import tempfile from .common import WorkspaceError, atomic_write, digest, run, safe_path from .manifest import SHA class Go: def __init__(self, workspace): self.workspace = workspace def call(self, path, *args): result = run(["go", *args], path, self.workspace.env) try: return json.loads(result.stdout) except ValueError as error: raise WorkspaceError("Go returned invalid module metadata") from error def modules(self, component): result = [] base = safe_path(self.workspace.root, component["path"]) for module in component.get("modules", []): directory = safe_path(base, module.get("directory", "."), allow_dot=True) if (directory / "go.mod").is_file(): document = self.call(directory, "mod", "edit", "-json") if document["Module"]["Path"] not in (module["path"], module.get("legacy")): raise WorkspaceError(f"module identity mismatch at {directory}") result.append((module, directory, document)) return result def resolve(self, component): selected = [] consumers = sorted(self.workspace.manifest.select(), key=lambda c: ("backend" not in c["id"].lower(), c["id"])) for consumer in consumers: if consumer["role"] != "application": continue for consumer_module, directory, document in self.modules(consumer): if document["Module"]["Path"] != consumer_module["path"]: # An old short-name graph still belongs to the old gitlinks. continue # Package management can update go.sum even when source merely # needs inspection. Resolve an identical isolated manifest so # both editable and historical consumers stay untouched. with tempfile.TemporaryDirectory(prefix="workspace-go-resolve-") as temporary: isolated = Path(temporary) shutil.copy2(directory / "go.mod", isolated / "go.mod") if (directory / "go.sum").exists(): shutil.copy2(directory / "go.sum", isolated / "go.sum") output = run(["go", "list", "-mod=mod", "-m", "-json", "all"], isolated, self.workspace.env).stdout graph = {} decoder = json.JSONDecoder() while output.strip(): metadata, end = decoder.raw_decode(output.lstrip()) output = output.lstrip()[end:] graph[metadata["Path"]] = metadata for module in component.get("modules", []): metadata = graph.get(module["path"]) if not metadata: continue if metadata.get("Replace"): raise WorkspaceError(f"local replacement cannot establish version baseline: {module['path']}") version = metadata.get("Version") if not version: raise WorkspaceError("Go module has no fixed selected version") download = self.call(isolated, "mod", "download", "-json", module["path"] + "@" + version) origin = download.get("Origin") or {} if not origin and download.get('Info'): # Cached downloads may omit Origin in stdout; the # Go-owned .info file retains the authenticated source. cached = json.loads(Path(download['Info']).read_text()) origin = cached.get('Origin') or {} commit = origin.get("Hash", "") if origin.get("VCS") != "git" or not SHA.fullmatch(commit): raise WorkspaceError(f"Go module has no verifiable Origin.Hash: {module['path']}") if origin.get("URL") != component["repo"]: # Canonical discovery uses HTTPS; transport maps that # exact registered source to SSH, without a new identity. canonical = "https://" + module["path"].split(".git", 1)[0] if origin.get("URL") not in {canonical, canonical + '.git', component['repo'].removesuffix('.git')}: raise WorkspaceError(f"Go Origin URL does not match approved repository: {module['path']}") selected.append((commit, {"kind": "go-module", "module": module["path"], "version": version, "consumer": consumer["id"], "origin": origin})) if not selected: return None if len({entry[0] for entry in selected}) != 1: raise WorkspaceError(f"consumer/submodule versions select different commits for {component['id']}; owner must choose compatible versions") commit, source = selected[0] source = dict(source, consumers=sorted({entry[1]["consumer"] for entry in selected}), selections=[entry[1] for entry in selected]) return commit, source def check_owned(self): path = safe_path(self.workspace.root, "go.work") recorded = self.workspace.state.data.get("go_work") if path.exists() and (not recorded or digest(path) != recorded.get("sha256")): raise WorkspaceError("go.work is not the registered generated overlay; preserve manual changes") if recorded and not path.exists(): raise WorkspaceError("registered go.work is missing; owner must resolve overlay state") def update(self): self.check_owned() paths = set() linked_modules = set() cached = {} def modules(component): if component["id"] not in cached: cached[component["id"]] = self.modules(component) return cached[component["id"]] for name, entry in self.workspace.state.data["components"].items(): if entry.get("linked"): for _, directory, document in modules(self.workspace.manifest.components[name]): paths.add(directory) linked_modules.add(document["Module"]["Path"]) for consumer in entry.get("source", {}).get("consumers", []): for _, directory, document in modules(self.workspace.manifest.components[consumer]): paths.add(directory) linked_modules.add(document["Module"]["Path"]) if linked_modules: # Include only consuming modules, including transitive local consumers. changed = True while changed: changed = False for component in self.workspace.manifest.select(): for _, directory, document in modules(component): required = {item["Path"] for item in document.get("Require") or []} if required & linked_modules and directory not in paths: paths.add(directory) linked_modules.add(document["Module"]["Path"]) changed = True path = safe_path(self.workspace.root, "go.work") if not paths: if path.exists(): path.unlink() self.workspace.state.data.pop("go_work", None) return version = run(["go", "env", "GOVERSION"], self.workspace.root, self.workspace.env).stdout.strip().removeprefix("go") content = "go " + version + "\n\nuse (\n" + "".join("\t" + json.dumps("./" + str(item.relative_to(self.workspace.root))) + "\n" for item in sorted(paths)) + ")\n" documents = [(directory, document) for rows in cached.values() for _, directory, document in rows if directory in paths] directories = {document["Module"]["Path"]: directory for directory, document in documents} aliases = {entry["Old"]["Path"] for _, document in documents for entry in document.get("Replace") or [] if not entry["New"].get("Version") and entry["Old"]["Path"] in directories} versions = {(entry["Path"], entry["Version"]) for _, document in documents for entry in document.get("Require") or [] if entry["Path"] in aliases} versions.update((entry["Old"]["Path"], entry["Old"]["Version"]) for _, document in documents for entry in document.get("Replace") or [] if entry["Old"]["Path"] in aliases and entry["Old"].get("Version")) # Historical modules can replace the same library with different nested # paths. Exact-version local overrides reconcile those paths while Go # forbids an all-version replacement of a workspace main module. if versions: content += "\nreplace (\n" + "".join("\t" + name + " " + selected + " => " + json.dumps("./" + str(directories[name].relative_to(self.workspace.root))) + "\n" for name, selected in sorted(versions)) + ")\n" atomic_write(path, content) self.workspace.state.data["go_work"] = {"sha256": digest(path), "paths": [str(item.relative_to(self.workspace.root)) for item in sorted(paths)]}