chore: add incremental Prettier tooling (#555)

This commit is contained in:
InfinityPacer
2026-07-18 15:41:23 +08:00
committed by GitHub
parent 67e78d0eb1
commit f6d8ab8aeb
9 changed files with 403 additions and 53 deletions

View File

@@ -1,2 +1,8 @@
dist
node_modules
node_modules
# 自动生成文件的格式由各自生成器决定。
auto-imports.d.ts
components.d.ts
eslint-suppressions.json
/vite.config.*.timestamp-*.mjs

View File

@@ -3,7 +3,7 @@
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"bracketSameLine": false,
"jsxSingleQuote": true,
"printWidth": 120,
"proseWrap": "preserve",

View File

@@ -4,13 +4,13 @@
## 工具职责
| 工具 | 职责 | 不负责 |
| --- | --- | --- |
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
| 工具 | 职责 | 不负责 |
| ---------------------- | ------------------------------------------------------------ | -------------------------------------- |
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线SonarJS 不整包展开 recommended而只显式启用安全和正确性的高信号规则避免插件升级隐式扩大检查范围。
@@ -36,8 +36,9 @@ Prettier 独立运行ESLint 中与格式重叠的规则保持关闭。任何
yarn lint # 只读 ESLint 检查,不修改文件
yarn lint:fix # 显式执行 ESLint 安全修复
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
yarn format # 使用 Prettier 格式化目标文件
yarn format:check # 检查目标文件格式,不修改文件
yarn format # 格式化当前分支、暂存区、工作区和未跟踪文件中的受支持变更文件
yarn format:check # 检查相同变更文件,不修改文件
yarn format:all:check # 只读测量全仓格式收敛情况
yarn typecheck
yarn test:run
yarn test:coverage
@@ -104,9 +105,17 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`
4. 文件集合为空时正常通过。
本地直接运行 `yarn format``yarn format:check` 时,脚本以可用的 `upstream/v2``origin/v2` 或本地 `v2` 为基线并合并当前工作区与未跟踪文件。CI 使用显式引用保证输入可复现:
```sh
yarn format:check --base <base-sha> --head <head-sha>
```
文件名通过 Git 的 NUL 分隔输出和 Node 参数数组传递,不经过 shell 字符串拼接重命名只检查新路径删除文件、ignore 文件和 Prettier 无法推断 parser 的文件不进入格式检查。
该门禁不要求未修改的存量文件通过 Prettier但所有进入 PR diff 的受支持文件都必须通过。Prettier 是文件级格式化工具因此修改存量文件时检查的是整个文件而不是仅检查变更行。Agent 或开发者应在提交前运行同一项目脚本完成格式化CI 只验证结果,不自动提交修改。
禁止在普通业务 PR 中运行全仓 `yarn format`。触及文件格式化仍可能重排整个文件;差异过大时应拆分 commit便于 reviewer 区分机械格式与业务逻辑。
禁止在普通业务 PR 中直接运行 `prettier . --write` 或其他全仓格式写入命令。`yarn format` 只处理选择器返回的变更文件;单个存量文件被触及时仍可能整体重排,差异过大时应拆分 commit便于 reviewer 区分机械格式与业务逻辑。
具体交付拆分为:
@@ -116,11 +125,13 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
## 全仓格式门禁的启用条件
不单独安排大爆炸式格式化阶段。存量通过日常“改到即格式化”逐步收敛,并定期执行全仓 `yarn format:check` 观察剩余范围。
不单独安排大爆炸式格式化阶段。存量通过日常“改到即格式化”逐步收敛,并定期执行全仓 `yarn format:all:check` 观察剩余范围。
Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 203 个存量文件需要格式化。该数字是收敛起点而不是忽略白名单;变更文件仍必须完整通过检查,存量数量随日常修改逐步下降。
变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check
- 全仓 `yarn format:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
- `yarn lint``yarn typecheck``yarn test:coverage``yarn build` 在格式收敛后全部通过。
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
@@ -130,7 +141,7 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
```sh
yarn --frozen-lockfile
yarn format:check
yarn format:all:check
yarn lint
yarn typecheck
yarn test:coverage
@@ -153,14 +164,14 @@ PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代
## 风险与回滚
| 风险 | 控制方式 | 回滚边界 |
| --- | --- | --- |
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
| 检查范围扩展到生成物或文档 | 首阶段限制文件类型并显式 ignore | 收窄 flat config 文件匹配 |
| Node 最低版本阻断安装 | 最低保持 20.19Node 24 作为推荐和主 CI | 保持兼容 job版本提升独立处理 |
| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 |
| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 |
| 风险 | 控制方式 | 回滚边界 |
| ---------------------------- | ------------------------------------------ | ------------------------------ |
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
| 检查范围扩展到生成物或文档 | 首阶段限制文件类型并显式 ignore | 收窄 flat config 文件匹配 |
| Node 最低版本阻断安装 | 最低保持 20.19Node 24 作为推荐和主 CI | 保持兼容 job版本提升独立处理 |
| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 |
| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 |
依赖迁移、规则迁移、存量代码修复、格式化和 GitHub required checks 应保持可独立评审、验证和回退,禁止合并成一个难以定位风险的大型变更。

View File

@@ -5,32 +5,20 @@ import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
const javascriptFiles = [
'**/*.{js,mjs,cjs,jsx}',
]
const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}']
const typescriptFiles = [
'**/*.{ts,tsx,mts,cts}',
'**/*.vue',
]
const typescriptFiles = ['**/*.{ts,tsx,mts,cts}', '**/*.vue']
const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
const managedFiles = [
...javascriptFiles,
...typescriptFiles,
]
const managedFiles = [...javascriptFiles, ...typescriptFiles]
const browserFiles = [
`src/**/*.{${managedScriptExtensions},vue}`,
`examples/**/src/**/*.{${managedScriptExtensions},vue}`,
]
const nodeFiles = [
'**/*.config.{js,mjs,cjs,ts,mts,cts}',
'public/service.js',
'scripts/**/*.{js,mjs,cjs,ts,mts,cts}',
]
const nodeFiles = ['**/*.config.{js,mjs,cjs,ts,mts,cts}', 'public/service.js', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}']
// TypeScript 关闭核心 no-undef显式禁止浏览器域中的 Node-only globals保证 JS、TS 与 Vue 使用同一运行时边界。
const browserGlobalNames = new Set(Object.keys(globals.browser))
@@ -67,7 +55,8 @@ const viteGlobCallSelectors = [
const layoutGlobRestrictions = viteGlobCallSelectors.map(callSelector => ({
selector: callSelector,
message: 'import.meta.glob is not allowed in @layouts because wildcard targets cannot be proven to stay within the module boundary.',
message:
'import.meta.glob is not allowed in @layouts because wildcard targets cannot be proven to stay within the module boundary.',
}))
const sonarRules = {
@@ -105,6 +94,7 @@ export default defineConfig([
globalIgnores([
'**/node_modules/**',
'**/dist/**',
'**/dev-dist/**',
'**/coverage/**',
'**/.worktrees/**',
'**/vite.config.*.timestamp-*.mjs',
@@ -124,9 +114,12 @@ export default defineConfig([
files: ['**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
'vue/valid-v-slot': [
'error',
{
allowModifiers: true,
},
],
},
},
{

View File

@@ -17,6 +17,10 @@
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"lint:suppressions:prune": "eslint . --prune-suppressions",
"format": "node scripts/format-changed.mjs --write",
"format:check": "node scripts/format-changed.mjs --check",
"format:all:check": "prettier . --check",
"test:format-config": "vitest run tests/config/format-changed.spec.ts",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
"postinstall": "npm run build:icons",
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
@@ -125,6 +129,7 @@
"msw": "2.15.0",
"postcss": "^8.5.1",
"postcss-html": "^1.5.0",
"prettier": "^3.9.5",
"stylelint": "^16.13.2",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard-scss": "^14.0.0",

201
scripts/format-changed.mjs Normal file
View File

@@ -0,0 +1,201 @@
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 = ['upstream/v2', 'origin/v2', 'v2']
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' }))
}
/** 判断本地仓库是否存在可用于三点 diff 的提交引用。 */
function hasCommitRef(cwd, ref) {
return (
spawnSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
cwd,
stdio: 'ignore',
}).status === 0
)
}
/** 把外部引用解析为提交 SHA后续 Git diff 不再解释用户提供的选项样式字符串。 */
function resolveCommit(cwd, ref) {
try {
return execFileSync('git', ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], {
cwd,
encoding: 'utf8',
}).trim()
} catch {
throw new Error(`无法解析 Git 提交引用:${ref}`)
}
}
/** 为本地命令选择现有 v2 基线CI 应显式传入事件中的 base/head SHA。 */
function resolveDefaultBase(cwd) {
const base = DEFAULT_BASE_REFS.find(ref => hasCommitRef(cwd, ref))
if (!base) {
throw new Error('无法定位 v2 基线,请通过 --base <ref> 显式指定比较起点。')
}
return base
}
/**
* 收集当前分支相对基线的文件集合。
* 显式 base/head 模式只读取提交区间,默认本地模式还会合并暂存区、工作区和未跟踪文件。
*/
function collectChangedPaths({ cwd, base, head }) {
if (Boolean(base) !== Boolean(head)) {
throw new Error('--base 与 --head 必须一起使用。')
}
const explicitRange = Boolean(base)
const resolvedBase = base ?? resolveDefaultBase(cwd)
const resolvedHead = head ?? 'HEAD'
const baseCommit = resolveCommit(cwd, resolvedBase)
const headCommit = resolveCommit(cwd, resolvedHead)
const paths = new Set(
gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMR', '-z', `${baseCommit}...${headCommit}`]),
)
if (!explicitRange) {
for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMR', '-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
}
/** 执行变更文件选择与格式化;显式引用模式为后续 PR workflow 提供确定输入。 */
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
})
}

View File

@@ -243,21 +243,23 @@ it.each(nodeOnlyGlobalNames)('拒绝浏览器 TypeScript 使用 Node 全局变
expect(ruleIds).toContain(restrictedGlobalsRule)
})
it.each([
'example.config.mts',
'example.config.cts',
'scripts/example.mts',
'scripts/example.cts',
])('使用 TypeScript parser 解析 %s', async filePath => {
const ruleIds = await lintRuleIds('const value: string = "x"; console.log(value)', filePath)
it.each(['example.config.mts', 'example.config.cts', 'scripts/example.mts', 'scripts/example.cts'])(
'使用 TypeScript parser 解析 %s',
async filePath => {
const ruleIds = await lintRuleIds('const value: string = "x"; console.log(value)', filePath)
expect(ruleIds).not.toContain(null)
})
expect(ruleIds).not.toContain(null)
},
)
it('忽略 Vite 生成的临时配置模块', async () => {
await expect(eslint.isPathIgnored('vite.config.ts.timestamp-1784340502076-0129cef6d3bbd8.mjs')).resolves.toBe(true)
})
it('忽略 Vite PWA 开发构建产物', async () => {
await expect(eslint.isPathIgnored('dev-dist/registerSW.js')).resolves.toBe(true)
})
it('不忽略普通 MJS 源码', async () => {
await expect(eslint.isPathIgnored('scripts/example.mjs')).resolves.toBe(false)
})

View File

@@ -0,0 +1,127 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const selectorScript = resolve(process.cwd(), 'scripts/format-changed.mjs')
const temporaryRepositories: string[] = []
function git(repository: string, ...args: string[]) {
return execFileSync('git', args, { cwd: repository, encoding: 'utf8' }).trim()
}
function write(repository: string, filePath: string, content: string) {
const absolutePath = resolve(repository, filePath)
mkdirSync(dirname(absolutePath), { recursive: true })
writeFileSync(absolutePath, content)
}
function createRepository() {
const repository = mkdtempSync(resolve(tmpdir(), 'moviepilot-format-changed-'))
temporaryRepositories.push(repository)
git(repository, 'init', '--initial-branch=v2')
git(repository, 'config', 'user.name', 'Format Test')
git(repository, 'config', 'user.email', 'format-test@example.invalid')
write(repository, '.gitignore', 'coverage/\n')
write(repository, '.prettierignore', 'generated/\n/vite.config.*.timestamp-*.mjs\n')
write(repository, 'src/modified.ts', "export const value = 'before'\n")
write(repository, 'src/rename old.ts', "export const renamed = 'before'\n")
write(repository, 'src/deleted.ts', 'export const deleted = true\n')
write(repository, 'coverage/tracked.ts', 'export const generated = true\n')
git(repository, 'add', '.')
git(repository, 'add', '--force', 'coverage/tracked.ts')
git(repository, 'commit', '-m', 'base')
return repository
}
function listSelected(repository: string, ...args: string[]) {
const output = execFileSync(process.execPath, [selectorScript, '--list', ...args], {
cwd: repository,
encoding: 'utf8',
})
return JSON.parse(output) as string[]
}
afterEach(() => {
for (const repository of temporaryRepositories.splice(0)) {
rmSync(repository, { recursive: true, force: true })
}
})
describe('Prettier 变更文件选择器', () => {
it('按 base 与 head 选择受支持文件并排除删除、忽略和符号链接', () => {
const repository = createRepository()
git(repository, 'switch', '-c', 'feature')
write(repository, 'src/modified.ts', "export const value='after'\n")
git(repository, 'mv', 'src/rename old.ts', 'src/rename [new].ts')
git(repository, 'rm', 'src/deleted.ts')
write(repository, 'src/new file.vue', '<template><div>new</div></template>\n')
write(repository, 'src/line\nbreak.ts', 'export const lineBreak=true\n')
symlinkSync('modified.ts', resolve(repository, 'src/linked.ts'))
write(repository, 'generated/ignored.ts', 'export const ignored=true\n')
write(repository, 'coverage/tracked.ts', 'export const generated=false\n')
write(repository, 'notes.bin', 'not prettier source\n')
git(repository, 'add', '-A')
git(repository, 'commit', '-m', 'feature')
expect(listSelected(repository, '--base', 'v2', '--head', 'HEAD')).toEqual([
'src/line\nbreak.ts',
'src/modified.ts',
'src/new file.vue',
'src/rename [new].ts',
])
})
it('本地模式合并分支、工作区和未跟踪文件,并排除删除、忽略和符号链接', () => {
const repository = createRepository()
git(repository, 'switch', '-c', 'feature')
write(repository, 'src/modified.ts', "export const value='working-tree'\n")
write(repository, 'src/untracked file.ts', 'export const untracked=true\n')
write(repository, 'scripts/feature.timestamp-parser.mjs', 'export const parser=true\n')
write(repository, 'vite.config.ts.timestamp-123-generated.mjs', 'export const temporary=true\n')
symlinkSync('modified.ts', resolve(repository, 'src/untracked-link.ts'))
write(repository, 'generated/untracked.ts', 'export const ignored=true\n')
git(repository, 'rm', 'src/deleted.ts')
expect(listSelected(repository)).toEqual([
'scripts/feature.timestamp-parser.mjs',
'src/modified.ts',
'src/untracked file.ts',
])
})
it('没有受支持的变更文件时正常通过', () => {
const repository = createRepository()
expect(listSelected(repository, '--base', 'HEAD', '--head', 'HEAD')).toEqual([])
expect(
spawnSync(process.execPath, [selectorScript, '--check', '--base', 'HEAD', '--head', 'HEAD'], {
cwd: repository,
}).status,
).toBe(0)
})
it('显式比较必须同时提供 base 与 head', () => {
const repository = createRepository()
const result = spawnSync(process.execPath, [selectorScript, '--check', '--base', 'HEAD'], {
cwd: repository,
encoding: 'utf8',
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('--base 与 --head 必须一起使用。')
})
it('通过参数数组安全格式化特殊文件名', () => {
const repository = createRepository()
git(repository, 'switch', '-c', 'feature')
write(repository, '--special [file].ts', 'export const special=true\n')
execFileSync(process.execPath, [selectorScript, '--write'], { cwd: repository })
expect(readFileSync(resolve(repository, '--special [file].ts'), 'utf8')).toBe('export const special = true;\n')
})
})

View File

@@ -6827,6 +6827,11 @@ prelude-ls@^1.2.1:
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@^3.9.5:
version "3.9.5"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.5.tgz#4fec97736e33b9d0b620b48914fe93b530e835ad"
integrity sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==
pretty-bytes@^5.3.0:
version "5.6.0"
resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz"