Files
svn-git-server/svn_mirror/git_to_svn.py
T

486 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Git→SVN sync direction.
Detects new Git commits (developer pushes) and translates them into SVN
commits, maintaining the mapping DB so that SVN→Git sync skips them.
"""
import logging
import os
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from .config import SVNConfig
from .mirror import Mirror
logger = logging.getLogger(__name__)
class GitToSVNError(Exception):
pass
# ─── Git subprocess helper (no circular import) ────────────────
def _git(git_dir: str, *args: str, input: str = None, timeout: int = 120,
check: bool = True) -> str:
"""Run a git command in the bare canonical repo and return stdout."""
try:
r = subprocess.run(
["git", "--git-dir", git_dir, *args],
input=input,
capture_output=True,
text=True,
timeout=timeout,
)
except FileNotFoundError:
raise GitToSVNError("git not found on PATH")
except subprocess.TimeoutExpired:
raise GitToSVNError(f"git command timed out: git {' '.join(args)}")
if check and r.returncode != 0:
raise GitToSVNError(
f"git command failed (exit {r.returncode}): "
f"git {' '.join(args)}\n{r.stderr.strip()}"
)
return r.stdout.strip() if r.returncode == 0 else ""
# ─── Git ref → SVN path helpers ────────────────────────────────
def _git_ref_to_svn_branch(git_ref: str, svn_cfg: SVNConfig) -> Optional[str]:
"""Reverse of :func:`branch_to_git_ref`.
``refs/heads/master`` → ``"trunk"``
``refs/heads/feat`` → ``"branches/feat"``
``refs/tags/v1`` → ``"tags/v1"``
"""
if git_ref == "refs/heads/master":
return svn_cfg.trunk
if git_ref.startswith("refs/heads/"):
branch_name = git_ref[len("refs/heads/"):]
return f"{svn_cfg.branches}/{branch_name}"
if git_ref.startswith("refs/tags/"):
tag_name = git_ref[len("refs/tags/"):]
return f"{svn_cfg.tags}/{tag_name}"
return None
def _git_ref_to_svn_url(git_ref: str, svn_url: str, svn_cfg: SVNConfig) -> Optional[str]:
"""Return the full SVN URL for a given Git ref."""
branch = _git_ref_to_svn_branch(git_ref, svn_cfg)
if branch is None:
return None
url = svn_url.rstrip("/") + "/" + branch
return url
def _wc_path(mirror: Mirror, svn_branch: str) -> Path:
"""Return the working-copy path for an SVN branch name.
Replaces ``/`` with ``_`` so branch names like ``branches/stable``
become a flat directory name.
"""
sanitized = svn_branch.replace("/", "_")
return mirror.svn_wc_dir / sanitized
# ─── SVN working copy management ───────────────────────────────
def _run_svn(args: list, input: bytes = None, timeout: int = 120,
wc: Optional[Path] = None,
auth_args: Optional[list] = None):
"""Run an svn command. If *wc* is given, run from that directory."""
if auth_args:
args = auth_args + args
env = {**os.environ, "LC_ALL": "C.UTF-8"}
try:
r = subprocess.run(
["svn"] + args,
input=input,
capture_output=True,
timeout=timeout,
cwd=str(wc) if wc else None,
env=env,
)
except FileNotFoundError:
raise GitToSVNError("svn CLI not found is subversion installed?")
except subprocess.TimeoutExpired:
raise GitToSVNError(f"svn command timed out: svn {' '.join(args)}")
if r.returncode != 0:
msg = (r.stderr or r.stdout or b"").decode("utf-8", errors="replace").strip()
raise GitToSVNError(f"svn failed (exit {r.returncode}): {msg}")
return r
def _ensure_wc(mirror: Mirror, svn_branch: str):
"""Ensure the SVN working copy for *svn_branch* exists and is at HEAD."""
auth_args = mirror.config.svn.auth_args()
wc = _wc_path(mirror, svn_branch)
svn_url = mirror.config.svn.url.rstrip("/") + "/" + svn_branch
if not (wc / ".svn").exists():
logger.info("Checking out SVN WC for %s …", svn_branch)
wc.mkdir(parents=True, exist_ok=True)
_run_svn(["checkout", "--ignore-externals", svn_url, str(wc)],
timeout=300, auth_args=auth_args)
else:
logger.debug("Updating SVN WC for %s …", svn_branch)
_run_svn(["revert", "-R", "."], timeout=120, wc=wc)
_run_svn(["update", "--ignore-externals"], timeout=120, wc=wc)
return wc
# ─── Author mapping (reverse) ──────────────────────────────────
def _parse_git_author(author_line: str) -> Tuple[str, str]:
"""Split ``"Name <email>"`` into (name, email).
Returns (author_line, "") if the format doesn't match.
"""
if "<" in author_line and author_line.endswith(">"):
name = author_line[:author_line.index("<")].strip()
email = author_line[author_line.index("<") + 1:-1].strip()
return (name, email)
return (author_line, "")
def _git_author_to_svn_username(author_name: str, author_email: str,
authors: Dict[str, str]) -> str:
"""Try to map a Git author (name, email) back to an SVN username.
The ``authors`` dict maps SVN username → ``"Name <email>"``. We
reverse the lookup by checking both the name and the email.
"""
for svn_user, mapped in authors.items():
name, email = _parse_git_author(mapped)
if name == author_name or email == author_email:
return svn_user
# Fallback: try name as-is
return author_name
# ─── Git commit detection ──────────────────────────────────────
def _tracked_refs(git_dir: str, svn_cfg: SVNConfig) -> List[Tuple[str, str]]:
"""Return ``(git_ref, svn_branch)`` pairs for commits → SVN.
Only returns ``refs/heads/*`` — tags are handled separately by
:func:`_detect_new_git_tags`.
"""
refs: List[Tuple[str, str]] = []
out = _git(git_dir, "for-each-ref", "--format=%(refname)", "refs/heads/",
timeout=30, check=False)
for ref in out.splitlines():
ref = ref.strip()
if not ref:
continue
branch = _git_ref_to_svn_branch(ref, svn_cfg)
if branch is not None:
refs.append((ref, branch))
return refs
def detect_new_git_commits(mirror: Mirror) -> List[Tuple[str, str, str]]:
"""Return ``[(commit_hash, svn_branch, author_name), …]`` oldest-first.
Walks each tracked Git ref backwards from its tip and collects
commits that are NOT yet recorded in the mapping DB.
"""
git_dir = str(mirror.canonical_dir)
svn_cfg = mirror.config.svn
new: List[Tuple[str, str, str]] = []
for git_ref, svn_branch in _tracked_refs(git_dir, svn_cfg):
out = _git(git_dir, "rev-list", "--topo-order", "--no-merges",
git_ref, timeout=60, check=False)
if not out:
continue
commits = out.splitlines()
unmapped: List[str] = []
found_boundary = False
for ch in commits:
if not ch:
continue
if mirror.db.get_svn_revision(ch) is not None:
found_boundary = True
break
unmapped.append(ch)
if not found_boundary:
logger.debug("No SVN ancestor found for %s skipping", git_ref)
continue
# Add oldest-first
for ch in reversed(unmapped):
author = _git(git_dir, "log", "--format=%aN <%aE>", "-1", ch,
timeout=30)
new.append((ch, svn_branch, author))
return new
# ─── Applying a single Git commit to SVN ────────────────────────
def _apply_diff_to_wc(mirror: Mirror, commit_hash: str, svn_branch: str, wc: Path):
"""Apply the file changes from *commit_hash* to the SVN working copy."""
git_dir = str(mirror.canonical_dir)
out = _git(git_dir, "diff-tree", "--no-commit-id", "-r", "--name-status",
"-z", commit_hash, timeout=60)
if not out:
logger.debug("Commit %s has no file changes", commit_hash[:8])
return
# Parse null-separated output: "A\0README\0M\0src/main.rs\0..."
# Status may include a score suffix, e.g. R100, C075 extract the letter.
parts = out.split("\0")
i = 0
while i < len(parts):
line = parts[i].strip()
if not line:
i += 1
continue
status = line[0]
if status not in ("A", "C", "D", "M", "R", "T"):
i += 1
continue
i += 1
if i >= len(parts):
break
path = parts[i]
i += 1
if status == "D":
_wc_delete(wc, path)
elif status in ("A", "C", "M", "T"):
_wc_add_or_modify(wc, git_dir, commit_hash, path)
elif status == "R":
if i < len(parts):
dest = parts[i]
i += 1
_wc_rename(wc, path, dest)
def _wc_delete(wc: Path, path: str):
full = wc / path
if full.exists():
_run_svn(["delete", "--force", str(full)], wc=wc)
logger.debug(" D %s", path)
def _wc_add_or_modify(wc: Path, git_dir: str, commit_hash: str, path: str):
full = wc / path
parent = full.parent
if not parent.exists():
parent.mkdir(parents=True, exist_ok=True)
_run_svn(["add", "--parents", str(parent)], wc=wc)
is_new = not full.exists()
full.parent.mkdir(parents=True, exist_ok=True)
r = subprocess.run(
["git", "--git-dir", git_dir, "show", f"{commit_hash}:{path}"],
capture_output=True, timeout=60,
)
if r.returncode == 0:
full.write_bytes(r.stdout)
if is_new:
_run_svn(["add", "--parents", str(full)], wc=wc)
logger.debug(" M %s", path)
def _wc_rename(wc: Path, src: str, dest: str):
src_full = wc / src
dest_full = wc / dest
if src_full.exists():
dest_full.parent.mkdir(parents=True, exist_ok=True)
_run_svn(["move", str(src_full), str(dest_full)], wc=wc)
logger.debug(" R %s%s", src, dest)
# ─── Parsing helpers ──────────────────────────────────────────
def _parse_commit_revision(result: subprocess.CompletedProcess) -> int:
"""Parse revision from ``svn commit`` output (on either stdout or stderr)."""
text = (result.stdout + b"\n" + result.stderr).decode("utf-8", errors="replace")
import re
m = re.search(r"Committed revision\s+(\d+)", text)
if m:
return int(m.group(1))
raise GitToSVNError(
f"Cannot determine committed revision from svn output:\n{text[:500]}"
)
# ─── Main entry point ──────────────────────────────────────────
def _detect_new_git_tags(mirror: Mirror) -> List[Tuple[str, str, int, str, str]]:
"""Find Git tags not yet pushed to SVN.
Returns ``[(tag_ref, svn_branch, source_svn_rev, author_line), …]``.
"""
git_dir = str(mirror.canonical_dir)
svn_cfg = mirror.config.svn
new: List[Tuple[str, str, int, str, str]] = []
out = _git(git_dir, "for-each-ref", "--format=%(refname)", "refs/tags/",
timeout=30, check=False)
for tag_ref in out.splitlines():
tag_ref = tag_ref.strip()
if not tag_ref:
continue
svn_branch = _git_ref_to_svn_branch(tag_ref, svn_cfg)
if svn_branch is None:
continue
# Already pushed to SVN?
if mirror.db.ref_exists(tag_ref):
continue
target = _git(git_dir, "rev-parse", tag_ref, timeout=30)
mapping = mirror.db.get_mapping_by_git_hash(target)
if mapping is None:
logger.debug(
"Tag %s target commit not in mapping DB skipping", tag_ref,
)
continue
source_rev = mapping["svn_revision"]
source_branch = mapping["svn_branch"]
author = _git(git_dir, "log", "--format=%aN <%aE>", "-1", target,
timeout=30)
new.append((tag_ref, svn_branch, source_rev, source_branch, author))
return new
def sync_git_to_svn(mirror: Mirror) -> int:
"""Push all pending Git commits and tags to SVN (thread-safe).
Returns the number of SVN revisions created.
"""
with mirror.sync_lock():
return _sync_git_to_svn_impl(mirror)
def _sync_git_to_svn_impl(mirror: Mirror) -> int:
git_dir = str(mirror.canonical_dir)
svn_cfg = mirror.config.svn
count = 0
# ── 1. Push new commits on branches ───────────────────────
commits = detect_new_git_commits(mirror)
for commit_hash, svn_branch, author_line in commits:
wc = _ensure_wc(mirror, svn_branch)
logger.info(
"Pushing Git commit %s → SVN %s", commit_hash[:8], svn_branch,
)
try:
_apply_diff_to_wc(mirror, commit_hash, svn_branch, wc)
message = _git(git_dir, "log", "--format=%B", "-1", commit_hash,
timeout=30)
svn_username = author_line
name, email = _parse_git_author(author_line)
if name != author_line and email:
svn_username = _git_author_to_svn_username(
name, email, mirror.config.authors,
)
result = _run_svn(
["commit", "--file", "-", "--username", svn_username],
input=message.encode("utf-8"),
wc=wc, timeout=120,
)
new_rev = _parse_commit_revision(result)
mirror.db.record_mapping(
new_rev, commit_hash, svn_branch,
_git_ref_from_branch(svn_branch, svn_cfg),
source="git",
)
logger.info(
"Pushed Git %s → SVN r%d (%s)",
commit_hash[:8], new_rev, svn_branch,
)
count += 1
except Exception:
logger.exception(
"Failed to push Git commit %s to SVN branch %s",
commit_hash[:8], svn_branch,
)
raise
# ── 2. Push new tags ──────────────────────────────────────
svn_url = svn_cfg.url.rstrip("/")
for tag_ref, svn_branch, source_rev, source_branch, author_line \
in _detect_new_git_tags(mirror):
logger.info("Pushing Git tag %s → SVN %s", tag_ref, svn_branch)
try:
# Build source URL (the SVN path being tagged)
src_url = f"{svn_url}/{source_branch}"
dst_url = f"{svn_url}/{svn_branch}"
message = _git(git_dir, "log", "--format=%B", "-1", tag_ref,
timeout=30)
svn_username = author_line
name, email = _parse_git_author(author_line)
if name != author_line and email:
svn_username = _git_author_to_svn_username(
name, email, mirror.config.authors,
)
result = _run_svn(
["copy", "-r", str(source_rev), src_url, dst_url,
"-m", message, "--username", svn_username],
timeout=120,
)
new_rev = _parse_commit_revision(result)
# The commit hash stored for a tag is the target commit hash
target = _git(git_dir, "rev-parse", tag_ref, timeout=30)
mirror.db.record_mapping(
new_rev, target, svn_branch, tag_ref, source="git",
)
logger.info(
"Pushed Git tag %s → SVN r%d (%s)", tag_ref, new_rev,
svn_branch,
)
count += 1
except Exception:
logger.exception(
"Failed to push Git tag %s to SVN", tag_ref,
)
raise
return count
def _git_ref_from_branch(svn_branch: str, svn_cfg: SVNConfig) -> str:
"""Convert SVN branch name to Git ref (mirror of :func:`branch_to_git_ref`)."""
if svn_branch == svn_cfg.trunk:
return "refs/heads/master"
if svn_branch.startswith(svn_cfg.branches + "/"):
return "refs/heads/" + svn_branch[len(svn_cfg.branches) + 1:]
if svn_branch.startswith(svn_cfg.tags + "/"):
return "refs/tags/" + svn_branch[len(svn_cfg.tags) + 1:]
return "refs/heads/" + svn_branch