import { ConfigStore } from './ConfigStore' import { exec } from 'child_process' import { promisify } from 'util' import { existsSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, rmdirSync } from 'fs' import { join } from 'path' import https from 'https' import http from 'http' import { createWriteStream } from 'fs' import { sendDownloadProgress } from '../main' const execAsync = promisify(exec) interface GitVersion { version: string path: string isActive: boolean } interface AvailableGitVersion { version: string downloadUrl: string type: 'portable' | 'installer' } export class GitManager { private configStore: ConfigStore constructor(configStore: ConfigStore) { this.configStore = configStore } /** * 获取 Git 安装路径 */ getGitPath(): string { return join(this.configStore.getBasePath(), 'git') } /** * 获取已安装的 Git 版本 */ async getInstalledVersions(): Promise { const versions: GitVersion[] = [] const gitPath = this.getGitPath() if (!existsSync(gitPath)) { return versions } // 检查是否存在 git.exe const gitExe = join(gitPath, 'cmd', 'git.exe') const gitExeAlt = join(gitPath, 'bin', 'git.exe') if (existsSync(gitExe) || existsSync(gitExeAlt)) { try { const exePath = existsSync(gitExe) ? gitExe : gitExeAlt const { stdout } = await execAsync(`"${exePath}" --version`, { windowsHide: true, timeout: 10000 }) const match = stdout.match(/git version (\d+\.\d+\.\d+)/) if (match) { versions.push({ version: match[1], path: gitPath, isActive: true }) } } catch (error: any) { console.error('获取 Git 版本失败:', error) } } return versions } /** * 获取可用的 Git 版本列表 */ async getAvailableVersions(): Promise { // Git for Windows 便携版下载地址 // https://github.com/git-for-windows/git/releases const versions: AvailableGitVersion[] = [ { version: '2.47.1', downloadUrl: 'https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.1/PortableGit-2.47.1-64-bit.7z.exe', type: 'portable' }, { version: '2.46.2', downloadUrl: 'https://github.com/git-for-windows/git/releases/download/v2.46.2.windows.1/PortableGit-2.46.2-64-bit.7z.exe', type: 'portable' }, { version: '2.45.2', downloadUrl: 'https://github.com/git-for-windows/git/releases/download/v2.45.2.windows.1/PortableGit-2.45.2-64-bit.7z.exe', type: 'portable' } ] // 过滤掉已安装的版本 const installed = await this.getInstalledVersions() const installedVersions = installed.map(v => v.version) return versions.filter(v => !installedVersions.includes(v.version)) } /** * 安装 Git */ async install(version: string): Promise<{ success: boolean; message: string }> { try { const available = await this.getAvailableVersions() const versionInfo = available.find(v => v.version === version) if (!versionInfo) { return { success: false, message: `未找到 Git ${version} 版本` } } const gitPath = this.getGitPath() const tempPath = this.configStore.getTempPath() const downloadPath = join(tempPath, `PortableGit-${version}.7z.exe`) // 确保目录存在 if (!existsSync(tempPath)) { mkdirSync(tempPath, { recursive: true }) } if (!existsSync(gitPath)) { mkdirSync(gitPath, { recursive: true }) } console.log(`开始下载 Git ${version} 从 ${versionInfo.downloadUrl}`) // 下载 Git await this.downloadFile(versionInfo.downloadUrl, downloadPath) console.log('下载完成,开始解压...') // 解压便携版 Git(自解压 7z) // 使用命令行运行自解压程序 try { await execAsync(`"${downloadPath}" -o"${gitPath}" -y`, { windowsHide: true, timeout: 300000 // 5分钟超时 }) } catch (error: any) { // 自解压可能不返回正确的退出码,检查文件是否存在 const gitExe = join(gitPath, 'cmd', 'git.exe') const gitExeAlt = join(gitPath, 'bin', 'git.exe') if (!existsSync(gitExe) && !existsSync(gitExeAlt)) { throw new Error(`解压失败: ${error.message}`) } } console.log('解压完成') // 删除临时文件 if (existsSync(downloadPath)) { unlinkSync(downloadPath) } // 添加到环境变量 await this.addToPath() return { success: true, message: `Git ${version} 安装成功` } } catch (error: any) { console.error('Git 安装失败:', error) return { success: false, message: `安装失败: ${error.message}` } } } /** * 卸载 Git */ async uninstall(): Promise<{ success: boolean; message: string }> { try { const gitPath = this.getGitPath() if (!existsSync(gitPath)) { return { success: false, message: 'Git 未安装' } } // 从环境变量移除 await this.removeFromPath() // 删除目录 this.removeDirectory(gitPath) return { success: true, message: 'Git 已卸载' } } catch (error: any) { return { success: false, message: `卸载失败: ${error.message}` } } } /** * 检查系统是否已安装 Git */ async checkSystemGit(): Promise<{ installed: boolean; version?: string; path?: string }> { try { const { stdout } = await execAsync('git --version', { windowsHide: true, timeout: 10000 }) const match = stdout.match(/git version (\d+\.\d+\.\d+)/) // 获取 git 路径 try { const { stdout: wherePath } = await execAsync('where git', { windowsHide: true, timeout: 5000 }) const gitExePath = wherePath.trim().split('\n')[0] return { installed: true, version: match ? match[1] : 'unknown', path: gitExePath } } catch { return { installed: true, version: match ? match[1] : 'unknown' } } } catch { return { installed: false } } } /** * 获取 Git 配置 */ async getGitConfig(): Promise<{ name?: string; email?: string }> { try { let name: string | undefined let email: string | undefined try { const { stdout: nameOut } = await execAsync('git config --global user.name', { windowsHide: true, timeout: 5000 }) name = nameOut.trim() } catch {} try { const { stdout: emailOut } = await execAsync('git config --global user.email', { windowsHide: true, timeout: 5000 }) email = emailOut.trim() } catch {} return { name, email } } catch { return {} } } /** * 设置 Git 配置 */ async setGitConfig(name: string, email: string): Promise<{ success: boolean; message: string }> { try { if (name) { await execAsync(`git config --global user.name "${name}"`, { windowsHide: true, timeout: 5000 }) } if (email) { await execAsync(`git config --global user.email "${email}"`, { windowsHide: true, timeout: 5000 }) } return { success: true, message: 'Git 配置已保存' } } catch (error: any) { return { success: false, message: `设置失败: ${error.message}` } } } // ==================== 私有方法 ==================== private async downloadFile(url: string, dest: string): Promise { return new Promise((resolve, reject) => { const file = createWriteStream(dest) const protocol = url.startsWith('https') ? https : http const request = protocol.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } }, (response) => { // 处理重定向 if (response.statusCode === 301 || response.statusCode === 302) { const redirectUrl = response.headers.location if (redirectUrl) { file.close() if (existsSync(dest)) unlinkSync(dest) this.downloadFile(redirectUrl, dest).then(resolve).catch(reject) return } } if (response.statusCode !== 200) { file.close() if (existsSync(dest)) unlinkSync(dest) reject(new Error(`下载失败,状态码: ${response.statusCode}`)) return } const totalSize = parseInt(response.headers['content-length'] || '0', 10) let downloadedSize = 0 let lastProgressTime = Date.now() response.on('data', (chunk) => { downloadedSize += chunk.length const now = Date.now() if (now - lastProgressTime > 500) { const progress = totalSize > 0 ? Math.round((downloadedSize / totalSize) * 100) : 0 sendDownloadProgress('git', progress, downloadedSize, totalSize) lastProgressTime = now } }) response.pipe(file) file.on('finish', () => { file.close() sendDownloadProgress('git', 100, totalSize, totalSize) resolve() }) file.on('error', (err) => { file.close() if (existsSync(dest)) unlinkSync(dest) reject(err) }) }) request.on('error', (err) => { file.close() if (existsSync(dest)) unlinkSync(dest) reject(new Error(`网络错误: ${err.message}`)) }) request.setTimeout(600000, () => { request.destroy() file.close() if (existsSync(dest)) unlinkSync(dest) reject(new Error('下载超时(10分钟)')) }) }) } private async addToPath(): Promise { try { const gitPath = this.getGitPath() const cmdPath = join(gitPath, 'cmd') const binPath = join(gitPath, 'bin') const tempScriptPath = join(this.configStore.getTempPath(), 'add_git_path.ps1') mkdirSync(this.configStore.getTempPath(), { recursive: true }) const psScript = ` param([string]$CmdPath, [string]$BinPath) $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') if ($userPath -eq $null) { $userPath = '' } $paths = $userPath -split ';' | Where-Object { $_ -ne '' -and $_.Trim() -ne '' } # 移除旧的 Git 路径 $filteredPaths = @() foreach ($p in $paths) { $pathLower = $p.ToLower() if (-not ($pathLower -like '*\\git\\*' -or $pathLower -like '*\\git-*')) { $filteredPaths += $p } } # 添加新路径 $allPaths = @($CmdPath, $BinPath) + $filteredPaths $newPath = $allPaths -join ';' [Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') Write-Host "SUCCESS: Git path added" ` writeFileSync(tempScriptPath, psScript, 'utf-8') await execAsync( `powershell -ExecutionPolicy Bypass -File "${tempScriptPath}" -CmdPath "${cmdPath}" -BinPath "${binPath}"`, { windowsHide: true, timeout: 30000 } ) if (existsSync(tempScriptPath)) { unlinkSync(tempScriptPath) } } catch (error: any) { console.error('添加 Git 到 PATH 失败:', error) } } private async removeFromPath(): Promise { try { const gitPath = this.getGitPath() const tempScriptPath = join(this.configStore.getTempPath(), 'remove_git_path.ps1') mkdirSync(this.configStore.getTempPath(), { recursive: true }) const psScript = ` param([string]$GitBasePath) $userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') if ($userPath -eq $null) { exit 0 } $gitPathLower = $GitBasePath.ToLower() $paths = $userPath -split ';' | Where-Object { $_ -ne '' -and -not $_.ToLower().StartsWith($gitPathLower) } $newPath = $paths -join ';' [Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') Write-Host "SUCCESS: Git path removed" ` writeFileSync(tempScriptPath, psScript, 'utf-8') await execAsync( `powershell -ExecutionPolicy Bypass -File "${tempScriptPath}" -GitBasePath "${gitPath}"`, { windowsHide: true, timeout: 30000 } ) if (existsSync(tempScriptPath)) { unlinkSync(tempScriptPath) } } catch (error: any) { console.error('从 PATH 移除 Git 失败:', error) } } private removeDirectory(dir: string): void { if (existsSync(dir)) { const files = readdirSync(dir, { withFileTypes: true }) for (const file of files) { const fullPath = join(dir, file.name) if (file.isDirectory()) { this.removeDirectory(fullPath) } else { unlinkSync(fullPath) } } rmdirSync(dir) } } }