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