← Files ZzzOpsARCHIVED FILE

scripts/cleanup_legacy.py

20.3 KB · Oct 3, 2026 · 06:31 UTC

↓ Download file

#!/usr/bin/env python3
"""Safely remove retired per-project ZzzOps installations."""

from __future__ import annotations

import argparse
from dataclasses import dataclass, field
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
import sys
import tempfile
from typing import Any


PLUGIN_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CATALOG = PLUGIN_ROOT / "assets" / "legacy_install_fingerprints.json"
LOCK_RELATIVE = ".zzzops/ZZZOPS_LOCK.json"
MANIFEST_RELATIVE = ".agents/zzzops/INSTALL_MANIFEST"
ROOT_IGNORE_START = "# BEGIN ZZZOPS DISPOSABLE MACHINERY"
ROOT_IGNORE_END = "# END ZZZOPS DISPOSABLE MACHINERY"
STATE_IGNORE_START = "# BEGIN ZZZOPS LOCAL STATE"
STATE_IGNORE_END = "# END ZZZOPS LOCAL STATE"
LEGACY_SKILLS = (
    "add-zzzops-goal",
    "execute-zzzops",
    "migrate-to-zzzops",
    "review-zzzops-policy",
    "send-zzzops-feedback",
    "suggest-zzzops-work",
)
LEGACY_ROOTS = (
    ".agents/zzzops",
    *(f".agents/skills/{name}" for name in LEGACY_SKILLS),
    *(f".claude/skills/{name}" for name in LEGACY_SKILLS),
    ".zzzops/rules",
)
CATALOG_ANCHOR = ".agents/zzzops/zzzops.py"
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
_REVISION = re.compile(r"^[0-9a-f]{40,64}$")
_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$")
_PORTABLE_PART = re.compile(r"^[A-Za-z0-9._-]+$")


class CleanupError(ValueError):
    """Legacy ownership cannot be proved safely."""


@dataclass
class CleanupPlan:
    safe: bool
    source: str | None
    remove_files: list[str]
    tracked: list[str]
    ignore_updates: dict[str, str | None]
    errors: list[str]
    warnings: list[str]
    signature: str
    catalog: dict[str, Any] = field(repr=False)


def file_digest_bytes(data: bytes) -> str:
    return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest()


def file_digest(path: Path) -> str:
    return file_digest_bytes(path.read_bytes())


def lock_file_digest(path: Path) -> str:
    data = path.read_bytes()
    if path.suffix.lower() in {".json", ".md", ".py", ".yaml", ".yml"} or path.name == "ZZZOPS_GITIGNORE":
        data = data.replace(b"\r\n", b"\n")
    return hashlib.sha256(data).hexdigest()


def safe_relative(value: Any, *, metadata: bool = False) -> str:
    if not isinstance(value, str) or not value or "\\" in value:
        raise CleanupError(f"unsafe managed path: {value!r}")
    path = PurePosixPath(value)
    if path.is_absolute() or str(path) != value or any(part in {"", ".", ".."} for part in path.parts):
        raise CleanupError(f"unsafe managed path: {value}")
    if any(not _PORTABLE_PART.fullmatch(part) for part in path.parts):
        raise CleanupError(f"unsafe non-portable managed path: {value}")
    if not metadata and not any(value == root or value.startswith(root + "/") for root in LEGACY_ROOTS):
        if value != ".zzzops/.gitignore":
            raise CleanupError(f"unsafe path outside retired ZzzOps roots: {value}")
    return value


def validate_files(raw: Any) -> dict[str, str]:
    if not isinstance(raw, dict) or not raw:
        raise CleanupError("fingerprint files must be a non-empty object")
    result: dict[str, str] = {}
    portable: set[str] = set()
    for raw_path, digest in raw.items():
        path = safe_relative(raw_path)
        folded = path.casefold()
        if folded in portable:
            raise CleanupError(f"duplicate cross-platform managed path: {path}")
        if not isinstance(digest, str) or not _DIGEST.fullmatch(digest):
            raise CleanupError(f"invalid SHA-256 digest for {path}")
        portable.add(folded)
        result[path] = digest
    return dict(sorted(result.items()))


