287 lines
12 KiB
Python
287 lines
12 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."""
|
|
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()
|