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

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

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

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

285 lines
16 KiB
Python

import hashlib
import json
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 ref_namespace(self, snapshot):
captured = {key: value for key, value in snapshot.items() if key != "ref_namespace"}
encoded = json.dumps(captured, sort_keys=True, separators=(",", ":")).encode()
source = hashlib.sha256(snapshot["path"].encode()).hexdigest()[:24]
version = hashlib.sha256(encoded).hexdigest()
return "refs/migration/sources/" + source + "/" + version
def remember_snapshots(self, record, snapshots):
history = list(record.get("legacy_snapshots", []))
seen = {entry.get("ref_namespace") for entry in history}
for snapshot in snapshots:
namespace = self.ref_namespace(snapshot)
if namespace not in seen:
history.append(dict(snapshot, ref_namespace=namespace))
seen.add(namespace)
record["legacy_snapshots"] = history
def pin_ref(self, path, reference, object_id):
current = self.workspace.git.text(path, "rev-parse", "--verify", "--quiet", reference, check=False)
if current:
if current != object_id:
raise WorkspaceError(f"preserved migration reference has another value: {reference}; owner must inspect it")
return
# Create only if absent: never overwrite a saved snapshot or a concurrent
# owner's reference, even when the expected namespace already exists.
self.workspace.git.call(path, "update-ref", reference, object_id, "0" * 40)
def preserve_refs(self, path, snapshots):
workspace = self.workspace
for snapshot in snapshots:
legacy = safe_path(workspace.root, snapshot["path"])
namespace = self.ref_namespace(snapshot)
for row in snapshot["refs"]:
object_id, reference = row.split(" ", 1)
if workspace.git.call(path, "cat-file", "-e", object_id, check=False).returncode:
# Fetch the captured object, not a mutable ref's current value.
# With no destination refspec this only updates FETCH_HEAD.
workspace.git.call(path, "fetch", "--no-tags", "--", str(legacy), object_id)
self.pin_ref(path, namespace + "/" + reference.removeprefix("refs/"), object_id)
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)
self.pin_ref(path, "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]
self.remember_snapshots(record, 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})
self.remember_snapshots(record, 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