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:
File diff suppressed because one or more lines are too long
+7
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Safe, local component source workspaces; language manifests own dependencies."""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)]}
|
||||
@@ -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")
|
||||
@@ -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])
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user