mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 16:13:28 +08:00
209 lines
6.9 KiB
JavaScript
209 lines
6.9 KiB
JavaScript
import { execFileSync, spawnSync } from 'node:child_process'
|
||
import { existsSync, lstatSync } from 'node:fs'
|
||
import { resolve } from 'node:path'
|
||
import process from 'node:process'
|
||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||
import * as prettier from 'prettier'
|
||
|
||
const DEFAULT_BASE_REFS = ['refs/remotes/upstream/v2', 'refs/remotes/origin/v2', 'refs/heads/v2']
|
||
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024
|
||
const PRETTIER_ARGUMENT_BUDGET = 24_000
|
||
const prettierCliPath = fileURLToPath(new URL('../node_modules/prettier/bin/prettier.cjs', import.meta.url))
|
||
|
||
/** 解析 Git 的 NUL 分隔输出,保留文件名中的空格、换行和通配符字符。 */
|
||
function parseNullDelimited(output) {
|
||
return output.toString('utf8').split('\0').filter(Boolean)
|
||
}
|
||
|
||
/** 运行只返回仓库相对路径的 Git 查询。 */
|
||
function gitPaths(cwd, args) {
|
||
return parseNullDelimited(
|
||
execFileSync('git', args, {
|
||
cwd,
|
||
encoding: 'buffer',
|
||
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
||
}),
|
||
)
|
||
}
|
||
|
||
/** 尝试把引用解析为提交 SHA;完整引用可避免分支与标签同名时产生歧义。 */
|
||
function tryResolveCommit(cwd, ref) {
|
||
const result = spawnSync('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], {
|
||
cwd,
|
||
encoding: 'utf8',
|
||
})
|
||
return result.status === 0 ? result.stdout.trim() : undefined
|
||
}
|
||
|
||
/** 把外部引用解析为提交 SHA,后续 Git diff 不再解释用户提供的选项样式字符串。 */
|
||
function resolveCommit(cwd, ref) {
|
||
const commit = tryResolveCommit(cwd, ref)
|
||
if (commit) return commit
|
||
throw new Error(`无法解析 Git 提交引用:${ref}`)
|
||
}
|
||
|
||
/** 判断两个提交能否进行三点比较。 */
|
||
function hasMergeBase(cwd, baseCommit, headCommit) {
|
||
return spawnSync('git', ['merge-base', baseCommit, headCommit], { cwd, stdio: 'ignore' }).status === 0
|
||
}
|
||
|
||
/** 为本地命令选择与当前提交具有共同祖先的 v2 基线。 */
|
||
function resolveDefaultBase(cwd, headCommit) {
|
||
for (const ref of DEFAULT_BASE_REFS) {
|
||
const commit = tryResolveCommit(cwd, ref)
|
||
if (commit && hasMergeBase(cwd, commit, headCommit)) return commit
|
||
}
|
||
|
||
throw new Error('无法定位可比较的 v2 基线,请通过 --base <ref> 显式指定比较起点。')
|
||
}
|
||
|
||
/**
|
||
* 收集当前分支相对基线的文件集合。
|
||
* 显式 base/head 模式只读取提交区间,默认本地模式还会合并暂存区、工作区和未跟踪文件。
|
||
*/
|
||
function collectChangedPaths({ cwd, base, head }) {
|
||
if (Boolean(base) !== Boolean(head)) {
|
||
throw new Error('--base 与 --head 必须一起使用。')
|
||
}
|
||
|
||
const explicitRange = Boolean(base)
|
||
const headCommit = resolveCommit(cwd, head ?? 'HEAD')
|
||
const baseCommit = base ? resolveCommit(cwd, base) : resolveDefaultBase(cwd, headCommit)
|
||
if (!hasMergeBase(cwd, baseCommit, headCommit)) {
|
||
throw new Error('Git 比较起点与终点没有共同祖先,无法执行三点比较。')
|
||
}
|
||
const paths = new Set(
|
||
gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', `${baseCommit}...${headCommit}`]),
|
||
)
|
||
|
||
if (!explicitRange) {
|
||
for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', 'HEAD'])) {
|
||
paths.add(filePath)
|
||
}
|
||
for (const filePath of gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z'])) {
|
||
paths.add(filePath)
|
||
}
|
||
}
|
||
|
||
return [...paths]
|
||
.filter(filePath => {
|
||
const absolutePath = resolve(cwd, filePath)
|
||
if (!existsSync(absolutePath)) return false
|
||
try {
|
||
return lstatSync(absolutePath).isFile()
|
||
} catch {
|
||
return false
|
||
}
|
||
})
|
||
.sort()
|
||
}
|
||
|
||
/** 使用仓库 ignore 文件和 Prettier parser 推断筛出真正受格式化工具管理的文件。 */
|
||
async function selectPrettierFiles(filePaths, cwd) {
|
||
const ignorePaths = ['.gitignore', '.prettierignore'].map(filePath => resolve(cwd, filePath)).filter(existsSync)
|
||
const selected = []
|
||
|
||
for (const filePath of filePaths) {
|
||
const absolutePath = resolve(cwd, filePath)
|
||
const fileInfo = await prettier.getFileInfo(absolutePath)
|
||
if (!fileInfo.inferredParser) continue
|
||
|
||
let ignored = false
|
||
for (const ignorePath of ignorePaths) {
|
||
if ((await prettier.getFileInfo(absolutePath, { ignorePath })).ignored) {
|
||
ignored = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if (!ignored) selected.push(filePath)
|
||
}
|
||
|
||
return selected
|
||
}
|
||
|
||
/** 按保守的参数长度预算拆批,避免大型 PR 超过操作系统命令行上限。 */
|
||
function batchPaths(filePaths) {
|
||
const batches = []
|
||
let batch = []
|
||
let length = 0
|
||
|
||
for (const filePath of filePaths) {
|
||
if (batch.length > 0 && length + filePath.length + 1 > PRETTIER_ARGUMENT_BUDGET) {
|
||
batches.push(batch)
|
||
batch = []
|
||
length = 0
|
||
}
|
||
batch.push(filePath)
|
||
length += filePath.length + 1
|
||
}
|
||
|
||
if (batch.length > 0) batches.push(batch)
|
||
return batches
|
||
}
|
||
|
||
/** 通过参数数组调用项目内 Prettier,避免 shell 展开或拼接特殊文件名。 */
|
||
function runPrettier(filePaths, mode, cwd) {
|
||
if (filePaths.length === 0) {
|
||
console.log('没有需要 Prettier 处理的变更文件。')
|
||
return
|
||
}
|
||
|
||
for (const batch of batchPaths(filePaths)) {
|
||
const result = spawnSync(process.execPath, [prettierCliPath, mode, '--ignore-unknown', '--', ...batch], {
|
||
cwd,
|
||
stdio: 'inherit',
|
||
})
|
||
if (result.status !== 0) {
|
||
throw new Error(`Prettier ${mode} 失败,退出码:${result.status ?? 'unknown'}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 解析面向开发者和 CI 的稳定命令行契约。 */
|
||
function parseArguments(args) {
|
||
const options = { action: undefined, base: undefined, head: undefined }
|
||
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const argument = args[index]
|
||
if (['--check', '--write', '--list'].includes(argument)) {
|
||
if (options.action) throw new Error('只能指定 --check、--write 或 --list 中的一种操作。')
|
||
options.action = argument
|
||
continue
|
||
}
|
||
if (argument === '--base' || argument === '--head') {
|
||
const value = args[index + 1]
|
||
if (!value) throw new Error(`${argument} 缺少引用值。`)
|
||
options[argument.slice(2)] = value
|
||
index += 1
|
||
continue
|
||
}
|
||
throw new Error(`未知参数:${argument}`)
|
||
}
|
||
|
||
if (!options.action) throw new Error('必须指定 --check、--write 或 --list。')
|
||
return options
|
||
}
|
||
|
||
/** 执行变更文件选择与格式化;显式引用模式为 CI 提供确定输入。 */
|
||
async function main() {
|
||
const cwd = process.cwd()
|
||
const { action, base, head } = parseArguments(process.argv.slice(2))
|
||
const changedPaths = collectChangedPaths({ cwd, base, head })
|
||
const selectedPaths = await selectPrettierFiles(changedPaths, cwd)
|
||
|
||
if (action === '--list') {
|
||
process.stdout.write(`${JSON.stringify(selectedPaths)}\n`)
|
||
return
|
||
}
|
||
|
||
runPrettier(selectedPaths, action, cwd)
|
||
}
|
||
|
||
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
||
main().catch(error => {
|
||
console.error(error instanceof Error ? error.message : error)
|
||
process.exitCode = 1
|
||
})
|
||
}
|