mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
132 lines
6.7 KiB
Python
Executable File
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())
|