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:
2026-10-13 15:58:23 +00:00
parent f71058cb7e
commit b450e51c40
20 changed files with 2633 additions and 13 deletions
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -53,7 +53,8 @@
"legacy_path": "modules/ui",
"role": "npm-library",
"modules": [],
"target": "main"
"target": "main",
"package_name": "@agent-system/ui"
},
{
"id": "Agent-System-Frontend",
@@ -140,6 +141,11 @@
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git/cmd/agent-squad",
"legacy": "agent-squad/cmd/agent-squad",
"directory": "cmd/agent-squad"
},
{
"path": "git-ssh.u1680832.nyat.app/weichen/Agent-Squad.git/adapters/a2a",
"legacy": "agent-squad/adapters/a2a",
"directory": "adapters/a2a"
}
],
"target": "main"
+2 -5
View File
@@ -1,14 +1,11 @@
{
"schema": 1,
"components": {
"Agent-System-UI": {
"commit": "2c4322ef56df64ad72f65907d25b048a74bb5ffc"
},
"Agent-System-Frontend": {
"commit": "eb2d6d32f9323094d568bc9d80b4e5ed4f0af25b"
"commit": "ad53e90a8542143104ddc7f759c8f8e7b7001924"
},
"Agent-System-Backend": {
"commit": "5d5fc9891c0eb8994545ccb8b740535d9b99fb0e"
"commit": "f1302ed5b3952aeb4ad386a793c0c78fe90d7a3c"
},
"Development-Guidelines": {
"commit": "2f5b19872be2a4597838a4131ad75d1be089ee5e"
+6
View File
@@ -43,6 +43,12 @@ def card_path(path):
def check_snapshot(snapshot):
evidence.validate_snapshot(snapshot)
stale = check_head(snapshot["head"])
current = evidence.component_snapshot(Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()))
if current is not None and snapshot.get('components') != current:
raise ValueError('recorded ordinary component/source-lock objects differ from the current objects')
for item in snapshot.get('components', {}).get('components', {}).values():
if item.get('status') or item.get('untracked'):
raise ValueError('recorded ordinary component includes uncommitted changes')
for line in snapshot["status"].splitlines():
if len(line) < 4 or line[2] != " ":
raise ValueError("invalid recorded worktree status")
+30
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Fail closed before task synchronization, verification, or worktree disposal."""
import os
import json
from pathlib import Path
import subprocess
import sys
@@ -103,6 +104,34 @@ def materialize(repo):
materialize(child)
def ordinary_components(repo, mode, removal_roots=()):
manifest = repo / 'components.json'
if not manifest.exists():
return
document = json.loads(manifest.read_text())
if document.get('schema') != 1 or not isinstance(document.get('components'), list):
raise RuntimeError('invalid component manifest')
for item in document['components']:
name = item.get('id')
relative = item.get('path')
if not isinstance(name, str) or relative != '.workspace/' + name:
raise RuntimeError('invalid component identity/path')
child = repo / relative
if child.resolve() != child.absolute() or repo not in child.resolve().parents:
raise RuntimeError('component path escapes its owner')
if not child.exists():
continue
if not (child / '.git').exists() or Path(text(child, 'rev-parse', '--show-toplevel')).resolve() != child:
raise RuntimeError('component has borrowed or missing Git identity')
check_operations(child)
if mode == 'finish':
check_clean(child, ignored=True)
check_component_worktrees(child, removal_roots)
head = text(child, 'rev-parse', 'HEAD')
if local_work_outside(child, head):
raise RuntimeError('component has unpreserved local refs; preserve its source before removal')
def local_work_outside(child, expected):
"""2026-10-05 修复:pinned-history 检查此前用 --all --reflog,把 refs/remotes 也
计入——pin 在 feature 顶端的子仓因 origin/main 不在 pin 历史内而永远无法 finish
@@ -165,6 +194,7 @@ def main():
materialize(repo)
else:
check_children(repo, mode, removal_roots)
ordinary_components(repo, mode, removal_roots)
print(f"[worktree] {mode}: recursive component checks passed")
+54 -2
View File
@@ -82,6 +82,52 @@ def common_dir():
return str(Path(git_text("rev-parse", "--git-common-dir")).resolve())
def component_snapshot(root):
"""Record ignored ordinary checkouts without resolving or changing versions."""
manifest = root / 'components.json'
if not manifest.exists():
return None
try:
config = json.loads(manifest.read_text())
if config.get('schema') != 1 or not isinstance(config.get('components'), list):
raise EvidenceError('Invalid component manifest')
result = {'manifest_sha256': digest(manifest.read_bytes()), 'components': {}}
lock = root / 'components.lock.json'
result['lock_sha256'] = digest(lock.read_bytes()) if lock.exists() else None
state = root / '.workspace/state.json'
result['state_sha256'] = digest(state.read_bytes()) if state.exists() else None
for item in config['components']:
name, relative = item.get('id'), item.get('path')
if not isinstance(name, str) or not isinstance(relative, str) or relative != '.workspace/' + name:
raise EvidenceError('Invalid component identity/path')
path = root / relative
if path.resolve() != path.absolute() or root.resolve() not in path.resolve().parents:
raise EvidenceError('Component path escapes its owner')
if not path.exists():
result['components'][name] = {'state': 'missing'}
continue
if not (path / '.git').exists():
raise EvidenceError('Component source has no Git identity')
if Path(git_text('-C', str(path), 'rev-parse', '--show-toplevel')).resolve() != path.resolve():
raise EvidenceError('Component Git identity is borrowed')
untracked = {}
for raw in git('-C', str(path), 'ls-files', '--others', '--exclude-standard', '-z').split(b'\0'):
if raw:
filename = os.fsdecode(raw)
entry = path / filename
untracked[filename] = digest(os.fsencode(os.readlink(entry))) if entry.is_symlink() else (digest(entry.read_bytes()) if entry.is_file() else None)
result['components'][name] = {
'head': git_text('-C', str(path), 'rev-parse', 'HEAD'),
'branch': git_text('-C', str(path), 'branch', '--show-current'),
'status': git('-C', str(path), 'status', '--porcelain', '--untracked-files=all').decode('utf-8', 'replace'),
'tracked_diff_sha256': digest(git('-C', str(path), 'diff', '--binary', 'HEAD', '--')),
'untracked': untracked,
}
return result
except (OSError, ValueError, TypeError, AttributeError) as error:
raise EvidenceError('Component object could not be recorded') from error
def snapshot():
untracked = {}
root = Path(git_text("rev-parse", "--show-toplevel"))
@@ -98,7 +144,7 @@ def snapshot():
else:
untracked[name] = None
target = os.environ.get("TASK_TARGET_BRANCH", "main")
return {
result = {
"head": git_text("rev-parse", "HEAD"),
"branch": git_text("rev-parse", "--abbrev-ref", "HEAD"),
"target_branch": target,
@@ -109,6 +155,10 @@ def snapshot():
"untracked": untracked,
"submodules": git("submodule", "status", "--recursive").decode("utf-8", "replace"),
}
components = component_snapshot(root)
if components is not None:
result['components'] = components
return result
def create_receipt(command, output, exit_code, before, after, *, timestamp=None,
@@ -135,8 +185,10 @@ def validate_snapshot(value):
raise EvidenceError("Invalid receipt object snapshot")
expected = {"head", "branch", "target_branch", "target_head", "status",
"tracked_diff_sha256", "submodules", "untracked"}
if set(value) != expected:
if set(value) not in (expected, expected | {'components'}):
raise EvidenceError("Invalid receipt object fields")
if 'components' in value and (not isinstance(value['components'], dict) or not isinstance(value['components'].get('components'), dict)):
raise EvidenceError('Invalid component snapshot')
for key in ("head", "branch", "target_branch", "status", "tracked_diff_sha256", "submodules"):
if not isinstance(value.get(key), str):
raise EvidenceError("Invalid receipt object field: " + key)
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env python3
"""Public entry point for fixed sources and local component development."""
from workspace.cli import main
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""Safe, local component source workspaces; language manifests own dependencies."""
+237
View File
@@ -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
+75
View File
@@ -0,0 +1,75 @@
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import subprocess
import tempfile
class WorkspaceError(Exception):
pass
def run(args, cwd, env=None, check=True):
result = subprocess.run(args, cwd=cwd, env=env, text=True, capture_output=True,
timeout=180)
if check and result.returncode:
raise WorkspaceError(f"{args[0]} failed ({result.returncode}): {result.stderr.strip() or result.stdout.strip()}")
return result
def relative_path(value, allow_dot=False):
if not isinstance(value, str) or not value or "\\" in value or any(ord(c) < 32 for c in value):
raise WorkspaceError("invalid relative path")
path = PurePosixPath(value)
if path.is_absolute() or ".." in path.parts or (value == "." and not allow_dot):
raise WorkspaceError(f"unsafe relative path: {value}")
return path
def safe_path(root, value, allow_dot=False):
relative_path(value, allow_dot)
path = root / value
# Refuse symlink path components even when a link currently points inside root.
# This avoids creating a second apparent owner and later retargeting escapes.
current = root
for part in PurePosixPath(value).parts:
current /= part
if current.is_symlink():
raise WorkspaceError(f"symlink path is not owned by this workspace: {value}")
if not path.resolve().is_relative_to(root.resolve()):
raise WorkspaceError(f"path escapes workspace: {value}")
return path
def read_json(path):
if path.is_symlink():
raise WorkspaceError(f"refusing symlink metadata: {path}")
try:
return json.loads(path.read_text())
except (OSError, ValueError) as error:
raise WorkspaceError(f"cannot read {path.name}: {error}") from error
def atomic_write(path, content):
if path.is_symlink():
raise WorkspaceError(f"refusing symlink output: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix="." + path.name + "-", dir=path.parent)
try:
with os.fdopen(descriptor, "w") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def write_json(path, value):
atomic_write(path, json.dumps(value, indent=2, sort_keys=True) + "\n")
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
+70
View File
@@ -0,0 +1,70 @@
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from .common import WorkspaceError, run
def display_origin(value):
parsed = urlsplit(value)
netloc = parsed.netloc
if parsed.username and (parsed.scheme != "ssh" or parsed.username != "git" or parsed.password):
netloc = "<redacted>@" + netloc.rsplit("@", 1)[-1]
return urlunsplit((parsed.scheme, netloc, parsed.path,
"<redacted>" if parsed.query else "", "<redacted>" if parsed.fragment else ""))
class Git:
def __init__(self, env):
self.env = env
def call(self, path, *args, check=True):
return run(["git", "-c", "gc.auto=0", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", *args], path, self.env, check)
def text(self, path, *args, check=True):
return self.call(path, *args, check=check).stdout.strip()
def inspect(self, path):
if not (path / ".git").exists():
raise WorkspaceError(f"existing path is not an independent Git checkout: {path}")
top = Path(self.text(path, "rev-parse", "--show-toplevel"))
if top.resolve() != path.resolve():
raise WorkspaceError("component path is not a Git root")
gitdir = Path(self.text(path, "rev-parse", "--absolute-git-dir"))
common = Path(self.text(path, "rev-parse", "--path-format=absolute", "--git-common-dir"))
locks = sorted(str(file) for directory in {gitdir, common} for file in directory.rglob("*.lock"))
operations = [name for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "BISECT_LOG")
if (gitdir / name).exists()]
changes = self.text(path, "status", "--porcelain=v1", "--untracked-files=all")
ignored = self.text(path, "ls-files", "--others", "--ignored", "--exclude-standard")
head = self.text(path, "rev-parse", "--verify", "HEAD", check=False)
branch = self.text(path, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)
worktrees = self.text(path, "worktree", "list", "--porcelain")
return {"head": head or None, "branch": branch or None,
"origin": display_origin(self.text(path, "remote", "get-url", "origin", check=False)),
"dirty": bool(changes or ignored), "changes": changes.splitlines(), "ignored": ignored.splitlines(),
"locks": locks, "operations": operations,
"worktrees": [line[9:] for line in worktrees.splitlines() if line.startswith("worktree ")],
"git_dir": str(gitdir), "common_dir": str(common)}
def safe(self, path, origin=None, clean=True, independent=True):
actual = self.inspect(path)
if origin is not None and actual["origin"] != origin:
raise WorkspaceError(f"origin mismatch at {path}")
if actual["locks"] or actual["operations"]:
raise WorkspaceError(f"Git operation/lock in progress at {path}")
if independent and (actual["git_dir"] != actual["common_dir"] or len(actual["worktrees"]) != 1):
raise WorkspaceError(f"shared Git worktree ownership at {path}; owner must resolve it")
if independent and ((path / ".git").is_symlink() or not (path / ".git").is_dir()
or Path(actual["git_dir"]).resolve() != (path / ".git").resolve()):
raise WorkspaceError(f"component Git metadata is owned outside its checkout: {path}")
if clean and actual["dirty"]:
raise WorkspaceError(f"dirty or ignored unique files at {path}; owner must save them")
return actual
def gitlink(self, root, legacy):
if not legacy:
return None
result = self.text(root, "ls-tree", "HEAD", "--", legacy, check=False)
if result.startswith("160000 commit "):
return result.split()[2]
return None
+153
View File
@@ -0,0 +1,153 @@
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 {}
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] + ".git"
if origin.get("URL") != canonical:
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)]}
+94
View File
@@ -0,0 +1,94 @@
import re
from urllib.parse import urlsplit
from .common import WorkspaceError, read_json, relative_path, safe_path
ROLES = {"application", "go-library", "npm-library", "documentation", "guidelines"}
FIXED_ROLES = {"application", "documentation", "guidelines"}
SHA = re.compile(r"[0-9a-f]{40}\Z")
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z")
def validate_repo(repo):
if not isinstance(repo, str) or not repo or repo.startswith("-") or any(c.isspace() for c in repo):
raise WorkspaceError("invalid component repository")
if "://" in repo:
parsed = urlsplit(repo)
if parsed.scheme not in {"ssh", "https", "file"} or parsed.password or parsed.query or parsed.fragment:
raise WorkspaceError("repository must be a credential-free approved Git URL")
if parsed.scheme == "https" and parsed.username:
raise WorkspaceError("repository credentials are forbidden")
if parsed.scheme == "ssh" and parsed.username not in (None, "git"):
raise WorkspaceError("unsupported SSH repository identity")
elif not repo.startswith("/"):
raise WorkspaceError("repository must be an absolute local path or approved URL")
class Manifest:
def __init__(self, root):
self.root = root
document = read_json(root / "components.json")
if not isinstance(document, dict) or document.get("schema") != 1 or not isinstance(document.get("components"), list):
raise WorkspaceError("unsupported components schema")
self.components = {}
modules = set()
for component in document["components"]:
if not isinstance(component, dict):
raise WorkspaceError("invalid component record")
name = component.get("id")
if not isinstance(name, str) or not IDENTIFIER.fullmatch(name) or name in self.components:
raise WorkspaceError("invalid or duplicate component id")
if component.get("path") != ".workspace/" + name:
raise WorkspaceError(f"component path must be .workspace/{name}")
safe_path(root, component["path"])
if component.get("legacy_path"):
safe_path(root, component["legacy_path"])
if component.get("role") not in ROLES:
raise WorkspaceError(f"invalid component role: {name}")
package = component.get("package_name")
if package is not None and (component["role"] != "npm-library" or not isinstance(package, str)
or not re.fullmatch(r"(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*", package)):
raise WorkspaceError(f"invalid npm package mapping: {name}")
validate_repo(component.get("repo"))
target = component.get("target", "main")
validate_ref(target)
entries = component.get("modules", [])
if not isinstance(entries, list):
raise WorkspaceError("modules must be a list")
for module in entries:
if not isinstance(module, dict) or not isinstance(module.get("path"), str) or not module["path"]:
raise WorkspaceError("invalid module mapping")
if module["path"] in modules or any(c.isspace() for c in module["path"]):
raise WorkspaceError("duplicate or invalid module identity")
relative_path(module.get("directory", "."), allow_dot=True)
modules.add(module["path"])
self.components[name] = component
lock = read_json(root / "components.lock.json")
if not isinstance(lock, dict) or lock.get("schema") != 1 or not isinstance(lock.get("components"), dict):
raise WorkspaceError("unsupported component lock schema")
self.lock = lock["components"]
for name, value in self.lock.items():
if name not in self.components or not isinstance(value, dict) or not SHA.fullmatch(str(value.get("commit", ""))):
raise WorkspaceError(f"invalid locked component: {name}")
if self.components[name]["role"] in ("go-library", "npm-library"):
raise WorkspaceError(f"language library {name} cannot have a competing source lock")
def select(self, name=None, fixed=False):
if name:
if name not in self.components:
raise WorkspaceError(f"unknown component: {name}")
return [self.components[name]]
return [entry for entry in self.components.values() if not fixed or entry["role"] in FIXED_ROLES]
def validate_ref(ref):
if not isinstance(ref, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", ref):
raise WorkspaceError("invalid branch/ref name")
if any(token in ref for token in ("..", "//", "/.", ".lock")) or ref.endswith(("/", ".")):
raise WorkspaceError("invalid branch/ref name")
def validate_branch(branch):
validate_ref(branch)
if not branch.startswith(("feature/", "fix/", "chore/", "codex/")):
raise WorkspaceError("editing requires a named task branch")
+62
View File
@@ -0,0 +1,62 @@
"""Resolve editable npm source from a consumer's Git package lock, without npm hooks."""
from pathlib import Path
from urllib.parse import unquote, urlsplit, urlunsplit
from .common import WorkspaceError, read_json, safe_path
from .manifest import SHA, validate_repo
def repository_identity(value):
value = value.removeprefix("git+")
parsed = urlsplit(value)
if parsed.scheme == "file":
if parsed.netloc not in ("", "localhost"):
raise WorkspaceError("npm Git origin must be local or approved")
return str(Path(unquote(parsed.path)).resolve())
validate_repo(value)
return value
class Npm:
def __init__(self, workspace):
self.workspace = workspace
def resolve(self, component):
package = component.get("package_name")
if not package:
return None
selected = []
consumers = sorted(self.workspace.manifest.select(), key=lambda entry: ("frontend" not in entry["id"].lower(), entry["id"]))
for consumer in consumers:
if consumer["role"] != "application":
continue
root = safe_path(self.workspace.root, consumer["path"])
path = safe_path(root, "package-lock.json")
if not path.exists():
continue
lock = read_json(path)
if not isinstance(lock, dict) or lock.get("lockfileVersion") not in (2, 3) or not isinstance(lock.get("packages"), dict):
raise WorkspaceError(f"unsupported npm lock for {consumer['id']}")
for location, entry in lock["packages"].items():
if location != "node_modules/" + package and not location.endswith("/node_modules/" + package):
continue
if not isinstance(entry, dict) or entry.get("link"):
raise WorkspaceError(f"local npm link cannot establish version baseline: {package}")
resolved = entry.get("resolved", "")
if not isinstance(resolved, str) or not resolved.startswith("git+"):
raise WorkspaceError(f"npm source needs a fixed approved Git origin: {package}")
parsed = urlsplit(resolved)
if not SHA.fullmatch(parsed.fragment):
raise WorkspaceError(f"npm Git source must pin a full commit: {package}")
origin = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, parsed.query, ""))
if repository_identity(origin) != repository_identity(component["repo"]):
raise WorkspaceError(f"npm Git origin mismatch: {package}")
selected.append((parsed.fragment, {"kind": "npm-lock", "package": package,
"version": entry.get("version"), "resolved": resolved,
"consumer": consumer["id"], "lock_path": str(path.relative_to(self.workspace.root))}))
if not selected:
return None
if len({entry[0] for entry in selected}) != 1:
raise WorkspaceError(f"consumers select conflicting npm Git commits for {package}; owner must choose compatible versions")
commit, source = selected[0]
return commit, dict(source, selections=[entry[1] for entry in selected])
+36
View File
@@ -0,0 +1,36 @@
from contextlib import contextmanager
import os
from .common import WorkspaceError, read_json, safe_path, write_json
class State:
def __init__(self, root):
self.root = root
self.area = safe_path(root, ".workspace")
self.path = safe_path(root, ".workspace/state.json")
self.data = read_json(self.path) if self.path.exists() else {"schema": 1, "components": {}}
if not isinstance(self.data, dict) or self.data.get("schema") != 1 or not isinstance(self.data.get("components"), dict):
raise WorkspaceError("unsupported workspace state")
for name, entry in self.data["components"].items():
if not isinstance(entry, dict) or entry.get("owner_root") != str(root):
raise WorkspaceError(f"workspace owner mismatch: {name}")
safe_path(root, entry.get("path", ""))
def save(self):
write_json(self.path, self.data)
@contextmanager
def operation(self):
self.area.mkdir(exist_ok=True)
path = safe_path(self.root, ".workspace/.operation.lock")
try:
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError as error:
raise WorkspaceError("workspace has another writer (.operation.lock); preserve the owner lock") from error
try:
with os.fdopen(descriptor, "w") as stream:
stream.write(str(os.getpid()) + "\n")
yield
finally:
path.unlink()
+249
View File
@@ -0,0 +1,249 @@
import hashlib
import os
import shutil
from .common import WorkspaceError, safe_path
from .manifest import validate_branch
def files_snapshot(path, excluded=()):
"""Hash files without following links or opening another repository's metadata."""
files = {}
for current, directories, names in os.walk(path, followlinks=False):
directories[:] = [name for name in directories if name != ".git"
and str((type(path)(current) / name).relative_to(path)) not in excluded]
for name in directories + names:
file = type(path)(current) / name
if name == ".git":
continue
relative = str(file.relative_to(path))
if file.is_symlink():
files[relative] = {"link": os.readlink(file)}
elif file.is_file():
files[relative] = {"sha256": hashlib.sha256(file.read_bytes()).hexdigest(),
"mode": file.stat().st_mode & 0o777}
return files
class Transition:
def __init__(self, workspace):
self.workspace = workspace
def inspect(self, component):
root = self.workspace.root
legacy_name = component.get("legacy_path")
legacy = safe_path(root, legacy_name) if legacy_name else None
if not legacy or not (legacy / ".git").exists():
return {"path": legacy_name, "gitlink": self.workspace.git.gitlink(root, legacy_name)}
return self.inspect_at(legacy, self.workspace.git.gitlink(root, legacy_name))
def inspect_at(self, legacy, pin=None):
root = self.workspace.root
result = {"path": str(legacy.relative_to(root)), "gitlink": pin}
if (legacy / ".git").exists():
result.update(self.workspace.git.inspect(legacy))
result["refs"] = self.workspace.git.text(legacy, "show-ref", check=False).splitlines()
result["reflog_commits"] = sorted(set(self.workspace.git.text(legacy, "reflog", "--all", "--format=%H", check=False).splitlines()))
index = type(root)(result["git_dir"]) / "index"
result["index_sha256"] = hashlib.sha256(index.read_bytes()).hexdigest() if index.exists() else None
result["index_entries"] = self.workspace.git.text(legacy, "ls-files", "--stage", "-z")
gitlinks = {}
for row in result["index_entries"].split("\0"):
if not row:
continue
metadata, relative = row.split("\t", 1)
mode, commit, stage = metadata.split()
if mode == "160000" and stage == "0":
gitlinks[relative] = commit
nested = []
for current, directories, names in os.walk(legacy, followlinks=False):
if current == str(legacy):
directories[:] = [name for name in directories if name != ".git"]
continue
if ".git" in directories or ".git" in names:
path = type(legacy)(current)
relative = str(path.relative_to(legacy))
child = self.inspect_at(path, gitlinks.get(relative))
matches = [entry["id"] for entry in self.workspace.manifest.select()
if entry["repo"] == child.get("origin")]
nested.append({"path": relative, "id": matches[0] if len(matches) == 1 else None,
"tracked": relative in gitlinks, "snapshot": child})
directories[:] = []
result["nested"] = nested
result["files"] = files_snapshot(legacy, {entry["path"] for entry in nested})
return result
def validate_files(self, legacy, snapshot):
for relative, entry in snapshot.items():
file = legacy / relative
if "link" in entry and (os.path.isabs(entry["link"]) or not file.resolve().is_relative_to(legacy.resolve())):
raise WorkspaceError(f"legacy symlink escapes component: {relative}; owner must resolve it")
def content_identity(self, snapshot):
return snapshot["head"], snapshot["files"], snapshot["index_entries"]
def preserve_refs(self, path, snapshots):
workspace = self.workspace
for snapshot in snapshots:
legacy = safe_path(workspace.root, snapshot["path"])
namespace = hashlib.sha256(snapshot["path"].encode()).hexdigest()[:24]
workspace.git.call(path, "fetch", "--no-tags", "--", str(legacy), "+refs/*:refs/migration/sources/" + namespace + "/*")
for commit in snapshot["reflog_commits"]:
if workspace.git.call(path, "cat-file", "-e", commit, check=False).returncode:
workspace.git.call(path, "fetch", "--no-tags", "--", str(legacy), commit)
workspace.git.call(path, "update-ref", "refs/migration/reflog/" + commit, commit)
def apply_all(self, components, branch):
"""Resolve the whole nested ownership plan before creating any clone."""
workspace = self.workspace
workspace.go.check_owned()
groups = {}
def add(component, snapshot):
name = component["id"]
legacy = safe_path(workspace.root, snapshot["path"])
if not snapshot.get("head"):
raise WorkspaceError(f"legacy checkout is unavailable: {name}")
workspace.git.safe(legacy, component["repo"], clean=False, independent=False)
self.validate_files(legacy, snapshot["files"])
gitdir = type(legacy)(snapshot["git_dir"])
if (gitdir / "objects/info/alternates").exists():
raise WorkspaceError(f"legacy object store borrows another repository: {name}; owner must make it independent")
previous = groups.setdefault(name, [])
if previous:
if self.content_identity(previous[0]) != self.content_identity(snapshot):
raise WorkspaceError(f"conflicting duplicate logical component {name}: {previous[0]['path']} and {snapshot['path']}; owner must choose or reconcile them")
old_refs = dict(row.split(" ", 1)[::-1] for old in previous for row in old["refs"])
new_refs = dict(row.split(" ", 1)[::-1] for row in snapshot["refs"])
if any(old_refs[ref] != new_refs[ref] for ref in old_refs.keys() & new_refs.keys()):
raise WorkspaceError(f"conflicting duplicate refs for {name}; owner must reconcile them")
if any(old["path"] == snapshot["path"] for old in previous):
return
previous.append(snapshot)
for nested in snapshot.get("nested", []):
if not nested["tracked"] or nested["id"] is None:
raise WorkspaceError(f"unknown nested repository ownership at {snapshot['path']}/{nested['path']}; register its origin and Git ownership first")
add(workspace.manifest.components[nested["id"]], nested["snapshot"])
for component in components:
snapshot = self.inspect(component)
# Default transition also lists uninitialized components; a named
# unavailable checkout still reports a precise failure to its owner.
add(component, snapshot)
for name, snapshots in groups.items():
component = workspace.manifest.components[name]
path = safe_path(workspace.root, component["path"])
if path.exists():
record = workspace.state.data["components"].get(name)
actual = workspace.git.safe(path, component["repo"], clean=False)
if not record or record.get("source", {}).get("kind") != "legacy-copy" or actual["branch"] != record.get("branch"):
raise WorkspaceError(f"migration destination has another owner: {name}; inspect its registered branch")
if actual["head"] != snapshots[0]["head"] or files_snapshot(path) != snapshots[0]["files"] or workspace.git.text(path, "ls-files", "--stage", "-z") != snapshots[0]["index_entries"]:
raise WorkspaceError(f"conflicting duplicate destination for {name}; preserve both scenes for the owner")
for name, snapshots in groups.items():
component = workspace.manifest.components[name]
path = safe_path(workspace.root, component["path"])
if path.exists():
self.preserve_refs(path, snapshots)
record = workspace.state.data["components"][name]
record["legacy_snapshots"] = snapshots
workspace.state.save()
else:
self.copy_one(component, branch, snapshots)
for snapshots in groups.values():
for snapshot in snapshots:
legacy = safe_path(workspace.root, snapshot["path"])
if self.inspect_at(legacy, snapshot["gitlink"]) != snapshot:
raise WorkspaceError("legacy checkout changed during migration; independent copies retained for owner review")
for name in groups:
record = workspace.state.data["components"][name]
record["linked"] = bool(workspace.go.modules(workspace.manifest.components[name]))
if record["linked"]:
record["mode"] = "linked"
workspace.go.update()
workspace.state.save()
def copy_one(self, component, branch, snapshots):
workspace = self.workspace
name = component["id"]
snapshot = snapshots[0]
legacy = safe_path(workspace.root, snapshot["path"])
excluded = {entry["path"] for entry in snapshot.get("nested", [])}
gitdir = type(legacy)(snapshot["git_dir"])
path = safe_path(workspace.root, component["path"])
if path.exists():
raise WorkspaceError(f"migration destination already exists: {name}; inspect its owner before retrying")
branch = branch or snapshot.get("branch")
if not branch or not branch.startswith(("feature/", "fix/", "chore/", "codex/")):
branch = "feature/migration-" + name.lower()
validate_branch(branch)
mappings = [{"id": nested["id"], "legacy_path": nested["snapshot"]["path"],
"workspace_path": workspace.manifest.components[nested["id"]]["path"]}
for nested in snapshot.get("nested", [])]
record = workspace.record(component, snapshot["head"], {"kind": "legacy-copy", "gitlink": snapshot["gitlink"], "nested_components": mappings})
record.update({"mode": "migrating", "legacy_snapshot": snapshot, "legacy_snapshots": snapshots})
workspace.state.save()
try:
# Local clone copies the complete object store without hardlinks/alternates.
# It never shares the submodule's Git common directory with the new root.
workspace.git.call(workspace.root, "clone", "--no-hardlinks", "--no-checkout", "--", str(legacy), str(path))
workspace.git.call(path, "remote", "set-url", "origin", component["repo"])
workspace.git.call(path, "fetch", "--no-tags", "--", str(legacy), "+refs/*:refs/migration/*")
self.preserve_refs(path, snapshots)
workspace.git.call(path, "checkout", "--detach", snapshot["head"])
if workspace.git.call(path, "show-ref", "--verify", "--quiet", "refs/heads/" + branch, check=False).returncode == 0:
if workspace.git.text(path, "rev-parse", "refs/heads/" + branch) != snapshot["head"]:
raise WorkspaceError("migration branch conflict; original and partial clone retained")
workspace.git.call(path, "checkout", branch)
else:
workspace.git.call(path, "checkout", "-b", branch)
# Keep deletions as well as tracked, untracked, and ignored file content.
for filename in workspace.git.text(legacy, "diff", "--name-only", "-z", "--diff-filter=D", "HEAD").split("\0"):
if not filename:
continue
relative = type(path)(filename)
parent = safe_path(path, str(relative.parent), allow_dot=True)
file = parent / relative.name
if file.is_file() or file.is_symlink():
file.unlink()
for current, directories, names in os.walk(legacy, followlinks=False):
directories[:] = [entry for entry in directories if entry != ".git"
and str((type(legacy)(current) / entry).relative_to(legacy)) not in excluded]
relative = type(legacy)(current).relative_to(legacy)
destination = path / relative
destination.mkdir(parents=True, exist_ok=True)
for filename in directories + names:
if filename == ".git":
continue
source = type(legacy)(current) / filename
target = destination / filename
if source.is_symlink():
if target.is_symlink() and os.readlink(target) == os.readlink(source):
continue
if target.exists() or target.is_symlink():
if target.is_dir() and not target.is_symlink():
raise WorkspaceError(f"migration symlink conflicts with directory: {filename}")
target.unlink()
target.symlink_to(os.readlink(source), target_is_directory=source.is_dir())
elif source.is_file():
if target.is_symlink():
target.unlink()
shutil.copy2(source, target)
# The clone copied all local objects, including staged blobs which
# are absent from HEAD. Preserve the index and split-index companions
# independently so staging is not silently flattened into file copies.
for index in list(gitdir.glob("sharedindex.*")) + [gitdir / "index"]:
if index.is_file():
shutil.copy2(index, path / ".git" / index.name)
if self.inspect_at(legacy, snapshot["gitlink"]) != snapshot:
raise WorkspaceError("legacy checkout changed during migration; partial clone preserved for owner")
if files_snapshot(path) != snapshot["files"]:
raise WorkspaceError("migration file snapshot differs; original and partial clone preserved for owner")
workspace.git.safe(path, component["repo"], clean=False)
record.update({"mode": "editable", "branch": branch, "linked": False})
record.pop("error", None)
workspace.state.save()
except Exception as error:
record["error"] = str(error)
workspace.state.save()
raise
+27 -5
View File
@@ -2,7 +2,7 @@
import json
import os
from pathlib import Path
from urllib.parse import urlsplit
from urllib.parse import urlsplit, unquote
def _url(value):
@@ -35,15 +35,37 @@ def environment(root=None):
if not isinstance(rewrites, list):
raise ValueError('invalid Git transport mappings')
if rewrites:
inherited = []
marker = env.get('CI_DEPENDENCY_MIRRORS')
if marker:
mirrors = Path(marker)
if not mirrors.is_absolute() or not mirrors.is_dir() or mirrors.is_symlink():
raise ValueError('invalid CI dependency mirror identity')
mirrors = mirrors.resolve()
try:
count = int(env.get('GIT_CONFIG_COUNT', '0'))
except ValueError as error:
raise ValueError('invalid process Git configuration') from error
for i in range(count):
key, value = env.get(f'GIT_CONFIG_KEY_{i}', ''), env.get(f'GIT_CONFIG_VALUE_{i}', '')
if (key, value) in {('protocol.file.allow', 'always'), ('core.hooksPath', '/dev/null'), ('credential.helper', '')}:
inherited.append((key, value))
elif key.startswith('url.file://') and key.endswith('.insteadOf'):
target = Path(unquote(urlsplit(key[4:-10]).path)).resolve()
if mirrors not in target.parents or not target.is_dir():
raise ValueError('CI mirror source escapes its registered directory')
inherited.append((key, _url(value)))
# Do not inherit temporary authentication configuration into candidate code.
for key in list(env):
if key == 'GIT_CONFIG_COUNT' or key.startswith(('GIT_CONFIG_KEY_', 'GIT_CONFIG_VALUE_')):
del env[key]
for i, rewrite in enumerate(rewrites):
entries = list(inherited)
for rewrite in rewrites:
if not isinstance(rewrite, dict) or set(rewrite) != {'from', 'to'}:
raise ValueError('invalid Git transport mapping')
source, target = _url(rewrite['from']), _url(rewrite['to'])
env[f'GIT_CONFIG_KEY_{i}'] = f'url.{target}.insteadOf'
env[f'GIT_CONFIG_VALUE_{i}'] = source
env['GIT_CONFIG_COUNT'] = str(len(rewrites))
entries.append((f'url.{target}.insteadOf', source))
for i, (key, value) in enumerate(entries):
env[f'GIT_CONFIG_KEY_{i}'], env[f'GIT_CONFIG_VALUE_{i}'] = key, value
env['GIT_CONFIG_COUNT'] = str(len(entries))
return env
+69
View File
@@ -0,0 +1,69 @@
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPTS = Path(__file__).resolve().parents[1] / 'scripts'
class ComponentGovernanceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.run_git(self.root, 'init', '-q')
self.run_git(self.root, 'config', 'user.name', 'Fixture')
self.run_git(self.root, 'config', 'user.email', 'fixture@example.test')
self.child = self.root / '.workspace/Provider'
self.child.mkdir(parents=True)
self.run_git(self.child, 'init', '-q')
self.run_git(self.child, 'config', 'user.name', 'Fixture')
self.run_git(self.child, 'config', 'user.email', 'fixture@example.test')
(self.child/'value').write_text('one')
self.run_git(self.child, 'add', 'value')
self.run_git(self.child, 'commit', '-qm', 'Add value')
(self.root/'.gitignore').write_text('.workspace/\n')
(self.root/'components.json').write_text(json.dumps({'schema':1,'components':[{
'id':'Provider','repo':self.child.as_uri(),'path':'.workspace/Provider',
'role':'documentation','modules':[],'target':'main'}]}))
self.run_git(self.root, 'add', '.gitignore', 'components.json')
self.run_git(self.root, 'commit', '-qm', 'Add component mapping')
self.old = Path.cwd()
os.chdir(self.root)
self.addCleanup(os.chdir,self.old)
def run_git(self,root,*args):
return subprocess.check_output(['git','-C',str(root),*args],stderr=subprocess.STDOUT,text=True)
def snapshot(self):
spec=importlib.util.spec_from_file_location('component_evidence',SCRIPTS/'evidence.py')
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
return module.snapshot()
def test_evidence_captures_ignored_component_revision_and_dirty_bytes(self):
first=self.snapshot()
(self.child/'value').write_text('two')
second=self.snapshot()
self.assertIn('components',first,'receipts must identify ordinary ignored component checkouts')
self.assertNotEqual(first['components'],second['components'])
self.run_git(self.child,'commit','-qam','Change value')
third=self.snapshot()
self.assertNotEqual(second['components'],third['components'])
self.assertEqual(first['head'],third['head'])
def test_before_sync_rejects_component_git_lock(self):
(self.child/'.git/index.lock').write_text('active owner')
result=subprocess.run(['python3',str(SCRIPTS/'check-worktree.py'),'before-sync'],cwd=self.root,capture_output=True,text=True)
self.assertNotEqual(result.returncode,0,'a hidden component writer must block synchronization')
self.assertTrue((self.child/'.git/index.lock').exists())
def test_receipts_reject_component_path_escape(self):
data=json.loads((self.root/'components.json').read_text())
data['components'][0]['path']='../outside'
(self.root/'components.json').write_text(json.dumps(data))
with self.assertRaises(Exception):
self.snapshot()
if __name__=='__main__': unittest.main()
+22
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts/workspace/transport.py'
@@ -56,5 +57,26 @@ class TransportTests(unittest.TestCase):
with self.assertRaises(ValueError):
transport.environment(root)
def test_verified_local_mirror_mapping_survives_without_authentication(self):
transport = self.load()
with tempfile.TemporaryDirectory() as td:
root = Path(td)
mirror = root / 'mirrors'
mirror.mkdir()
target = mirror / 'Provider.git'
target.mkdir()
(root / 'components.json').write_text(json.dumps({'transport': {'git_rewrites': [
{'from':'https://example.test/', 'to':'ssh://git@example.test:31411/'}]}}))
inherited = {'CI_DEPENDENCY_MIRRORS':str(mirror), 'GIT_CONFIG_COUNT':'3',
'GIT_CONFIG_KEY_0':f'url.{target.as_uri()}.insteadOf','GIT_CONFIG_VALUE_0':'https://example.test/provider',
'GIT_CONFIG_KEY_1':'protocol.file.allow','GIT_CONFIG_VALUE_1':'always',
'GIT_CONFIG_KEY_2':'http.https://example.test/.extraheader','GIT_CONFIG_VALUE_2':'AUTHORIZATION: token fake-private'}
with patch.dict(os.environ,inherited):
env=transport.environment(root)
pairs=[(env[f'GIT_CONFIG_KEY_{i}'],env[f'GIT_CONFIG_VALUE_{i}']) for i in range(int(env['GIT_CONFIG_COUNT']))]
self.assertIn((f'url.{target.as_uri()}.insteadOf','https://example.test/provider'),pairs)
self.assertIn(('protocol.file.allow','always'),pairs)
self.assertFalse(any('extraheader' in key or 'fake-private' in value for key,value in pairs))
if __name__ == '__main__':
unittest.main()
+574
View File
@@ -0,0 +1,574 @@
"""Workspace behavior against disposable real Git repositories and Go modules."""
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
import zipfile
SOURCE = Path(__file__).resolve().parents[1]
class WorkspaceTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="workspace-test-")
self.addCleanup(self.tmp.cleanup)
self.base = Path(self.tmp.name)
self.root = self.base / "project"
self.root.mkdir()
self.env = os.environ.copy()
for key in list(self.env):
if key.startswith("GIT_CONFIG_") or key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GOWORK"):
self.env.pop(key, None)
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "Workspace Test", "GIT_AUTHOR_EMAIL": "test@example.invalid",
"GIT_COMMITTER_NAME": "Workspace Test", "GIT_COMMITTER_EMAIL": "test@example.invalid",
"GOTOOLCHAIN": "local", "GOPROXY": "off", "GOSUMDB": "off"})
self.git(self.root, "init", "--initial-branch=main")
(self.root / ".gitignore").write_text(".workspace/\ngo.work\ngo.work.sum\n")
self.git(self.root, "add", ".gitignore")
self.git(self.root, "commit", "-m", "Create project")
self.remote, self.author, self.first = self.component("App")
self.manifest = {"schema": 1, "components": [self.entry("App", self.remote)]}
self.lock = {"schema": 1, "components": {"App": {"commit": self.first}}}
self.save_manifest()
def command(self, cwd, *args, check=True, env=None, separate_stderr=False):
result = subprocess.run(args, cwd=cwd, env=env or self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE if separate_stderr else subprocess.STDOUT, timeout=60)
if check:
self.assertEqual(result.returncode, 0, result.stdout + (result.stderr or ""))
return result
def git(self, cwd, *args, **kwargs):
return self.command(cwd, "git", *args, **kwargs)
def cli(self, *args, check=True, env=None):
return self.command(self.root, sys.executable, str(SOURCE / "scripts/workspace.py"),
"--root", str(self.root), *args, check=check, env=env)
def component(self, name, files=None):
remote = self.base / (name + ".git")
author = self.base / (name + "-author")
self.git(self.base, "init", "--bare", "--initial-branch=main", str(remote))
self.git(remote, "config", "gc.auto", "0")
self.git(remote, "config", "receive.autogc", "false")
self.git(self.base, "init", "--initial-branch=main", str(author))
for name, value in (files or {"content": "first\n", ".gitignore": "private/\n"}).items():
path = author / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value)
self.git(author, "add", ".")
self.git(author, "commit", "-m", "First component")
self.git(author, "remote", "add", "origin", str(remote))
self.git(author, "push", "-u", "origin", "main")
return remote, author, self.git(author, "rev-parse", "HEAD").stdout.strip()
def entry(self, name, remote, role="application", modules=None):
return {"id": name, "repo": str(remote), "path": ".workspace/" + name,
"legacy_path": "modules/" + name, "role": role,
"modules": modules or [], "target": "main"}
def save_manifest(self):
(self.root / "components.json").write_text(json.dumps(self.manifest))
(self.root / "components.lock.json").write_text(json.dumps(self.lock))
def state(self):
return json.loads((self.root / ".workspace/state.json").read_text())
def test_prepare_pins_fixed_commit_and_reuses_without_advancing(self):
(self.author / "content").write_text("latest\n")
self.git(self.author, "commit", "-am", "Advance remote")
self.git(self.author, "push", "origin", "main")
self.cli("prepare")
clone = self.root / ".workspace/App"
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
self.assertEqual((clone / "content").read_text(), "first\n")
self.cli("prepare")
self.assertEqual(self.state()["components"]["App"]["baseline"], self.first)
self.assertTrue((self.root / ".workspace/resolved.json").is_file())
def test_edit_registers_branch_and_prepare_preserves_work(self):
self.cli("edit", "--name", "App", "--branch", "feature/change")
clone = self.root / ".workspace/App"
(clone / "content").write_text("my change\n")
self.git(clone, "commit", "-am", "Local work")
self.cli("prepare")
self.assertEqual(self.git(clone, "branch", "--show-current").stdout.strip(), "feature/change")
self.assertEqual((clone / "content").read_text(), "my change\n")
self.cli("unlink", "--name", "App")
self.assertTrue(clone.is_dir())
def test_rejects_unknown_component_and_bad_branch_without_source_creation(self):
result = self.cli("edit", "--name", "missing", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("unknown component", result.stdout.lower())
result = self.cli("edit", "--name", "App", "--branch", "feature/../../escape", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.root / ".workspace/App").exists())
def test_rejects_paths_and_symlink_escape(self):
for path in ("../escape", "/tmp/escape", ".workspace/../escape"):
with self.subTest(path=path):
self.manifest["components"][0]["path"] = path
self.save_manifest()
result = self.cli("prepare", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.base / "escape").exists())
self.manifest["components"][0]["path"] = ".workspace/App"
self.save_manifest()
(self.root / ".workspace").symlink_to(self.author, target_is_directory=True)
result = self.cli("prepare", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.author / "App").exists())
def test_rejects_mismatched_origin_dirty_and_git_lock_without_changes(self):
self.cli("prepare")
clone = self.root / ".workspace/App"
self.git(clone, "remote", "set-url", "origin", str(self.base / "wrong.git"))
self.assertNotEqual(self.cli("prepare", check=False).returncode, 0)
self.git(clone, "remote", "set-url", "origin", str(self.remote))
(clone / "unique").write_text("unsaved\n")
self.assertNotEqual(self.cli("edit", "--name", "App", check=False).returncode, 0)
self.assertEqual((clone / "unique").read_text(), "unsaved\n")
(clone / "unique").unlink()
lock = clone / ".git/index.lock"
lock.write_text("owned by another operation\n")
self.assertNotEqual(self.cli("edit", "--name", "App", check=False).returncode, 0)
self.assertEqual(lock.read_text(), "owned by another operation\n")
def test_ignored_unique_files_and_shared_worktrees_block_branch_changes(self):
self.cli("prepare")
clone = self.root / ".workspace/App"
(clone / "private").mkdir()
(clone / "private/key").write_text("unique\n")
self.assertNotEqual(self.cli("edit", "--name", "App", check=False).returncode, 0)
shutil.rmtree(clone / "private")
external = self.base / "other-owner"
self.git(clone, "worktree", "add", "-b", "feature/other", str(external))
result = self.cli("edit", "--name", "App", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.git(external, "branch", "--show-current").stdout.strip(), "feature/other")
def test_workspace_operation_lock_and_user_go_work_are_preserved(self):
area = self.root / ".workspace"
area.mkdir()
lock = area / ".operation.lock"
lock.write_text("other writer\n")
result = self.cli("prepare", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(lock.read_text(), "other writer\n")
lock.unlink()
(self.root / "go.work").write_text("go 1.27\n// manual\n")
result = self.cli("edit", "--name", "App", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / "go.work").read_text(), "go 1.27\n// manual\n")
def test_partial_initialization_is_retained_and_retry_is_safe(self):
self.lock["components"]["App"]["commit"] = "a" * 40
self.save_manifest()
result = self.cli("prepare", check=False)
self.assertNotEqual(result.returncode, 0)
clone = self.root / ".workspace/App"
self.assertTrue((clone / ".git").exists(), result.stdout)
self.assertFalse((self.root / ".workspace/.operation.lock").exists())
self.lock["components"]["App"]["commit"] = self.first
self.save_manifest()
self.cli("prepare")
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), self.first)
def legacy(self):
self.git(self.root, "submodule", "add", str(self.remote), "modules/App")
self.git(self.root, "commit", "-am", "Pin legacy component")
return self.root / "modules/App"
def fingerprint(self, path):
files = {}
for file in path.rglob("*"):
if ".git" not in file.relative_to(path).parts and file.is_file():
files[str(file.relative_to(path))] = hashlib.sha256(file.read_bytes()).hexdigest()
return (self.git(path, "show-ref").stdout, self.git(path, "status", "--porcelain=v1").stdout, files)
def test_transition_report_is_readonly_apply_preserves_unique_commits_refs_and_files(self):
legacy = self.legacy()
self.git(legacy, "checkout", "-b", "feature/legacy")
(legacy / "content").write_text("unpublished commit\n")
self.git(legacy, "commit", "-am", "Unpublished")
unique_head = self.git(legacy, "rev-parse", "HEAD").stdout.strip()
self.git(legacy, "branch", "feature/other-ref")
(legacy / "content").write_text("uncommitted\n")
(legacy / "unique").write_text("untracked\n")
(legacy / "private").mkdir()
(legacy / "private/data").write_text("ignored\n")
before = self.fingerprint(legacy)
report = json.loads(self.cli("transition", "--name", "App").stdout)
self.assertEqual(report["components"]["App"]["legacy"]["head"], unique_head)
self.assertFalse((self.root / ".workspace").exists())
self.cli("transition", "--name", "App", "--apply")
clone = self.root / ".workspace/App"
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), unique_head)
self.assertEqual(self.git(clone, "remote", "get-url", "origin").stdout.strip(), str(self.remote))
self.assertEqual((clone / "content").read_text(), "uncommitted\n")
self.assertEqual((clone / "unique").read_text(), "untracked\n")
self.assertEqual((clone / "private/data").read_text(), "ignored\n")
self.assertEqual(self.git(clone, "rev-parse", "refs/migration/heads/feature/other-ref").stdout.strip(), unique_head)
self.assertEqual(self.fingerprint(legacy), before)
self.assertFalse((clone / ".git/objects/info/alternates").exists())
def test_transition_rejects_external_symlinks_preserving_both_locations(self):
legacy = self.legacy()
outside = self.base / "outside"
outside.write_text("owner data\n")
(legacy / "escape").symlink_to(outside)
before = self.fingerprint(legacy)
result = self.cli("transition", "--name", "App", "--apply", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(outside.read_text(), "owner data\n")
self.assertEqual(self.fingerprint(legacy), before)
self.assertFalse((self.root / ".workspace/App").exists())
def test_library_legacy_baseline_uses_gitlink_not_current_head(self):
legacy = self.legacy()
(legacy / "content").write_text("local newer commit\n")
self.git(legacy, "commit", "-am", "Local advance")
self.manifest["components"][0]["role"] = "go-library"
self.lock["components"] = {}
self.save_manifest()
self.cli("edit", "--name", "App")
self.assertEqual(self.git(self.root / ".workspace/App", "rev-parse", "HEAD").stdout.strip(), self.first)
self.assertEqual((legacy / "content").read_text(), "local newer commit\n")
def test_status_reports_dirty_and_verify_rejects_it(self):
self.cli("prepare")
clone = self.root / ".workspace/App"
(clone / "content").write_text("unsaved\n")
result = json.loads(self.cli("status").stdout)
self.assertTrue(result["components"]["App"]["actual"]["dirty"])
self.assertNotEqual(self.cli("verify", check=False).returncode, 0)
self.assertEqual((clone / "content").read_text(), "unsaved\n")
def test_transition_preserves_staged_blob_internal_symlink_and_reflog_commit(self):
legacy = self.legacy()
(legacy / "content").write_text("orphan commit\n")
self.git(legacy, "commit", "-am", "Reflog-only work")
orphan = self.git(legacy, "rev-parse", "HEAD").stdout.strip()
self.git(legacy, "checkout", "--detach", self.first)
(legacy / "content").write_text("staged value\n")
self.git(legacy, "add", "content")
(legacy / "content").write_text("working value\n")
(legacy / "link").symlink_to("content")
before = self.fingerprint(legacy)
self.cli("transition", "--name", "App", "--apply")
clone = self.root / ".workspace/App"
self.assertEqual(self.git(clone, "show", ":content").stdout, "staged value\n")
self.assertEqual((clone / "content").read_text(), "working value\n")
self.assertTrue((clone / "link").is_symlink())
self.assertEqual(os.readlink(clone / "link"), "content")
self.assertEqual(self.git(clone, "rev-parse", "refs/migration/reflog/" + orphan).stdout.strip(), orphan)
self.assertEqual(self.fingerprint(legacy), before)
def test_transition_refuses_initialized_nested_repository(self):
legacy = self.legacy()
nested = legacy / "child"
nested.mkdir()
self.git(nested, "init", "--initial-branch=main")
(nested / "unique").write_text("owned by nested repository\n")
result = self.cli("transition", "--name", "App", "--apply", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("nested repository", result.stdout)
self.assertFalse((self.root / ".workspace/App").exists())
self.assertEqual((nested / "unique").read_text(), "owned by nested repository\n")
def nested_legacy(self):
legacy = self.legacy()
errors_remote, _, _ = self.component("Errors", {
"go.mod": "module example.invalid/errors\n\ngo 1.27\n",
"value.go": 'package errors\nfunc Value() string { return "errors" }\n',
".gitignore": "private/\n"})
logging_remote, _, _ = self.component("Logging", {
"go.mod": "module example.invalid/logging\n\ngo 1.27\n",
"value.go": 'package logging\nfunc Value() string { return "logging" }\n'})
self.git(legacy, "submodule", "add", str(errors_remote), "deps/errors")
self.git(legacy, "submodule", "add", str(logging_remote), "deps/logging")
(legacy / "go.mod").write_text("module example.invalid/native\n\ngo 1.27\n\nrequire (\n example.invalid/errors v0.0.0\n example.invalid/logging v0.0.0\n)\nreplace example.invalid/errors => ./deps/errors\nreplace example.invalid/logging => ./deps/logging\n")
(legacy / "main.go").write_text('package main\nimport ("fmt"; errors "example.invalid/errors"; logging "example.invalid/logging")\nfunc main() { fmt.Print(errors.Value()+":"+logging.Value()) }\n')
self.git(legacy, "add", ".")
self.git(legacy, "commit", "-m", "Add registered nested dependencies")
self.manifest["components"][0]["modules"] = [{"path": "example.invalid/native", "directory": "."}]
self.manifest["components"] += [
self.entry("Errors", errors_remote, "go-library", [{"path": "example.invalid/errors", "directory": "."}]),
self.entry("Logging", logging_remote, "go-library", [{"path": "example.invalid/logging", "directory": "."}])]
self.save_manifest()
return legacy, legacy / "deps/errors", legacy / "deps/logging"
def test_transition_known_nested_repositories_preserves_external_worktree_and_runs_go(self):
legacy, errors, logging = self.nested_legacy()
self.git(errors, "checkout", "-b", "feature/nested")
(errors / "value.go").write_text('package errors\nfunc Value() string { return "committed" }\n')
self.git(errors, "commit", "-am", "Unpushed nested commit")
errors_head = self.git(errors, "rev-parse", "HEAD").stdout.strip()
(errors / "value.go").write_text('package errors\nfunc Value() string { return "staged" }\n')
self.git(errors, "add", "value.go")
(errors / "value.go").write_text('package errors\nfunc Value() string { return "working" }\n')
(errors / "private").mkdir()
(errors / "private/unique").write_text("nested ignored data\n")
external = self.base / "nested-dependent-worktree"
self.git(errors, "worktree", "add", "-b", "feature/external", str(external))
before = {str(path): self.fingerprint(path) for path in (legacy, errors, logging, external)}
self.cli("transition", "--name", "App", "--apply")
clone = self.root / ".workspace/Errors"
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), errors_head)
self.assertIn('return "staged"', self.git(clone, "show", ":value.go").stdout)
self.assertEqual((clone / "private/unique").read_text(), "nested ignored data\n")
self.assertTrue((clone / ".git").is_dir())
self.assertFalse((clone / ".git/objects/info/alternates").exists())
self.assertFalse((self.root / ".workspace/App/deps/errors/.git").exists())
self.assertEqual(self.command(self.root / ".workspace/App", "go", "run", ".").stdout, "working:logging")
for path in (legacy, errors, logging, external):
self.assertEqual(self.fingerprint(path), before[str(path)])
self.assertTrue(Path(self.git(external, "rev-parse", "--absolute-git-dir").stdout.strip()).exists())
mapping = self.state()["components"]["App"]["source"]["nested_components"]
self.assertEqual({item["id"] for item in mapping}, {"Errors", "Logging"})
def test_conflicting_duplicate_nested_component_stops_before_cloning(self):
legacy, errors, logging = self.nested_legacy()
remote = next(item["repo"] for item in self.manifest["components"] if item["id"] == "Errors")
self.git(legacy, "submodule", "add", remote, "deps/duplicate-errors")
self.git(legacy, "commit", "-am", "Add duplicate logical dependency")
duplicate = legacy / "deps/duplicate-errors"
(duplicate / "value.go").write_text("conflicting unsaved source\n")
before = self.fingerprint(legacy)
result = self.cli("transition", "--name", "App", "--apply", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("conflicting duplicate", result.stdout)
self.assertIn("Errors", result.stdout)
self.assertFalse((self.root / ".workspace/App").exists())
self.assertFalse((self.root / ".workspace/Errors").exists())
self.assertEqual(self.fingerprint(legacy), before)
def test_equivalent_recursive_nested_sources_share_an_independent_component(self):
legacy, errors, logging = self.nested_legacy()
remote = next(item["repo"] for item in self.manifest["components"] if item["id"] == "Errors")
self.git(logging, "submodule", "add", remote, "deps/errors")
(logging / "go.mod").write_text("module example.invalid/logging\n\ngo 1.27\n\nrequire example.invalid/errors v0.0.0\nreplace example.invalid/errors => ./deps/errors\n")
(logging / "value.go").write_text('package logging\nimport errors "example.invalid/errors"\nfunc Value() string { return "logging:"+errors.Value() }\n')
self.git(logging, "add", ".")
self.git(logging, "commit", "-m", "Add recursive registered dependency")
self.git(legacy, "add", "deps/logging")
self.git(legacy, "commit", "-m", "Pin recursive logging candidate")
duplicate = logging / "deps/errors"
before = {str(path): self.fingerprint(path) for path in (legacy, errors, logging, duplicate)}
self.cli("transition", "--name", "App", "--apply")
self.assertEqual(len(self.state()["components"]["Errors"]["legacy_snapshots"]), 2)
self.assertEqual(self.command(self.root / ".workspace/App", "go", "run", ".").stdout, "errors:logging:errors")
for path in (legacy, errors, logging, duplicate):
self.assertEqual(self.fingerprint(path), before[str(path)])
def npm_fixture(self):
remote, author, head = self.component("UI", {"package.json": '{"name":"@agent-system/ui","version":"0.0.0"}\n'})
(author / "package.json").write_text('{"name":"@agent-system/ui","version":"0.0.1"}\n')
self.git(author, "commit", "-am", "Advance UI remote beyond consumer lock")
self.git(author, "push", "origin", "main")
(self.author / "package-lock.json").write_text(json.dumps({"name": "frontend", "lockfileVersion": 3,
"packages": {"": {"dependencies": {"@agent-system/ui": "git+file://" + str(remote) + "#" + head}},
"node_modules/@agent-system/ui": {"version": "0.0.0", "resolved": "git+file://" + str(remote) + "#" + head}}}))
self.git(self.author, "add", "package-lock.json")
self.git(self.author, "commit", "-m", "Lock UI package")
self.git(self.author, "push", "origin", "main")
self.lock["components"]["App"]["commit"] = self.git(self.author, "rev-parse", "HEAD").stdout.strip()
entry = self.entry("UI", remote, "npm-library")
entry["package_name"] = "@agent-system/ui"
self.manifest["components"].append(entry)
self.save_manifest()
return remote, author, head
def test_npm_library_edit_uses_consumer_lock_git_sha_not_remote_main(self):
_, _, head = self.npm_fixture()
self.cli("edit", "--name", "UI")
clone = self.root / ".workspace/UI"
self.assertEqual(self.git(clone, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(json.loads((clone / "package.json").read_text())["version"], "0.0.0")
self.assertEqual(self.state()["components"]["UI"]["source"]["kind"], "npm-lock")
self.assertFalse((self.root / "go.work").exists())
def test_npm_library_rejects_wrong_lock_origin_and_competing_manual_lock(self):
remote, _, head = self.npm_fixture()
self.lock["components"]["UI"] = {"commit": head}
self.save_manifest()
result = self.cli("edit", "--name", "UI", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("competing", result.stdout)
self.lock["components"].pop("UI")
self.save_manifest()
self.cli("prepare")
lock_path = self.root / ".workspace/App/package-lock.json"
document = json.loads(lock_path.read_text())
document["packages"]["node_modules/@agent-system/ui"]["resolved"] = "git+file://" + str(self.base / "unapproved.git") + "#" + head
lock_path.write_text(json.dumps(document))
result = self.cli("edit", "--name", "UI", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertIn("origin", result.stdout)
self.assertFalse((self.root / ".workspace/UI").exists())
def test_unlink_refuses_manual_overlay_changes_without_changing_registration(self):
self.go_fixture()
self.cli("prepare")
consumer = self.root / ".workspace/Consumer"
self.command(consumer, "go", "mod", "download", "all", env=self.env | {"GOWORK": "off"})
self.cli("edit", "--name", "Native")
overlay = self.root / "go.work"
overlay.write_text(overlay.read_text() + "// manual edit\n")
result = self.cli("unlink", "--name", "Native", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(self.state()["components"]["Native"]["linked"])
self.assertTrue(overlay.read_text().endswith("// manual edit\n"))
def test_missing_fixed_source_never_falls_back_to_remote_main(self):
self.lock["components"] = {}
self.save_manifest()
result = self.cli("prepare", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.root / ".workspace/App").exists())
def test_existing_fixed_checkout_is_not_switched_after_lock_change(self):
self.cli("prepare")
(self.author / "content").write_text("advanced version\n")
self.git(self.author, "commit", "-am", "Advance")
self.git(self.author, "push", "origin", "main")
self.lock["components"]["App"]["commit"] = self.git(self.author, "rev-parse", "HEAD").stdout.strip()
self.save_manifest()
result = self.cli("prepare", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.git(self.root / ".workspace/App", "rev-parse", "HEAD").stdout.strip(), self.first)
def test_private_git_metadata_symlink_is_not_adopted(self):
self.cli("prepare")
clone = self.root / ".workspace/App"
external = self.base / "other-owner-metadata"
(clone / ".git").rename(external)
(clone / ".git").symlink_to(external, target_is_directory=True)
result = self.cli("edit", "--name", "App", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((external / "refs/heads/feature").exists())
def test_git_operation_is_reported_and_not_removed(self):
self.cli("prepare")
operation = self.root / ".workspace/App/.git/MERGE_HEAD"
operation.write_text(self.first + "\n")
result = self.cli("edit", "--name", "App", check=False)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(operation.read_text(), self.first + "\n")
def test_status_does_not_expose_credentials_from_an_unmatched_actual_origin(self):
self.cli("prepare")
clone = self.root / ".workspace/App"
self.git(clone, "remote", "set-url", "origin", "https://owner:workspace-fake-secret@example.invalid/repo.git")
result = self.cli("status")
self.assertNotIn("workspace-fake-secret", result.stdout)
self.assertIn("redacted", result.stdout)
def test_transition_preserves_committed_symlink_deletion_and_file_type_change(self):
legacy = self.legacy()
(legacy / "old-link").symlink_to("content")
(legacy / "regular").write_text("committed file\n")
self.git(legacy, "add", "old-link", "regular")
self.git(legacy, "commit", "-m", "Add two file types")
(legacy / "old-link").unlink()
(legacy / "regular").unlink()
(legacy / "regular").symlink_to("content")
before = self.fingerprint(legacy)
self.cli("transition", "--name", "App", "--apply")
clone = self.root / ".workspace/App"
self.assertFalse((clone / "old-link").is_symlink())
self.assertTrue((clone / "regular").is_symlink())
self.assertEqual(self.git(clone, "diff", "--raw").stdout, self.git(legacy, "diff", "--raw").stdout)
self.assertEqual(self.fingerprint(legacy), before)
def go_fixture(self):
module = "example.invalid/Native"
library_remote, author, head = self.component("Native", {
"go.mod": "module example.invalid/Native\n\ngo 1.27\n",
"value.go": 'package native\nfunc Value() string { return "versioned" }\n'})
version = "v0.0.0-20261013000000-" + head[:12]
proxy = self.base / "proxy/example.invalid/!native/@v"
proxy.mkdir(parents=True)
(proxy / (version + ".mod")).write_text((author / "go.mod").read_text())
# Go's real proxy protocol supplies Origin.Hash, avoiding a fake Go executable.
(proxy / (version + ".info")).write_text(json.dumps({"Version": version,
"Time": "2026-10-13T00:00:00Z", "Origin": {"VCS": "git", "URL": str(library_remote), "Hash": head}}))
with zipfile.ZipFile(proxy / (version + ".zip"), "w") as archive:
for filename in ("go.mod", "value.go"):
archive.write(author / filename, module + "@" + version + "/" + filename)
app_remote, _, app_head = self.component("Consumer", {
"go.mod": "module example.invalid/consumer\n\ngo 1.27\n\nrequire " + module + " " + version + "\n",
"main.go": 'package main\nimport ("fmt"; native "example.invalid/Native")\nfunc main() { fmt.Print(native.Value()) }\n'})
self.manifest["components"] = [self.entry("Consumer", app_remote, modules=[{"path": "example.invalid/consumer", "directory": "."}]),
self.entry("Native", library_remote, "go-library", [{"path": module, "legacy": "native", "directory": "."}])]
self.lock["components"] = {"Consumer": {"commit": app_head}}
self.save_manifest()
self.env.update({"GOPROXY": (self.base / "proxy").as_uri(), "GOMODCACHE": str(self.base / "modcache"),
"GOPATH": str(self.base / "gopath")})
return head
def test_actual_go_version_baseline_overlay_and_unlink_restore_version_consumption(self):
head = self.go_fixture()
self.cli("prepare")
consumer = self.root / ".workspace/Consumer"
native = self.root / ".workspace/Native"
self.command(consumer, "go", "mod", "download", "all", env=self.env | {"GOWORK": "off"})
self.assertEqual(self.command(consumer, "go", "run", "-mod=readonly", ".", env=self.env | {"GOWORK": "off"}).stdout, "versioned")
self.cli("edit", "--name", "Native", "--branch", "feature/value")
self.assertEqual(self.git(native, "rev-parse", "HEAD").stdout.strip(), head)
(native / "value.go").write_text('package native\nfunc Value() string { return "edited" }\n')
self.assertEqual(self.command(consumer, "go", "run", ".").stdout, "edited")
self.assertEqual(self.command(consumer, "go", "run", "-mod=readonly", ".", env=self.env | {"GOWORK": "off"}).stdout, "versioned")
self.cli("unlink", "--name", "Native")
self.assertFalse((self.root / "go.work").exists())
self.assertEqual(self.command(consumer, "go", "run", "-mod=readonly", ".").stdout, "versioned")
self.assertEqual((native / "value.go").read_text(), 'package native\nfunc Value() string { return "edited" }\n')
def test_fresh_library_edit_resolves_fixed_consumer_and_preserves_consumer_manifest(self):
head = self.go_fixture()
self.cli("edit", "--name", "Native")
native = self.root / ".workspace/Native"
consumer = self.root / ".workspace/Consumer"
self.assertEqual(self.git(native, "rev-parse", "HEAD").stdout.strip(), head)
self.assertFalse((consumer / "go.sum").exists(), "source resolution must not write a consumer's go.sum")
self.assertEqual(self.git(consumer, "status", "--porcelain=v1").stdout, "")
def test_library_baseline_resolves_transitive_selected_module(self):
head = self.go_fixture()
native_version = "v0.0.0-20261013000000-" + head[:12]
bridge_remote, bridge_author, bridge_head = self.component("Bridge", {
"go.mod": "module example.invalid/Bridge\n\ngo 1.27\n\nrequire example.invalid/Native " + native_version + "\n",
"value.go": 'package bridge\nimport native "example.invalid/Native"\nfunc Value() string { return native.Value() }\n'})
bridge_version = "v0.0.0-20261013000000-" + bridge_head[:12]
proxy = self.base / "proxy/example.invalid/!bridge/@v"
proxy.mkdir(parents=True)
(proxy / (bridge_version + ".mod")).write_text((bridge_author / "go.mod").read_text())
(proxy / (bridge_version + ".info")).write_text(json.dumps({"Version": bridge_version,
"Time": "2026-10-13T00:00:00Z", "Origin": {"VCS": "git", "URL": str(bridge_remote), "Hash": bridge_head}}))
with zipfile.ZipFile(proxy / (bridge_version + ".zip"), "w") as archive:
for filename in ("go.mod", "value.go"):
archive.write(bridge_author / filename, "example.invalid/Bridge@" + bridge_version + "/" + filename)
author = self.base / "Consumer-author"
(author / "go.mod").write_text("module example.invalid/consumer\n\ngo 1.27\n\nrequire example.invalid/Bridge " + bridge_version + "\n")
(author / "main.go").write_text('package main\nimport ("fmt"; bridge "example.invalid/Bridge")\nfunc main() { fmt.Print(bridge.Value()) }\n')
self.git(author, "commit", "-am", "Consume Native transitively")
self.git(author, "push", "origin", "main")
self.lock["components"]["Consumer"]["commit"] = self.git(author, "rev-parse", "HEAD").stdout.strip()
self.save_manifest()
self.cli("edit", "--name", "Native")
self.assertEqual(self.git(self.root / ".workspace/Native", "rev-parse", "HEAD").stdout.strip(), head)
self.assertFalse((self.root / ".workspace/Consumer/go.sum").exists())
(self.root / ".workspace/Native/value.go").write_text('package native\nfunc Value() string { return "transitive edit" }\n')
self.assertEqual(self.command(self.root / ".workspace/Consumer", "go", "run", ".", separate_stderr=True).stdout, "transitive edit")
if __name__ == "__main__":
unittest.main()