#!/usr/bin/env python3 """gaokao-sync-remotes — T10.3 三仓同步脚本。""" from __future__ import annotations import argparse import subprocess import sys from dataclasses import dataclass DEFAULT_REMOTES = ("gitea", "origin", "tksea") @dataclass(frozen=True) class PushResult: remote: str branch: str local_head: str class SyncError(RuntimeError): """Raised when sync preconditions or push verification fail.""" def _run_git(*args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["git", *args], capture_output=True, text=True, check=False, ) def _git_stdout(*args: str) -> str: result = _run_git(*args) if result.returncode != 0: raise SyncError(result.stderr.strip() or f"git {' '.join(args)} failed") return result.stdout.strip() def _resolve_branch(explicit_branch: str | None) -> str: if explicit_branch: return explicit_branch branch = _git_stdout("branch", "--show-current") if not branch: raise SyncError("detached HEAD: 请显式传入 --branch") return branch def _ensure_required_remotes(remotes: tuple[str, ...]) -> None: configured = { line.strip() for line in _git_stdout("remote").splitlines() if line.strip() } missing = [remote for remote in remotes if remote not in configured] if missing: raise SyncError(f"missing remotes: {', '.join(missing)}") def _current_head(branch: str) -> str: return _git_stdout("rev-parse", branch) def _remote_head(remote: str, branch: str) -> str: result = _run_git("ls-remote", "--heads", remote, branch) if result.returncode != 0: raise SyncError(result.stderr.strip() or f"git ls-remote {remote} {branch} failed") output = result.stdout.strip() if not output: raise SyncError(f"remote {remote} missing branch {branch} after push") return output.split()[0] def sync_remotes(remotes: tuple[str, ...], branch: str, dry_run: bool) -> list[PushResult]: _ensure_required_remotes(remotes) local_head = _current_head(branch) results: list[PushResult] = [] for remote in remotes: if dry_run: print(f"[DRY-RUN] git push {remote} {branch}") results.append(PushResult(remote=remote, branch=branch, local_head=local_head)) continue push = _run_git("push", remote, branch) if push.returncode != 0: stderr = push.stderr.strip() or push.stdout.strip() or f"git push {remote} {branch} failed" raise SyncError(f"push failed for {remote}: {stderr}") remote_head = _remote_head(remote, branch) if remote_head != local_head: raise SyncError( f"verify failed for {remote}: local {local_head} != remote {remote_head}" ) results.append(PushResult(remote=remote, branch=branch, local_head=local_head)) return results def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="gaokao-sync-remotes", description="Push 当前分支到 gitea / origin / tksea 并校验 remote HEAD。", ) parser.add_argument( "--branch", help="要同步的本地分支,默认取当前分支", ) parser.add_argument( "--remote", dest="remotes", action="append", help="指定要同步的 remote,可重复传入;默认 gitea/origin/tksea", ) parser.add_argument( "--dry-run", action="store_true", help="只打印将执行的 git push,不真正推送", ) return parser def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: branch = _resolve_branch(args.branch) remotes = tuple(args.remotes or DEFAULT_REMOTES) results = sync_remotes(remotes=remotes, branch=branch, dry_run=args.dry_run) except SyncError as exc: print(str(exc), file=sys.stderr) return 2 if args.dry_run: print(f"DRY-RUN OK: {branch} -> {', '.join(result.remote for result in results)}") return 0 for result in results: print(f"OK {result.remote}: {result.branch} @ {result.local_head}") return 0 if __name__ == "__main__": raise SystemExit(main())