Files
StarPilot/tools/vendor/sync.py
T
2026-10-18 16:16:19 -05:00

132 lines
6.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Preview or stage a three-way dependency update from an explicit upstream commit."""
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from openpilot.common.vendor_manifest import MANIFEST, SHA, dependency_entries, git, under, validate_worktree
def run(repo: Path, *args: str, data: bytes | None = None) -> bytes:
return subprocess.check_output(["git", "-c", "core.hooksPath=/dev/null", "-C", str(repo), *args], input=data)
def commit_tree(repo: Path, tree: str, parent: str | None = None) -> str:
args = ["-c", "user.name=Source sync", "-c", "user.email=source-sync@localhost", "-c", "commit.gpgsign=false", "commit-tree", tree]
if parent is not None:
args += ["-p", parent]
return run(repo, *args, data=b"Temporary source merge\n").decode().strip()
def filtered_tree(repo: Path, revision: str, excluded: list[str], included: list[str] | None = None) -> str:
# With an include list, only the listed upstream paths are imported, less any exclusions.
run(repo, "read-tree", revision)
records = run(repo, "ls-files", "--stage", "-z").split(b"\0")
remove = []
for record in records:
if not record:
continue
meta, path = record.split(b"\t", 1)
name = path.decode()
if under(name, excluded) or (included is not None and not under(name, included)):
remove.append(path)
elif meta.startswith(b"160000") or Path(name).name.lower() in (".gitmodules", "agents.md"):
raise ValueError(f"Upstream snapshot contains unsupported metadata: {name}")
if remove:
run(repo, "update-index", "--force-remove", "-z", "--stdin", data=b"\0".join(remove) + b"\0")
return run(repo, "write-tree").decode().strip()
def upstream_cache(entry: dict, target: str) -> Path:
cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "starpilot-source" / entry["path"]
cache.mkdir(parents=True, exist_ok=True)
if not (cache / "HEAD").exists():
run(cache, "init", "--bare", "--quiet")
run(cache, "fetch", "--no-tags", "--no-recurse-submodules", "--depth=1", entry["url"], entry["commit"], target)
return cache
def sync(repo: Path, dependency: str, target: str, source_repo: Path | None = None, apply: bool = False) -> dict:
repo = repo.resolve()
if not SHA.fullmatch(target):
raise ValueError("Select an explicit full upstream commit hash")
manifest = validate_worktree(repo)
if git(repo, "status", "--porcelain=v1", "--untracked-files=all").strip():
raise ValueError("Commit or preserve all working tree changes before syncing")
entry = next((item for item in dependency_entries(manifest) if item["path"] == dependency), None)
if entry is None:
raise ValueError(f"Unknown dependency: {dependency}")
source = source_repo.resolve() if source_repo is not None else upstream_cache(entry, target)
if source == repo or repo in source.parents:
raise ValueError("Upstream object cache must live outside the source checkout")
for revision in (entry["commit"], target):
if run(source, "rev-parse", "--verify", f"{revision}^{{commit}}").decode().strip() != revision:
raise ValueError("Selected source object is not a commit")
original_tree = run(source, "rev-parse", f"{entry['commit']}^{{tree}}").decode().strip()
if original_tree != entry["tree"]:
raise ValueError("Recorded upstream base tree does not match its commit")
target_tree = run(source, "rev-parse", f"{target}^{{tree}}").decode().strip()
current_tree = run(repo, "rev-parse", f"HEAD:{dependency}").decode().strip()
current_head = run(repo, "rev-parse", "HEAD").decode().strip()
with tempfile.TemporaryDirectory(prefix="source-merge-") as directory:
scratch = Path(directory)
run(scratch, "init", "--quiet")
object_dirs = []
for source_path in (source, repo):
objects = Path(run(source_path, "rev-parse", "--git-path", "objects").decode().strip())
object_dirs.append(str(objects if objects.is_absolute() else (source_path / objects).resolve()))
(scratch / ".git/objects/info/alternates").write_text("\n".join(object_dirs) + "\n")
base_tree = filtered_tree(scratch, entry["commit"], entry["exclude"], entry.get("include"))
incoming_tree = filtered_tree(scratch, target, entry["exclude"], entry.get("include"))
base = commit_tree(scratch, base_tree)
ours = commit_tree(scratch, current_tree, base)
theirs = commit_tree(scratch, incoming_tree, base)
result = subprocess.run(["git", "-C", str(scratch), "merge-tree", "--write-tree", ours, theirs], capture_output=True)
if result.returncode == 1:
raise ValueError("Upstream update conflicts with local changes; checkout and manifest unchanged:\n" + result.stdout.decode())
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr)
merged_tree = result.stdout.splitlines()[0].decode()
# Validate merged metadata too: local edits must not undo import exclusions.
filtered_tree(scratch, merged_tree, [])
patch = run(scratch, "diff", "--binary", "--no-ext-diff", "--no-textconv", "--no-renames",
f"--src-prefix=a/{dependency}/", f"--dst-prefix=b/{dependency}/", current_tree, merged_tree)
changed = [os.fsdecode(name) for name in run(scratch, "diff", "--name-only", "-z", current_tree, merged_tree).split(b"\0") if name]
if apply:
if run(repo, "rev-parse", "HEAD").decode().strip() != current_head or git(repo, "status", "--porcelain=v1", "--untracked-files=all").strip():
raise ValueError("Source checkout changed while preparing the update")
if patch:
run(repo, "apply", "--check", "--index", "--binary", data=patch)
run(repo, "apply", "--index", "--binary", "--whitespace=nowarn", data=patch)
entry.update(commit=target, tree=target_tree)
(repo / MANIFEST).write_text(json.dumps(manifest, indent=2) + "\n")
run(repo, "add", "--", MANIFEST)
return {"dependency": dependency, "upstream_commit": target, "changed_files": changed, "applied": apply}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("dependency")
parser.add_argument("commit", help="Full upstream commit hash")
parser.add_argument("--source-repo", type=Path, help="Existing external upstream Git cache (offline operation)")
parser.add_argument("--apply", action="store_true", help="Stage a conflict-free update; never commit or push")
args = parser.parse_args()
try:
print(json.dumps(sync(ROOT, args.dependency, args.commit, args.source_repo, args.apply), indent=2))
except (ValueError, OSError, subprocess.CalledProcessError) as e:
print(f"Source sync failed: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())