def validate_catalog(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict) or set(raw) != {"schema_version", "releases"}:
        raise CleanupError("fingerprint catalog must contain only schema_version and releases")
    if raw["schema_version"] != 1 or not isinstance(raw["releases"], dict) or not raw["releases"]:
        raise CleanupError("fingerprint catalog schema or releases are invalid")
    releases: dict[str, Any] = {}
    for name, release in raw["releases"].items():
        if not isinstance(name, str) or not name or not isinstance(release, dict) or set(release) != {"files"}:
            raise CleanupError("fingerprint release entries are invalid")
        releases[name] = {"files": validate_files(release["files"])}
    return {"schema_version": 1, "releases": dict(sorted(releases.items()))}


def load_catalog(path: Path = DEFAULT_CATALOG) -> dict[str, Any]:
    try:
        return validate_catalog(json.loads(path.read_text(encoding="utf-8-sig")))
    except (OSError, UnicodeError, json.JSONDecodeError) as exc:
        raise CleanupError(f"could not read the fingerprint catalog: {type(exc).__name__}") from exc


def target_path(repo: Path, relative: str) -> Path:
    current = repo
    for part in PurePosixPath(relative).parts:
        current = current / part
        if current.is_symlink():
            raise CleanupError(f"managed path uses a symlink or junction: {relative}")
    return current


def legacy_inventory(repo: Path) -> dict[str, str]:
    result: dict[str, str] = {}
    for root in LEGACY_ROOTS:
        directory = target_path(repo, root)
        if not directory.exists():
            continue
        if not directory.is_dir():
            raise CleanupError(f"retired machinery root is not a directory: {root}")
        for item in directory.rglob("*"):
            relative = item.relative_to(repo).as_posix()
            if item.is_symlink():
                raise CleanupError(f"managed path uses a symlink or junction: {relative}")
            if item.is_file():
                result[relative] = file_digest(item)
    return dict(sorted(result.items()))


def parse_lock(repo: Path) -> tuple[dict[str, str] | None, str | None]:
    path = target_path(repo, LOCK_RELATIVE)
    if not path.exists():
        return None, None
    try:
        raw = json.loads(path.read_text(encoding="utf-8-sig"))
    except (OSError, UnicodeError, json.JSONDecodeError) as exc:
        return None, f"installation lock is unreadable: {type(exc).__name__}"
    if not isinstance(raw, dict) or raw.get("schema_version") != 1 or set(raw) != {"schema_version", "revision", "version", "files"}:
        return None, "installation lock schema is invalid"
    if not isinstance(raw["revision"], str) or not _REVISION.fullmatch(raw["revision"]):
        return None, "installation lock revision is invalid"
    if not isinstance(raw["version"], str) or not _VERSION.fullmatch(raw["version"]):
        return None, "installation lock version is invalid"
    try:
        return validate_files(raw.get("files")), None
    except CleanupError as exc:
        return None, f"installation lock is invalid: {exc}"


def manifest_digest(data: bytes, expected: str) -> str:
    header = f"blob {len(data)}\0".encode("ascii")
    constructor = hashlib.sha1 if len(expected) == 40 else hashlib.sha256
    return constructor(header + data).hexdigest()


