Files
qiming/qimingclaw/crates/agent-electron-client/scripts/prepare/prepare-git.js

344 lines
11 KiB
JavaScript
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.
#!/usr/bin/env node
/**
* Git 集成(仅 Windows 客户端)
*
* 仅在 Windows 构建时执行:准备 resources/git/PortableGit + bash供端口检查等脚本使用。
* macOS/Linux 使用系统 git/bash不打包内置 Git。
*
* 参考 LobsterAI 方案scripts/setup-mingit.jsworkspace/lobsterAI
*
* - Windows下载 PortableGit.7z.exe并用 7zip-bin 解压,或使用本地归档/缓存
* - 非 Windows默认跳过可通过 --required 或 QIMING_SETUP_GIT_FORCE=1 在 macOS 上为 Windows 打包做准备
*
* 环境变量:
* QIMING_PORTABLE_GIT_ARCHIVE - 本地离线归档路径CI 推荐预先下载后设置)
* QIMING_GIT_URL - 下载地址覆盖(镜像等)
* QIMING_SETUP_GIT_FORCE=1 - 非 Windows 主机也执行准备(用于在 macOS 上打 Windows 包)
*
* CLI
* --required - 若无法准备则失败CI 中建议配合 QIMING_PORTABLE_GIT_ARCHIVE 使用)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { Readable } = require('stream');
const { pipeline } = require('stream/promises');
const { getProjectRoot } = require('../utils/project-paths');
// 与 LobsterAI 保持一致
const GIT_VERSION = '2.47.1';
const PORTABLE_GIT_FILE = `PortableGit-${GIT_VERSION}-64-bit.7z.exe`;
const DEFAULT_PORTABLE_GIT_URL =
`https://github.com/git-for-windows/git/releases/download/v${GIT_VERSION}.windows.1/${PORTABLE_GIT_FILE}`;
const PROJECT_ROOT = getProjectRoot();
const GIT_ROOT = path.join(PROJECT_ROOT, 'resources', 'git');
const CACHE_DIR = path.join(GIT_ROOT, '.cache');
const DEFAULT_ARCHIVE_PATH = path.join(CACHE_DIR, PORTABLE_GIT_FILE);
// 需要删除的目录(与 LobsterAI 一致,减小体积)
const DIRS_TO_PRUNE = [
'doc',
'ReleaseNotes.html',
'README.portable',
path.join('mingw64', 'doc'),
path.join('mingw64', 'share', 'doc'),
path.join('mingw64', 'share', 'gtk-doc'),
path.join('mingw64', 'share', 'man'),
path.join('mingw64', 'share', 'gitweb'),
path.join('mingw64', 'share', 'git-gui'),
path.join('mingw64', 'libexec', 'git-core', 'git-gui'),
path.join('mingw64', 'libexec', 'git-core', 'git-gui--askpass'),
path.join('usr', 'share', 'doc'),
path.join('usr', 'share', 'man'),
path.join('usr', 'share', 'vim'),
path.join('usr', 'share', 'perl5'),
path.join('usr', 'lib', 'perl5'),
];
function parseArgs(argv) {
return {
required: argv.includes('--required'),
};
}
function resolveInputPath(input) {
if (typeof input !== 'string') return null;
const trimmed = input.trim();
if (!trimmed) return null;
return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
}
function isNonEmptyFile(filePath) {
try {
return fs.statSync(filePath).isFile() && fs.statSync(filePath).size > 0;
} catch {
return false;
}
}
function getDirSize(dir) {
let size = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
size += getDirSize(full);
} else {
size += fs.statSync(full).size;
}
}
return size;
}
/** 解析 7zip-bin 可执行路径(与 LobsterAI 一致) */
function resolve7zaPath() {
let path7za;
try {
({ path7za } = require('7zip-bin'));
} catch (error) {
throw new Error(
'缺少依赖 "7zip-bin",请先 npm install 后重试。' +
`原始错误: ${error instanceof Error ? error.message : String(error)}`
);
}
if (!path7za || !fs.existsSync(path7za)) {
throw new Error(`7zip-bin 可执行文件未找到: ${path7za || '(空路径)'}`);
}
return path7za;
}
/**
* 查找 PortableGit 的 bash.exe 路径(与 LobsterAI findPortableGitBash 一致)
* @param {string} [baseDir=GIT_ROOT]
* @returns {string|null}
*/
function findPortableGitBash(baseDir = GIT_ROOT) {
const candidates = [
path.join(baseDir, 'bin', 'bash.exe'),
path.join(baseDir, 'usr', 'bin', 'bash.exe'),
path.join(baseDir, 'mingw64', 'bin', 'bash.exe'),
path.join(baseDir, 'mingw64', 'usr', 'bin', 'bash.exe'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/** 使用 fetch + stream 下载归档(与 LobsterAI 一致Node 18+ */
async function downloadArchive(url, destination) {
const response = await fetch(url, { redirect: 'follow' });
if (!response.ok || !response.body) {
throw new Error(`下载失败 (${response.status} ${response.statusText}): ${url}`);
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
const tmpFile = `${destination}.download`;
try {
const stream = fs.createWriteStream(tmpFile);
await pipeline(Readable.fromWeb(response.body), stream);
if (!isNonEmptyFile(tmpFile)) {
throw new Error('下载的归档为空。');
}
fs.renameSync(tmpFile, destination);
} catch (error) {
try {
fs.rmSync(tmpFile, { force: true });
} catch {
// ignore
}
throw error;
}
}
function pruneUnneededFiles() {
let prunedCount = 0;
for (const relPath of DIRS_TO_PRUNE) {
const fullPath = path.join(GIT_ROOT, relPath);
if (!fs.existsSync(fullPath)) continue;
try {
fs.rmSync(fullPath, { recursive: true, force: true });
prunedCount++;
} catch (error) {
console.warn(
`[prepare-git] 删除失败: ${relPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
console.log(`[prepare-git] 已删除 ${prunedCount} 个不需要的目录/文件`);
}
/**
* 使用 7zip-bin 解压 .7z.exe 到临时目录,再将内容移动到 GIT_ROOT
* 归档内通常有一层根目录(如 PortableGit 或 mingw64需把其内容移到 GIT_ROOT
*/
function extractArchive(archivePath) {
const sevenZip = resolve7zaPath();
const extractDir = path.join(GIT_ROOT, '.extract');
if (fs.existsSync(extractDir)) {
fs.rmSync(extractDir, { recursive: true, force: true });
}
fs.mkdirSync(extractDir, { recursive: true });
console.log(`[prepare-git] 使用 7zip-bin 解压: ${archivePath}`);
const result = spawnSync(sevenZip, ['x', archivePath, `-o${extractDir}`, '-y'], {
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`7z 解压失败,退出码 ${result.status}`);
}
// 将解压出的第一层目录内容移到 GIT_ROOT归档根可能是 PortableGit 或 mingw64 等)
const entries = fs.readdirSync(extractDir);
if (entries.length === 1) {
const single = path.join(extractDir, entries[0]);
if (fs.statSync(single).isDirectory()) {
const subEntries = fs.readdirSync(single);
for (const e of subEntries) {
const src = path.join(single, e);
const dest = path.join(GIT_ROOT, e);
if (fs.existsSync(dest)) {
fs.rmSync(dest, { recursive: true, force: true });
}
fs.renameSync(src, dest);
}
} else {
fs.renameSync(single, path.join(GIT_ROOT, entries[0]));
}
} else {
for (const entry of entries) {
const src = path.join(extractDir, entry);
const dest = path.join(GIT_ROOT, entry);
if (fs.existsSync(dest)) {
fs.rmSync(dest, { recursive: true, force: true });
}
fs.renameSync(src, dest);
}
}
fs.rmSync(extractDir, { recursive: true, force: true });
}
/**
* 解析归档来源:环境变量本地文件 → 缓存文件 → 下载
* @param {boolean} required - 若为 true 且无法得到归档则抛错
*/
async function resolveArchive(required) {
const envArchive = resolveInputPath(process.env.QIMING_PORTABLE_GIT_ARCHIVE);
if (envArchive) {
if (!isNonEmptyFile(envArchive)) {
throw new Error(
`QIMING_PORTABLE_GIT_ARCHIVE 指向无效文件: ${envArchive}`
);
}
console.log(`[prepare-git] 使用本地归档 QIMING_PORTABLE_GIT_ARCHIVE: ${envArchive}`);
return { archivePath: envArchive, source: 'env-archive' };
}
if (isNonEmptyFile(DEFAULT_ARCHIVE_PATH)) {
console.log(`[prepare-git] 使用缓存: ${DEFAULT_ARCHIVE_PATH}`);
return { archivePath: DEFAULT_ARCHIVE_PATH, source: 'cache' };
}
const urlFromEnv =
typeof process.env.QIMING_GIT_URL === 'string'
? process.env.QIMING_GIT_URL.trim()
: '';
const downloadUrl = urlFromEnv || DEFAULT_PORTABLE_GIT_URL;
try {
console.log(`[prepare-git] 下载 PortableGit: ${downloadUrl}`);
await downloadArchive(downloadUrl, DEFAULT_ARCHIVE_PATH);
const fileSizeMB = (fs.statSync(DEFAULT_ARCHIVE_PATH).size / 1024 / 1024).toFixed(1);
console.log(`[prepare-git] 已下载 (${fileSizeMB} MB): ${DEFAULT_ARCHIVE_PATH}`);
return { archivePath: DEFAULT_ARCHIVE_PATH, source: 'download' };
} catch (error) {
if (required) {
throw new Error(
'无法获取 PortableGit 归档。' +
'可设置 QIMING_PORTABLE_GIT_ARCHIVE 为本地离线包路径,或 QIMING_GIT_URL 为可访问镜像。' +
`原始错误: ${error instanceof Error ? error.message : String(error)}`
);
}
console.warn(
'[prepare-git] PortableGit 归档不可用,已跳过(未使用 --required。' +
`原因: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
/**
* 确保 PortableGit 已准备(与 LobsterAI ensurePortableGit 逻辑一致)
* @param {{ required?: boolean }} [options]
*/
async function ensurePortableGit(options = {}) {
const required = Boolean(options.required);
const isWindows = process.platform === 'win32';
const force = process.env.QIMING_SETUP_GIT_FORCE === '1';
const shouldRun = isWindows || required || force;
if (!shouldRun) {
console.log(
'[prepare-git] 非 Windows 主机跳过(可用 --required 或 QIMING_SETUP_GIT_FORCE=1 强制为 Windows 打包做准备)'
);
return { ok: true, skipped: true, bashPath: null };
}
const existingBash = findPortableGitBash();
if (existingBash) {
console.log(`[prepare-git] PortableGit 已就绪: ${existingBash}`);
return { ok: true, skipped: false, bashPath: existingBash };
}
const archive = await resolveArchive(required);
if (!archive) {
return { ok: true, skipped: true, bashPath: null };
}
extractArchive(archive.archivePath);
const resolvedBash = findPortableGitBash();
if (!resolvedBash) {
throw new Error(
'PortableGit 解压完成但未找到 bash.exe。' +
`已检查: ${path.join(GIT_ROOT, 'bin', 'bash.exe')} 与 usr/bin、mingw64 等。`
);
}
pruneUnneededFiles();
const finalSize = getDirSize(GIT_ROOT);
console.log(`[prepare-git] PortableGit 准备完成: ${resolvedBash}`);
console.log(`[prepare-git] 总大小: ~${(finalSize / 1024 / 1024).toFixed(1)} MB`);
return { ok: true, skipped: false, bashPath: resolvedBash };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
await ensurePortableGit({ required: args.required });
}
if (require.main === module) {
main().catch((error) => {
console.error('[prepare-git] 错误:', error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
module.exports = {
ensurePortableGit,
findPortableGitBash,
GIT_VERSION,
GIT_ROOT,
};