diff --git a/.prettierignore b/.prettierignore index db4c6d9b..82c47fe6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,8 @@ dist -node_modules \ No newline at end of file +node_modules + +# 自动生成文件的格式由各自生成器决定。 +auto-imports.d.ts +components.d.ts +eslint-suppressions.json +/vite.config.*.timestamp-*.mjs diff --git a/.prettierrc.json b/.prettierrc.json index 5e3e7e8e..bbcccc1c 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -3,7 +3,7 @@ "bracketSpacing": true, "htmlWhitespaceSensitivity": "css", "insertPragma": false, - "jsxBracketSameLine": false, + "bracketSameLine": false, "jsxSingleQuote": true, "printWidth": 120, "proseWrap": "preserve", diff --git a/docs/code-quality.md b/docs/code-quality.md index e002216b..89451e17 100644 --- a/docs/code-quality.md +++ b/docs/code-quality.md @@ -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 --head +``` + +文件名通过 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.19,Node 24 作为推荐和主 CI | 保持兼容 job,版本提升独立处理 | -| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 | -| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则,Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 | +| 风险 | 控制方式 | 回滚边界 | +| ---------------------------- | ------------------------------------------ | ------------------------------ | +| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 | +| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit | +| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 | +| 检查范围扩展到生成物或文档 | 首阶段限制文件类型并显式 ignore | 收窄 flat config 文件匹配 | +| Node 最低版本阻断安装 | 最低保持 20.19,Node 24 作为推荐和主 CI | 保持兼容 job,版本提升独立处理 | +| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 | +| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则,Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 | 依赖迁移、规则迁移、存量代码修复、格式化和 GitHub required checks 应保持可独立评审、验证和回退,禁止合并成一个难以定位风险的大型变更。 diff --git a/eslint.config.js b/eslint.config.js index f197665a..ca10e965 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, + }, + ], }, }, { diff --git a/package.json b/package.json index af4c04f0..7f1270e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/format-changed.mjs b/scripts/format-changed.mjs new file mode 100644 index 00000000..faefb788 --- /dev/null +++ b/scripts/format-changed.mjs @@ -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 显式指定比较起点。') + } + + 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 + }) +} diff --git a/tests/config/eslint-config.spec.ts b/tests/config/eslint-config.spec.ts index 31fbf010..4837e737 100644 --- a/tests/config/eslint-config.spec.ts +++ b/tests/config/eslint-config.spec.ts @@ -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) }) diff --git a/tests/config/format-changed.spec.ts b/tests/config/format-changed.spec.ts new file mode 100644 index 00000000..d3ea0185 --- /dev/null +++ b/tests/config/format-changed.spec.ts @@ -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', '\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') + }) +}) diff --git a/yarn.lock b/yarn.lock index fa0f1ece..c65f4b2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"