import base64 import asyncio import os import shutil import subprocess import tempfile from pathlib import Path from typing import Tuple from urllib.parse import urlsplit, urlunsplit class GitService: """Run Git synchronization without persisting repository credentials.""" def _clean_repo_url(self, repo_url: str) -> str: """Remove any user info already embedded in an HTTP(S) URL.""" parsed = urlsplit(repo_url) if parsed.scheme not in ("http", "https"): return repo_url host = parsed.hostname or "" if parsed.port: host = f"{host}:{parsed.port}" return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment)) def _auth_env(self, username: str = None, token: str = None) -> dict: """Pass HTTPS Basic credentials to Git for this process only.""" env = {"GIT_TERMINAL_PROMPT": "0"} if username and token: credential = base64.b64encode(f"{username}:{token}".encode()).decode() env.update({ "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "http.extraHeader", "GIT_CONFIG_VALUE_0": f"Authorization: Basic {credential}", }) return env def _run_command(self, cmd: list, cwd: Path, env: dict = None, timeout: int = 60) -> Tuple[bool, str]: """Run a command and return its output without exposing process environment.""" try: run_env = os.environ.copy() run_env.update({"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"}) if env: run_env.update(env) result = subprocess.run( cmd, cwd=str(cwd), capture_output=True, text=True, encoding="utf-8", env=run_env, timeout=timeout, check=False, ) if result.returncode == 0: return True, result.stdout return False, result.stderr.strip() or result.stdout.strip() or "Git command failed" except subprocess.TimeoutExpired: return False, f"Git 命令超时({timeout} 秒):{' '.join(cmd)}" except Exception as exc: # noqa: BLE001 return False, str(exc) def _ensure_git_initialized(self, cwd: Path, repo_url: str) -> Tuple[bool, str]: """Ensure the project has a Git repository pointing to the credential-free URL.""" if not (cwd / ".git").exists(): ok, message = self._run_command(["git", "init"], cwd) if not ok: return False, message ok, message = self._run_command(["git", "branch", "-M", "main"], cwd) if not ok: return False, message for key, value in ( ("core.quotepath", "false"), ("i18n.logOutputEncoding", "utf-8"), ("core.precomposeunicode", "true"), ): ok, message = self._run_command(["git", "config", key, value], cwd) if not ok: return False, message clean_url = self._clean_repo_url(repo_url) ok, _ = self._run_command(["git", "remote", "get-url", "origin"], cwd) if ok: return self._run_command(["git", "remote", "set-url", "origin", clean_url], cwd) return self._run_command(["git", "remote", "add", "origin", clean_url], cwd) def _normalize_sub_path(self, sub_path: str = None) -> str: if not sub_path: return "" normalized = str(sub_path).strip().strip("/") if not normalized: return "" parts = normalized.split("/") if any(part in ("", ".", "..") or part.startswith(".") for part in parts): raise ValueError(f"非法的同步目录: {normalized}") return normalized def _ensure_commit_identity(self, cwd: Path) -> Tuple[bool, str]: ok, name = self._run_command(["git", "config", "user.name"], cwd) if not ok or not name.strip(): ok, message = self._run_command(["git", "config", "user.name", "NEX Docus"], cwd) if not ok: return False, message ok, email = self._run_command(["git", "config", "user.email"], cwd) if not ok or not email.strip(): return self._run_command( ["git", "config", "user.email", "noreply@nex-docus.local"], cwd ) return True, "" def _commit_if_changed(self, cwd: Path, message: str) -> Tuple[bool, str, bool]: ok, status = self._run_command(["git", "status", "--porcelain"], cwd) if not ok: return False, status, False if not status.strip(): return True, "", False ok, identity_message = self._ensure_commit_identity(cwd) if not ok: return False, identity_message, False ok, commit_message = self._run_command(["git", "commit", "-m", message], cwd) return ok, commit_message, ok def _remote_worktree(self, repo_url: str, branch: str, auth_env: dict): """Create an isolated worktree at the latest remote branch revision.""" temp_dir = tempfile.TemporaryDirectory(prefix="nex-docus-git-") root = Path(temp_dir.name) ok, message = self._run_command(["git", "init"], root) if not ok: temp_dir.cleanup() return None, None, message ok, message = self._run_command( ["git", "remote", "add", "origin", self._clean_repo_url(repo_url)], root ) if not ok: temp_dir.cleanup() return None, None, message ok, message = self._run_command(["git", "fetch", "origin"], root, auth_env) if not ok: temp_dir.cleanup() return None, None, message remote_ref = f"origin/{branch}" ok, _ = self._run_command(["git", "rev-parse", "--verify", remote_ref], root) if ok: ok, message = self._run_command(["git", "checkout", "-B", branch, remote_ref], root) else: ok, message = self._run_command(["git", "checkout", "-B", branch], root) if not ok: temp_dir.cleanup() return None, None, message return temp_dir, root, "" def _replace_path(self, source_root: Path, target_root: Path, sub_path: str) -> None: source = source_root / sub_path target = target_root / sub_path if target.exists() or target.is_symlink(): if target.is_dir() and not target.is_symlink(): shutil.rmtree(target) else: target.unlink() if source.exists(): target.parent.mkdir(parents=True, exist_ok=True) if source.is_dir(): shutil.copytree(source, target) else: shutil.copy2(source, target) def _pull( self, project_path: Path, repo_url: str, branch: str = "main", username: str = None, token: str = None, force: bool = False, sub_path: str = None, ) -> Tuple[bool, str]: if not project_path.exists(): return False, "项目目录不存在" sub_path = self._normalize_sub_path(sub_path) auth_env = self._auth_env(username, token) if sub_path: if force: return False, "目录级同步不支持强制拉取" temp_dir, remote_root, message = self._remote_worktree(repo_url, branch, auth_env) if not temp_dir: return False, f"获取远端仓库失败: {message}" try: self._replace_path(remote_root, project_path, sub_path) finally: temp_dir.cleanup() return True, f"已从远端同步目录 [{sub_path}]" ok, message = self._ensure_git_initialized(project_path, repo_url) if not ok: return False, message ok, message = self._run_command(["git", "fetch", "origin"], project_path, auth_env) if not ok: return False, f"获取远端更新失败: {message}" if force: return self._run_command(["git", "reset", "--hard", f"origin/{branch}"], project_path) return self._run_command(["git", "pull", "--ff-only", "origin", branch], project_path, auth_env) async def pull(self, *args, **kwargs) -> Tuple[bool, str]: """Run pull outside the async event loop.""" return await asyncio.to_thread(self._pull, *args, **kwargs) def _push( self, project_path: Path, repo_url: str, branch: str = "main", username: str = None, token: str = None, force: bool = False, sub_path: str = None, ) -> Tuple[bool, str]: if not project_path.exists(): return False, "项目目录不存在" sub_path = self._normalize_sub_path(sub_path) auth_env = self._auth_env(username, token) if sub_path: if force: return False, "目录级推送不支持强制推送" temp_dir, remote_root, message = self._remote_worktree(repo_url, branch, auth_env) if not temp_dir: return False, f"获取远端仓库失败: {message}" try: self._replace_path(project_path, remote_root, sub_path) ok, message = self._run_command(["git", "add", "-A", "--", sub_path], remote_root) if not ok: return False, f"暂存目录失败: {message}" ok, message, changed = self._commit_if_changed( remote_root, f"Update {sub_path} from NEX Docus" ) if not ok: return False, f"提交目录失败: {message}" if not changed: return True, f"目录 [{sub_path}] 没有需要推送的变更" ok, message = self._run_command( ["git", "push", "-u", "origin", branch], remote_root, auth_env ) if not ok: return False, f"推送被远端拒绝,请重新拉取该目录后再试: {message}" return True, f"已推送目录 [{sub_path}]" finally: temp_dir.cleanup() ok, message = self._ensure_git_initialized(project_path, repo_url) if not ok: return False, message ok, message = self._run_command(["git", "fetch", "origin"], project_path, auth_env) if not ok: return False, f"获取远端更新失败: {message}" if not force: ok, _ = self._run_command( ["git", "merge-base", "--is-ancestor", f"origin/{branch}", "HEAD"], project_path ) if not ok: return False, "远端仓库已有未拉取的更新,请先执行整库拉取后再推送" ok, message = self._run_command(["git", "add", "."], project_path) if not ok: return False, f"暂存项目失败: {message}" ok, message, _ = self._commit_if_changed(project_path, "Update from NEX Docus") if not ok: return False, f"提交项目失败: {message}" command = ["git", "push", "-u", "origin", branch] if force: command.append("--force-with-lease") return self._run_command(command, project_path, auth_env) async def push(self, *args, **kwargs) -> Tuple[bool, str]: """Run push outside the async event loop.""" return await asyncio.to_thread(self._push, *args, **kwargs) def list_remote_tree( self, project_path: Path, repo_url: str, branch: str = "main", username: str = None, token: str = None, ) -> Tuple[bool, str]: if not project_path.exists(): return False, "项目目录不存在" temp_dir, remote_root, message = self._remote_worktree( repo_url, branch, self._auth_env(username, token) ) if not temp_dir: return False, f"获取远端仓库失败: {message}" try: return self._run_command( ["git", "-c", "core.quotepath=false", "ls-tree", "-r", "--name-only", "HEAD"], remote_root, ) finally: temp_dir.cleanup() git_service = GitService()