RAG/utils/git_tool.py

550 lines
24 KiB
Python
Raw Permalink 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命令封装工具类
实现Git仓库的克隆、更新检测、增量拉取等功能
"""
import subprocess
import os
from typing import Tuple, Dict, List
from loguru import logger
from config import settings
class GitTool:
def __init__(self, user_id: str = "test", repo_id: str = "test", git_config: dict = None,
git_url: str = None, branch: str = None, protocol: str = None,
https_token: str = None, ssh_key: str = None, password: str = None, local_repo_path: str = None):
"""
初始化Git工具类
Args:
user_id: 用户ID
repo_id: 仓库ID
git_config: Git配置信息
git_url: Git仓库URL直接参数优先级高于git_config
branch: Git分支直接参数优先级高于git_config
protocol: Git协议直接参数优先级高于git_config
https_token: HTTPS令牌直接参数优先级高于git_config
ssh_key: SSH密钥直接参数优先级高于git_config
password: SSH密码直接参数优先级高于git_config
local_repo_path: 本地仓库路径直接参数优先级高于git_config
"""
self.user_id = user_id
self.repo_id = repo_id
# 优先使用直接参数如果没有则使用git_config
if git_config:
self.git_url = git_url or git_config.get("git_url")
# 如果branch为空字符串保持为空字符串不使用默认值
self.branch = branch if branch is not None else git_config.get("branch", "")
self.protocol = protocol or git_config.get("protocol", "https")
self.ssh_key = ssh_key or git_config.get("ssh_key")
self.https_token = https_token or git_config.get("https_token")
self.password = password or git_config.get("password")
# 本地结构化存储路径
self.local_repo_path = local_repo_path or git_config.get("local_repo_path")
else:
self.git_url = git_url
# 如果branch为空字符串保持为空字符串不使用默认值
self.branch = branch if branch is not None else ""
self.protocol = protocol or "https"
self.ssh_key = ssh_key
self.https_token = https_token
self.password = password
self.local_repo_path = local_repo_path
if not self.local_repo_path:
self.local_repo_path = os.path.join(settings.GIT_LOCAL_STORAGE_ROOT, user_id, repo_id)
# 初始化Git环境
try:
self._init_git_env()
except Exception as e:
import traceback
logger.error(f"初始化Git环境失败: {e}")
logger.error(f"错误类型: {type(e).__name__}")
logger.error(f"错误堆栈: {traceback.format_exc()}")
raise
def _init_git_env(self):
"""
初始化Git环境SSH密钥配置
"""
logger.info(f"初始化Git环境密码{self.password is not None}, SSH密钥{self.ssh_key is not None}")
# 设置 SSH variant 为 ssh避免 "simple" variant 不支持端口问题
os.environ["GIT_SSH_VARIANT"] = "ssh"
# 提取SSH端口如果在URL中指定
ssh_port = 22
if self.git_url and self.git_url.startswith('ssh://'):
# 解析SSH URL以获取端口
import re
port_match = re.search(r'ssh://[^:]+:([0-9]+)/', self.git_url)
if port_match:
ssh_port = port_match.group(1)
if self.ssh_key:
# 解密SSH私钥写入临时文件配置Git SSH
ssh_key_path = f"/tmp/ssh_key_{self.user_id}_{self.repo_id}"
with open(ssh_key_path, "w") as f:
f.write(self.ssh_key)
if not self.ssh_key.endswith('\n'):
f.write('\n')
os.chmod(ssh_key_path, 0o600)
os.environ["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -p {ssh_port} -o StrictHostKeyChecking=no"
logger.info(f"使用SSH密钥认证命令{os.environ['GIT_SSH_COMMAND']}")
elif self.password:
# 检查是否在Windows系统上
import platform
system = platform.system()
logger.info(f"本地系统类型:{system}")
# 使用 GIT_ASKPASS 机制,这是 Git 官方推荐的密码输入方式
# 创建 askpass 脚本
askpass_script = r'''#!/usr/bin/env python3
import sys
# 密码文件路径通过环境变量传递
import os
password_file = os.environ.get('GIT_ASKPASS_PASSWORD_FILE', '')
if password_file and os.path.exists(password_file):
with open(password_file, 'r', encoding='utf-8') as f:
print(f.read().strip())
else:
print('111111') # 默认密码
'''
import tempfile
import sys as sys_module
# 写入 askpass 脚本
askpass_file = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8')
askpass_file.write(askpass_script)
askpass_file.close()
askpass_path = askpass_file.name
# 写入密码文件
password_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8')
password_file.write(self.password)
password_file.close()
password_file_path = password_file.name
# 设置环境变量
os.environ['GIT_ASKPASS'] = sys_module.executable.replace('\\', '/') + ' "' + askpass_path.replace('\\', '/') + '"'
os.environ['GIT_ASKPASS_PASSWORD_FILE'] = password_file_path.replace('\\', '/')
os.environ['GIT_TERMINAL_PROMPT'] = '0' # 禁用 Git 的终端提示
# 设置 SSH 使用交互式模式
os.environ['GIT_SSH_COMMAND'] = f'ssh -o StrictHostKeyChecking=no -o BatchMode=no'
logger.info(f"使用 GIT_ASKPASS 机制进行密码认证")
logger.info(f"GIT_ASKPASS: {os.environ['GIT_ASKPASS']}")
logger.info(f"GIT_SSH_COMMAND: {os.environ['GIT_SSH_COMMAND']}")
logger.info("使用密码认证,自动使用配置中的密码")
def clone_repo(self) -> bool:
"""
克隆Git仓库
Returns:
bool: 是否成功克隆
"""
if not os.path.exists(self.local_repo_path):
os.makedirs(os.path.dirname(self.local_repo_path), exist_ok=True)
# 尝试两种URL格式原始URL和带/不带.git后缀的URL
test_urls = [self.git_url]
# 如果是Git daemon协议尝试两种格式
if self.git_url.startswith("git://"):
if ".git" in self.git_url:
# 尝试不带.git后缀的格式
test_urls.append(self.git_url.replace(".git", ""))
else:
# 尝试带.git后缀的格式
test_urls.append(f"{self.git_url}.git")
# 如果是SSH协议且路径不包含.git后缀尝试带.git后缀的格式
elif self.git_url.startswith("ssh://") and ".git" not in self.git_url:
test_urls.append(f"{self.git_url}.git")
for url in test_urls:
# 根据分支是否为空决定克隆方式
if self.branch:
# 有指定分支,使用带分支克隆
cmd = [
"git", "clone", "--single-branch",
"--branch", self.branch, url, self.local_repo_path
]
logger.info(f"执行Git克隆命令: {' '.join(cmd)}")
# 捕获输出,避免密码提示 #BUG: 对于云服务器方式不能使用git pass密码验证显示permission denied
res = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace')
if res.returncode == 0:
# 克隆成功更新git_url
self.git_url = url
# 克隆后校验
self._check_repo_integrity()
logger.info(f"Git仓库克隆成功: {self.local_repo_path}")
return True
else:
logger.error(f"Git克隆失败: {res.stderr}")
# 清理失败的克隆尝试
if os.path.exists(self.local_repo_path):
import shutil
shutil.rmtree(self.local_repo_path)
# 尝试不带分支克隆(适用于空分支或分支不存在的情况)
cmd_no_branch = [
"git", "clone", url, self.local_repo_path
]
logger.info(f"尝试不带分支克隆: {' '.join(cmd_no_branch)}")
res_no_branch = subprocess.run(cmd_no_branch, capture_output=True, text=True, encoding='utf-8', errors='replace')
if res_no_branch.returncode == 0:
# 克隆成功更新git_url
self.git_url = url
# 克隆后校验
self._check_repo_integrity()
logger.info(f"Git仓库克隆成功(不带分支): {self.local_repo_path}")
return True
else:
logger.error(f"不带分支克隆失败: {res_no_branch.stderr}")
# 继续尝试其他URL格式
continue
# 所有格式都尝试失败
raise Exception("所有Git克隆尝试都失败")
else:
# 仓库已存在,执行完整性检查
logger.info(f"Git仓库已存在: {self.local_repo_path}")
# 检查仓库完整性
self._check_repo_integrity()
# 尝试获取当前分支
try:
current_branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.local_repo_path,
capture_output=True,
text=True,
encoding='utf-8',
errors='replace'
).stdout.strip()
if current_branch:
self.branch = current_branch
logger.info(f"检测到本地仓库分支: {current_branch}")
# 尝试获取远程URL
remote_url = subprocess.run(
["git", "config", "--get", "remote.origin.url"],
cwd=self.local_repo_path,
capture_output=True,
text=True,
encoding='utf-8',
errors='replace'
).stdout.strip()
if remote_url:
self.git_url = remote_url
logger.info(f"检测到远程URL: {remote_url}")
except Exception as e:
logger.warning(f"获取本地仓库信息失败: {e}")
return False
def _check_repo_integrity(self):
"""
仓库完整性校验git fsck+ 支持的编程语言检测
"""
# 执行git fsck
try:
subprocess.run(["git", "fsck"], cwd=self.local_repo_path, check=True, capture_output=True, text=True, encoding='utf-8', errors='replace')
logger.info(f"Git仓库完整性校验成功: {self.local_repo_path}")
except subprocess.CalledProcessError as e:
logger.warning(f"Git仓库完整性校验失败: {e.stderr}")
# 扫描文件类型,记录支持的编程语言
support_lang = self._detect_support_lang()
logger.info(f"检测到支持的编程语言: {support_lang}")
return support_lang
def _detect_support_lang(self) -> List[str]:
"""
检测仓库支持的编程语言
Returns:
List[str]: 支持的编程语言列表
"""
lang_extensions = {
"python": [".py"],
"java": [".java"],
"go": [".go"],
"javascript": [".js", ".jsx"],
"typescript": [".ts", ".tsx"],
"c": [".c", ".h"],
"cpp": [".cpp", ".hpp", ".cc"],
"csharp": [".cs"],
"rust": [".rs"],
"php": [".php"],
"ruby": [".rb"],
"swift": [".swift"],
"kotlin": [".kt"],
"scala": [".scala"]
}
support_lang = []
for root, dirs, files in os.walk(self.local_repo_path):
# 跳过.git目录
if ".git" in dirs:
dirs.remove(".git")
# 跳过其他常见的非代码目录
dirs_to_skip = ["node_modules", "venv", "dist", "build", "__pycache__"]
dirs[:] = [d for d in dirs if d not in dirs_to_skip]
for file in files:
for lang, extensions in lang_extensions.items():
if any(file.endswith(ext) for ext in extensions):
if lang not in support_lang:
support_lang.append(lang)
break
return support_lang
def detect_remote_update(self) -> Tuple[bool, str, str]:
"""
远程更新检测
Returns:
Tuple[bool, str, str]: (是否有更新, 本地commit ID, 远程commit ID)
"""
# 确保仓库存在
if not os.path.exists(self.local_repo_path):
raise Exception(f"Git仓库不存在: {self.local_repo_path}")
# 拉取远程commit记录
try:
# 修改fetch命令只获取远程更新而不直接更新本地分支
subprocess.run(["git", "fetch", "origin", self.branch],
cwd=self.local_repo_path, check=True, capture_output=True, text=True, encoding='utf-8',errors="replace")
except subprocess.CalledProcessError as e:
logger.error(f"Git fetch失败: {e.stderr}")
raise
# 获取本地/远程commit ID
local_commit = subprocess.run(["git", "rev-parse", "HEAD"],
cwd=self.local_repo_path, capture_output=True, text=True, encoding='utf-8', errors='replace').stdout.strip()
remote_commit = subprocess.run(["git", "rev-parse", f"origin/{self.branch}"],
cwd=self.local_repo_path, capture_output=True, text=True, encoding='utf-8', errors='replace').stdout.strip()
has_update = local_commit != remote_commit
logger.info(f"Git更新检测: 本地={local_commit[:7]}, 远程={remote_commit[:7]}, 有更新={has_update}")
return has_update, local_commit, remote_commit
def incremental_pull(self, local_commit: str, remote_commit: str) -> Dict[str, List[str]]:
"""
增量拉取代码+解析文件变更
Args:
local_commit: 本地commit ID
remote_commit: 远程commit ID
Returns:
Dict[str, List[str]]: 文件变更集
"""
# 快进合并到远程最新版本
try:
subprocess.run(["git", "merge", "--ff-only", f"origin/{self.branch}"],
cwd=self.local_repo_path, check=True, capture_output=True, text=True, encoding='utf-8', errors="replace")
logger.info(f"Git快进合并成功: {self.branch}")
except subprocess.CalledProcessError as e:
logger.error(f"Git合并失败: {e.stderr}")
raise
# 提取增量commit的文件变更
delta_commits = subprocess.run(["git", "log", "--pretty=format:%H", f"{local_commit}..{remote_commit}"],
cwd=self.local_repo_path, capture_output=True, text=True, encoding='utf-8', errors='replace').stdout.split()
# 解析文件变更为ADD/MODIFY/DELETE
delta_files = self._parse_delta_files(delta_commits)
logger.info(f"Git增量变更: ADD={len(delta_files['ADD'])}, MODIFY={len(delta_files['MODIFY'])}, DELETE={len(delta_files['DELETE'])}, RENAME={len(delta_files['RENAME'])}")
return delta_files
def _parse_delta_files(self, delta_commits: List[str]) -> Dict[str, List[str]]:
"""
解析文件变更集
Args:
delta_commits: 增量commit列表
Returns:
Dict[str, List[str]]: 文件变更集
"""
add_files, modify_files, delete_files, rename_files = [], [], [], []
for commit in delta_commits:
# git show --name-status 获取文件变更
res = subprocess.run(["git", "show", "--name-status", commit],
cwd=self.local_repo_path, capture_output=True, text=True, encoding='utf-8', errors='replace').stdout
for line in res.splitlines():
if not line:
continue
# 解析状态和文件路径
if "\t" in line:
status, file_path = line.split("\t", 1)
if status.startswith("R"):
# 处理重命名:分割旧路径和新路径
old_path, new_path = file_path.split("\t", 1)
old_full_path = os.path.join(self.local_repo_path, old_path)
new_full_path = os.path.join(self.local_repo_path, new_path)
if status == "R100":
# 对于R100仅重命名无内容变更处理为仅修改对应document的元数据file_path、chunk_id、func_id
rename_files.append((old_full_path, new_full_path))
else:
# 旧路径视为删除,新路径视为添加
delete_files.append(old_full_path)
add_files.append(new_full_path)
elif status == "A":
full_path = os.path.join(self.local_repo_path, file_path)
add_files.append(full_path)
elif status == "M":
full_path = os.path.join(self.local_repo_path, file_path)
modify_files.append(full_path)
elif status == "D":
full_path = os.path.join(self.local_repo_path, file_path)
delete_files.append(full_path)
# 去重并返回
return {
"ADD": list(set(add_files)),
"MODIFY": list(set(modify_files)),
"DELETE": list(set(delete_files)),
"RENAME": list(set(rename_files)),
}
def get_current_commit(self) -> str:
"""
获取当前commit ID
Returns:
str: 当前commit ID
"""
if not os.path.exists(self.local_repo_path):
raise Exception(f"Git仓库不存在: {self.local_repo_path}")
commit_id = subprocess.run(["git", "rev-parse", "HEAD"],
cwd=self.local_repo_path, capture_output=True, text=True, encoding='utf-8', errors='replace').stdout.strip()
return commit_id
def get_repo_info(self) -> Dict[str, str]:
"""
获取仓库信息
Returns:
Dict[str, str]: 仓库信息
"""
if not os.path.exists(self.local_repo_path):
raise Exception(f"Git仓库不存在: {self.local_repo_path}")
# 获取仓库URL
remote_url = subprocess.run(["git", "config", "--get", "remote.origin.url"],
cwd=self.local_repo_path, capture_output=True, text=True, encoding='utf-8', errors='replace').stdout.strip()
# 获取当前分支
current_branch = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.local_repo_path, capture_output=True, text=True, encoding='utf-8', errors='replace').stdout.strip()
# 获取当前commit
current_commit = self.get_current_commit()
return {
"remote_url": remote_url,
"current_branch": current_branch,
"current_commit": current_commit,
"local_path": self.local_repo_path
}
def test_connection(self) -> bool:
"""
测试Git连接
Returns:
bool: 是否连接成功
"""
if not self.git_url:
raise Exception("Git仓库URL未设置")
# 尝试两种URL格式原始URL和带/不带.git后缀的URL
test_urls = [self.git_url]
# 如果是Git daemon协议尝试两种格式
if self.git_url.startswith("git://"):
if ".git" in self.git_url:
# 尝试不带.git后缀的格式
test_urls.append(self.git_url.replace(".git", ""))
else:
# 尝试带.git后缀的格式
test_urls.append(f"{self.git_url}.git")
for url in test_urls:
logger.info(f"测试Git连接: {url}")
# 尝试执行git ls-remote命令来测试连接
try:
cmd = ["git", "ls-remote", "--heads", url, f"refs/heads/{self.branch}"]
logger.info(f"执行Git连接测试命令: {' '.join(cmd)}")
res = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8', errors='replace')
if res.returncode == 0:
if self.branch in res.stdout or not res.stdout:
logger.info("Git连接测试成功")
self.git_url = url
return True
else:
logger.warning(f"Git连接测试失败分支 {self.branch} 不存在")
continue
else:
logger.error(f"Git连接测试失败: {res.stderr}")
continue
except Exception as e:
logger.error(f"Git连接测试异常: {e}")
continue
logger.error("所有Git连接测试格式都失败")
return False
def get_file_blob_sha(self, file_path: str) -> str:
"""获取文件的Git blob SHA值"""
try:
rel_path = os.path.relpath(file_path, self.local_repo_path)
cmd = ["git", "ls-files", "-s", rel_path]
result = subprocess.run(cmd, cwd=self.local_repo_path, capture_output=True, text=True)
if result.returncode == 0 and result.stdout.strip():
parts = result.stdout.strip().split()
if len(parts) >= 3:
return parts[1]
except Exception as e:
logger.warning(f"获取文件blob SHA失败 {file_path}: {e}")
return ""
def get_all_file_shas(self) -> Dict[str, str]:
"""获取仓库中所有支持语言文件的blob SHA"""
from sync.ast_parser import ASTParser
file_shas = {}
support_lang = self._detect_support_lang()
for root, dirs, files in os.walk(self.local_repo_path):
if ".git" in dirs:
dirs.remove(".git")
for file in files:
file_path = os.path.join(root, file)
lang = ASTParser.detect_language(file_path)
if lang and lang in support_lang:
blob_sha = self.get_file_blob_sha(file_path)
if blob_sha:
file_shas[file_path] = blob_sha
return file_shas