def parse_manifest(repo: Path) -> tuple[dict[str, str] | None, str | None]:
    path = target_path(repo, MANIFEST_RELATIVE)
    if not path.exists():
        return None, None
    try:
        lines = path.read_text(encoding="utf-8-sig").splitlines()
    except (OSError, UnicodeError) as exc:
        return None, f"legacy manifest is unreadable: {type(exc).__name__}"
    if not lines or lines[0] != "zzzops-install-manifest-v1":
        return None, "legacy manifest header is invalid"
    files: dict[str, str] = {}
    revision = None
    for line in lines[1:]:
        fields = line.split("\t")
        if len(fields) == 2 and fields[0] == "revision" and revision is None:
            revision = fields[1]
        elif len(fields) == 2 and fields[0] == "version":
            continue
        elif len(fields) == 3 and fields[0] == "file" and len(fields[1]) in {40, 64}:
            try:
                relative = safe_relative(fields[2])
            except CleanupError as exc:
                return None, f"legacy manifest is invalid: {exc}"
            if relative == ".zzzops/.gitignore" or relative in files:
                return None, f"legacy manifest contains unsupported or duplicate path: {relative}"
            files[relative] = fields[1].lower()
        else:
            return None, "legacy manifest contains an invalid record"
    if not revision or not files:
        return None, "legacy manifest lacks provenance or files"
    return dict(sorted(files.items())), None


def strip_block(text: str, start: str, end: str) -> tuple[str, bool]:
    normalized = text.replace("\r\n", "\n")
    before, marker, remainder = normalized.partition(start)
    if not marker:
        if end in normalized:
            raise CleanupError(f"ignore file contains {end} without {start}")
        return normalized, False
    discarded, closing, after = remainder.partition(end)
    if not closing or start in after or end in after:
        raise CleanupError(f"ignore file contains malformed or repeated {start} block")
    del discarded
    kept = before.rstrip("\n")
    suffix = after.lstrip("\n")
    result = (kept + "\n" if kept else "") + suffix
    return result, True


def ignore_updates(repo: Path) -> dict[str, str | None]:
    updates: dict[str, str | None] = {}
    for relative, start, end in (
        (".gitignore", ROOT_IGNORE_START, ROOT_IGNORE_END),
        (".zzzops/.gitignore", STATE_IGNORE_START, STATE_IGNORE_END),
    ):
        path = target_path(repo, relative)
        if not path.exists():
            continue
        try:
            current = path.read_text(encoding="utf-8-sig")
        except (OSError, UnicodeError) as exc:
            raise CleanupError(f"could not read {relative}: {type(exc).__name__}") from exc
        updated, changed = strip_block(current, start, end)
        if changed:
            updates[relative] = updated or None
    return updates


def git_paths(repo: Path, files: list[str]) -> list[str]:
    if not files:
        return []
    result = subprocess.run(
        ["git", "-c", f"safe.directory={repo}", "-C", str(repo), "ls-files", "-z", "--", *files],
        capture_output=True,
        check=False,
    )
    if result.returncode:
        raise CleanupError("Git could not report tracked cleanup paths")
    return sorted(path for path in result.stdout.decode("utf-8", errors="replace").split("\0") if path)


def snapshot_signature(repo: Path, inventory: dict[str, str], updates: dict[str, str | None]) -> str:
    metadata: dict[str, str | None] = {}
    for relative in (LOCK_RELATIVE, MANIFEST_RELATIVE, ".gitignore", ".zzzops/.gitignore"):
        path = target_path(repo, relative)
        metadata[relative] = hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
    return hashlib.sha256(json.dumps({"inventory": inventory, "metadata": metadata, "updates": updates}, sort_keys=True).encode()).hexdigest()


