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.
285 lines
16 KiB
Python
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
|