#!/usr/bin/env python3 """Export current maintained Perforce files, excluding pending/depot deletions.""" import argparse import hashlib import json import marshal import os from pathlib import Path import shutil import subprocess import sys def perforce_files(source): command = ["p4", "-G", "fstat", "-T", "depotFile,clientFile,headAction,action,haveRev", str(source / "...")] process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) records, errors = [], [] while True: try: record = marshal.load(process.stdout) except EOFError: break record = {(k.decode() if isinstance(k, bytes) else k): (v.decode() if isinstance(v, bytes) else v) for k, v in record.items()} if record.get("code") == "error": errors.append(record.get("data", str(record))) elif record.get("clientFile"): if record.get("action") in ("delete", "move/delete"): continue if record.get("headAction") in ("delete", "move/delete") and record.get("action") not in ("add", "move/add", "branch"): continue records.append(record) error_text = process.stderr.read().decode() if process.wait() or errors: raise RuntimeError("Perforce inventory failed: " + "\n".join(errors) + error_text) return records def export(source, destination): source = source.resolve() if destination.exists(): raise ValueError("Export destination must not exist") records = perforce_files(source) if not records: raise ValueError("No maintained Perforce files found") manifest = [] # Validate all input files before creating the export. for record in records: path = Path(record["clientFile"]) relative = path.relative_to(source) if not path.is_file() and not path.is_symlink(): raise ValueError("Maintained file missing locally: " + str(relative)) if path.is_symlink(): data = os.readlink(path).encode() else: data = path.read_bytes() manifest.append({"path": relative.as_posix(), "depot": record.get("depotFile"), "haveRev": record.get("haveRev"), "action": record.get("action", ""), "sha256": hashlib.sha256(data).hexdigest(), "symlink": os.readlink(path) if path.is_symlink() else None}) destination.mkdir(parents=True) for entry, record in zip(manifest, records): target = destination / entry["path"] target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(record["clientFile"], target, follow_symlinks=False) copied = os.readlink(target).encode() if target.is_symlink() else target.read_bytes() if hashlib.sha256(copied).hexdigest() != entry["sha256"]: raise RuntimeError("Source changed during export: " + entry["path"]) (destination / ".export-manifest.json").write_text(json.dumps(sorted(manifest, key=lambda row: row["path"]), indent=2) + "\n") return manifest def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("source", type=Path) parser.add_argument("destination", type=Path) args = parser.parse_args() try: entries = export(args.source, args.destination) except (OSError, ValueError, RuntimeError) as error: parser.exit(1, str(error) + "\n") print("Exported %d maintained files to %s" % (len(entries), args.destination)) if __name__ == "__main__": main()