def build_plan(repo: Path, catalog: dict[str, Any] | None = None) -> CleanupPlan:
    repo = repo.resolve()
    validated = validate_catalog(catalog) if catalog is not None else load_catalog()
    errors: list[str] = []
    warnings: list[str] = []
    remove: list[str] = []
    source: str | None = None
    updates: dict[str, str | None] = {}
    inventory: dict[str, str] = {}
    try:
        inventory = legacy_inventory(repo)
        updates = ignore_updates(repo)
        lock, lock_error = parse_lock(repo)
        manifest, manifest_error = parse_manifest(repo)
        if lock_error:
            errors.append(lock_error)
        elif lock is not None:
            source = "installation lock"
            actual = {path: digest for path, digest in inventory.items() if path != MANIFEST_RELATIVE}
            unknown = sorted(set(actual) - set(lock))
            mismatched = sorted(
                path for path in set(actual) & set(lock)
                if lock_file_digest(target_path(repo, path)) != lock[path]
            )
            if unknown or mismatched:
                errors.append("installed machinery does not match the installation lock: " + ", ".join(unknown + mismatched))
            else:
                remove = sorted(actual)
                if target_path(repo, LOCK_RELATIVE).is_file():
                    remove.append(LOCK_RELATIVE)
        elif manifest_error:
            errors.append(manifest_error)
        elif manifest is not None:
            source = "legacy install manifest"
            actual = {path: digest for path, digest in inventory.items() if path != MANIFEST_RELATIVE}
            unknown = sorted(set(actual) - set(manifest))
            mismatched: list[str] = []
            for relative, expected in manifest.items():
                path = target_path(repo, relative)
                if path.is_file() and manifest_digest(path.read_bytes(), expected) != expected:
                    mismatched.append(relative)
            if unknown or mismatched:
                errors.append("installed machinery does not match the legacy manifest: " + ", ".join(unknown + mismatched))
            else:
                remove = sorted(actual) + [MANIFEST_RELATIVE]
        elif inventory:
            matches: list[tuple[str, dict[str, str]]] = []
            for name, release in validated["releases"].items():
                expected = release["files"]
                if CATALOG_ANCHOR not in inventory or inventory.get(CATALOG_ANCHOR) != expected.get(CATALOG_ANCHOR):
                    continue
                unknown = set(inventory) - set(expected)
                mismatched = [path for path in set(inventory) & set(expected) if inventory[path] != expected[path]]
                if not unknown and not mismatched:
                    matches.append((name, expected))
            if len(matches) != 1:
                errors.append("installed machinery does not match exactly one approved legacy release fingerprint")
            else:
                source = f"published {matches[0][0]} fingerprint"
                remove = sorted(inventory)
                extra_metadata = ".zzzops/.gitignore"
                expected = matches[0][1]
                metadata_path = target_path(repo, extra_metadata)
                if extra_metadata in expected:
                    if not metadata_path.is_file() or file_digest(metadata_path) != expected[extra_metadata]:
                        errors.append(f"installed machinery does not match {matches[0][0]} at {extra_metadata}")
                    else:
                        remove.append(extra_metadata)
        elif target_path(repo, LOCK_RELATIVE).exists() or target_path(repo, MANIFEST_RELATIVE).exists():
            errors.append("provenance metadata exists without matching retired machinery")
        if errors:
            remove = []
            updates = {}
        tracked = git_paths(repo, sorted(set(remove) | set(updates))) if not errors else []
        if tracked:
            warnings.append("Git-tracked paths will remain in the index as deletions; the cleaner never changes the index")
        signature = snapshot_signature(repo, inventory, updates)
    except (CleanupError, OSError) as exc:
        errors.append(str(exc))
        remove = []
        updates = {}
        tracked = []
        signature = ""
    return CleanupPlan(not errors, source, sorted(set(remove)), tracked, updates, errors, warnings, signature, validated)


