344 lines
11 KiB
JavaScript
344 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Git 集成(仅 Windows 客户端)
|
||
*
|
||
* 仅在 Windows 构建时执行:准备 resources/git/(PortableGit + bash),供端口检查等脚本使用。
|
||
* macOS/Linux 使用系统 git/bash,不打包内置 Git。
|
||
*
|
||
* 参考 LobsterAI 方案:scripts/setup-mingit.js(workspace/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,
|
||
};
|