147 lines
4.2 KiB
Plaintext
147 lines
4.2 KiB
Plaintext
|
|
#!/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())
|