def atomic_write(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    descriptor, temporary_name = tempfile.mkstemp(prefix=".zzzops-cleanup-", suffix=".tmp", dir=path.parent)
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
            stream.write(text)
        temporary.replace(path)
    finally:
        temporary.unlink(missing_ok=True)


def apply_plan(repo: Path, plan: CleanupPlan) -> None:
    repo = repo.resolve()
    current = build_plan(repo, plan.catalog)
    if not current.safe or current.signature != plan.signature or current.remove_files != plan.remove_files or current.ignore_updates != plan.ignore_updates:
        raise CleanupError("the target changed after preview; no cleanup was started")
    protected = None
    if plan.source == "installation lock":
        protected = LOCK_RELATIVE
    elif plan.source == "legacy install manifest":
        protected = MANIFEST_RELATIVE
    elif CATALOG_ANCHOR in plan.remove_files:
        protected = CATALOG_ANCHOR
    ordered = [path for path in plan.remove_files if path != protected]
    for relative in ordered:
        path = target_path(repo, relative)
        if not path.is_file():
            raise CleanupError(f"planned file changed during cleanup: {relative}")
        path.unlink()
    for relative, updated in plan.ignore_updates.items():
        path = target_path(repo, relative)
        if not path.is_file():
            raise CleanupError(f"planned ignore file changed during cleanup: {relative}")
        if updated is None:
            path.unlink()
        else:
            atomic_write(path, updated)
    if protected:
        path = target_path(repo, protected)
        if not path.is_file():
            raise CleanupError(f"ownership proof changed during cleanup: {protected}")
        path.unlink()
    directories: set[Path] = set()
    for relative in plan.remove_files:
        for root in LEGACY_ROOTS:
            if relative == root or relative.startswith(root + "/"):
                root_path = target_path(repo, root)
                current_path = target_path(repo, relative).parent
                while current_path != root_path.parent:
                    directories.add(current_path)
                    current_path = current_path.parent
                break
    for directory in sorted(directories, key=lambda value: len(value.parts), reverse=True):
        try:
            directory.rmdir()
        except OSError:
            pass


def print_plan(repo: Path, plan: CleanupPlan) -> None:
    print("ZzzOps legacy cleanup preview")
    print(f"Target: {repo}")
    if plan.source:
        print(f"Ownership proof: {plan.source}.")
    print(f"Files to remove: {len(plan.remove_files)}; ignore files to update: {len(plan.ignore_updates)}.")
    for relative in plan.remove_files:
        print(f"- remove {relative}")
    for relative in plan.ignore_updates:
        print(f"- remove ZzzOps-owned marker block from {relative}")
    if plan.tracked:
        print("Tracked paths (the Git index will not be changed):")
        for relative in plan.tracked:
            print(f"- {relative}")
    for warning in plan.warnings:
        print(f"Warning: {warning}")
    for error in plan.errors:
        print(f"Blocked: {error}")


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Remove a proven retired per-project ZzzOps installation")
    parser.add_argument("target", nargs="?", default=".")
    parser.add_argument("--apply", action="store_true", help="apply the previewed cleanup")
    parser.add_argument("--yes", action="store_true", help="confirm non-interactively; requires --apply")
    args = parser.parse_args(sys.argv[1:] if argv is None else argv)
    repo = Path(args.target).resolve()
    if not repo.is_dir() or not (repo / ".git").exists():
        print("Blocked: target must be a Git working tree")
        return 2
    try:
        plan = build_plan(repo)
        print_plan(repo, plan)
        if not plan.safe:
            print("No files or Git index entries were changed.")
            return 2
        if not args.apply:
            print("Dry run only. Re-run with --apply after reviewing this exact preview.")
            return 0
        if not args.yes:
            try:
                answer = input("Type 'remove legacy ZzzOps' to confirm: ")
            except EOFError:
                answer = ""
            if answer != "remove legacy ZzzOps":
                print("Cleanup cancelled; no files or Git index entries were changed.")
                return 0
        apply_plan(repo, plan)
        print("Legacy ZzzOps project machinery was removed. Durable .zzzops state and the Git index were preserved.")
        return 0
    except (CleanupError, OSError) as exc:
        print(f"Cleanup stopped: {exc}")
        print("Current remaining work:")
        try:
            print_plan(repo, build_plan(repo))
        except (CleanupError, OSError) as remaining_error:
            print(f"Blocked: {remaining_error}")
        print("Any already removed proven files are safe to leave absent; resolve a reported blocker, then rerun.")
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 399af53731e04748a886394524ef6767bba8e65568f628d7476f0986c410db28