366 lines
16 KiB
Python
366 lines
16 KiB
Python
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.
|
||
|
||
同时禁止交互式提示,避免凭证/主机密钥校验卡住进程:
|
||
- GIT_TERMINAL_PROMPT=0:禁止 Git 弹出用户名/密码输入
|
||
- GIT_ASKPASS=echo:凭证缺失时立即返回空串而不是挂起
|
||
- GIT_SSH_COMMAND:SSH 仓库走 BatchMode + 连接超时,不等待交互
|
||
"""
|
||
env = {
|
||
"GIT_TERMINAL_PROMPT": "0",
|
||
"GIT_ASKPASS": "echo",
|
||
"GIT_SSH_COMMAND": "ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new",
|
||
}
|
||
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 _test_connection(
|
||
self, repo_url: str, branch: str = "main", username: str = None, token: str = None
|
||
) -> Tuple[bool, str]:
|
||
"""Verify remote access without changing the project working tree.
|
||
|
||
通过 Git 配置让连接快速失败,避免错误配置(地址错误、认证失败、
|
||
网络不可达)长时间挂起后仅返回"命令超时":
|
||
- http.connectTimeout:TCP/TLS 连接阶段超时(含 DNS)
|
||
- http.lowSpeedLimit/lowSpeedTime:连接后传输过慢视为失败
|
||
"""
|
||
if not repo_url or not str(repo_url).strip():
|
||
return False, "仓库地址不能为空"
|
||
|
||
with tempfile.TemporaryDirectory(prefix="nex-docus-git-test-") as temp_path:
|
||
cwd = Path(temp_path)
|
||
ok, message = self._run_command(
|
||
[
|
||
"git",
|
||
"-c", "http.connectTimeout=10",
|
||
"-c", "http.lowSpeedLimit=1000",
|
||
"-c", "http.lowSpeedTime=15",
|
||
"ls-remote", "--heads", self._clean_repo_url(repo_url),
|
||
f"refs/heads/{branch or 'main'}",
|
||
],
|
||
cwd,
|
||
self._auth_env(username, token),
|
||
timeout=30,
|
||
)
|
||
if ok:
|
||
return True, ""
|
||
return False, self._humanize_git_error(message)
|
||
|
||
def _humanize_git_error(self, message: str) -> str:
|
||
"""把底层 Git/libcurl 报错翻译成对用户友好的提示。"""
|
||
if not message:
|
||
return "无法连接远端仓库"
|
||
lowered = message.lower()
|
||
if "超时" in message or "timed out" in lowered or "operation timed out" in lowered:
|
||
return "连接远端仓库超时,请检查仓库地址、网络或代理设置"
|
||
if "could not resolve host" in lowered or "name or service not known" in lowered:
|
||
return "无法解析仓库地址,请检查仓库 URL 是否正确"
|
||
if (
|
||
"connection refused" in lowered
|
||
or "connection reset" in lowered
|
||
or "couldn't connect" in lowered
|
||
or "could not connect" in lowered
|
||
or "failed to connect" in lowered
|
||
or "unable to access" in lowered
|
||
or "connection timed out" in lowered
|
||
):
|
||
return "无法连接到远端仓库,请检查仓库地址、网络或代理设置"
|
||
if "authentication failed" in lowered or "401" in lowered or "403" in lowered:
|
||
return "认证失败,请检查用户名与访问令牌是否正确"
|
||
if "could not read username" in lowered or "terminal prompts disabled" in lowered:
|
||
return "未提供有效的用户名与访问令牌"
|
||
if "host key verification failed" in lowered:
|
||
return "SSH 主机密钥校验失败,请确认该主机已受信任"
|
||
if "permission denied" in lowered or "publickey" in lowered:
|
||
return "SSH 认证失败,请检查用户名与访问令牌"
|
||
if "repository not found" in lowered or "not found" in lowered or "does not exist" in lowered:
|
||
return "远端仓库不存在或无权访问"
|
||
return message
|
||
|
||
async def test_connection(
|
||
self, repo_url: str, branch: str = "main", username: str = None, token: str = None
|
||
) -> Tuple[bool, str]:
|
||
"""Run remote authentication test outside the async event loop."""
|
||
return await asyncio.to_thread(self._test_connection, repo_url, branch, username, token)
|
||
|
||
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()
|